diff --git a/MMM-JsonTable.js b/MMM-JsonTable.js index 9f9a577..5f30f38 100644 --- a/MMM-JsonTable.js +++ b/MMM-JsonTable.js @@ -2,8 +2,6 @@ Module.register("MMM-JsonTable", { jsonData: null, - - // Default module config. defaults: { url: "", arrayName: null, @@ -14,141 +12,211 @@ Module.register("MMM-JsonTable", { tryFormatDate: false, updateInterval: 15000, animationSpeed: 500, - descriptiveRow: null + descriptiveRow: null, + httpTimeout: 15000, + requestHeaders: {}, + tlsInsecure: false, + maxRedirects: 3 }, - start () { this.getJson(); this.scheduleUpdate(); }, - scheduleUpdate () { const self = this; setInterval(() => { self.getJson(); }, this.config.updateInterval); }, - - // Request node_helper to get json from url getJson () { - this.sendSocketNotification("MMM-JsonTable_GET_JSON", { + const options = { + timeout: this.config.httpTimeout, + headers: this.config.requestHeaders, + tlsInsecure: this.config.tlsInsecure, + maxRedirects: this.config.maxRedirects + }; + const payload = {id: this.identifier, url: this.config.url, - id: this.identifier - }); + options}; + this.sendSocketNotification("MMM-JsonTable_GET_JSON", payload); }, - socketNotificationReceived (notification, payload) { if (notification === "MMM-JsonTable_JSON_RESULT") { - // Only continue if the notification came from the request we made - // This way we can load the module more than once - if (payload.id === this.identifier) { + const isMine = payload && payload.id === this.identifier || payload && payload.url === this.config.url; + if (isMine) { this.jsonData = payload.data; this.updateDom(this.config.animationSpeed); } + return; + } + if (notification === "MMM-JsonTable_JSON_ERROR") { + const isMineError = payload && payload.id === this.identifier || payload && payload.url === this.config.url; + if (isMineError) { + this.jsonData = null; + this.updateDom(this.config.animationSpeed); + } } }, - - // Override dom generator. + normalizePathToParts (rawPathInput) { + const raw = String(rawPathInput); + const normalizedBracket = raw.replace(/\[(?\d+)\]/gu, ".$"); + const normalized = normalizedBracket.replace(/^\./u, ""); + return normalized.split("."); + }, + resolvePath (data, pathStr) { + try { + if (data === null || typeof data === "undefined") { + return null; + } + if (!pathStr) { + return data; + } + const parts = this.normalizePathToParts(pathStr); + let node = data; + for (let idx = 0; idx < parts.length; idx += 1) { + const raw = parts[idx]; + let key = raw; + if ((/^\d+$/u).test(raw)) { + key = Number(raw); + } + if (node === null || typeof node === "undefined" || !(key in node)) { + return null; + } + node = node[key]; + } + return node; + } catch { + return null; + } + }, + unwrapSingleNestedArray (arr) { + if (Array.isArray(arr) && arr.length === 1 && Array.isArray(arr[0])) { + return arr[0]; + } + return arr; + }, + resolveArrayWithFallbacks (root, paths) { + let list = []; + if (Array.isArray(paths)) { + list = paths; + } else if (paths) { + list = [paths]; + } + for (let idx = 0; idx < list.length; idx += 1) { + const pathStr = list[idx]; + const node = this.unwrapSingleNestedArray(this.resolvePath(root, pathStr)); + if (Array.isArray(node)) { + return node; + } + } + return null; + }, + resolveItemsFromConfig () { + let candidates = null; + if (this.config.arrayName && this.config.arrayName.length) { + candidates = this.config.arrayName; + } else { + candidates = ["data.Forbruk", "Forbruk.Forbruk", "Forbruk"]; + } + let items = this.resolveArrayWithFallbacks(this.jsonData, candidates); + if (!Array.isArray(items)) { + const maybeRoot = this.unwrapSingleNestedArray(this.jsonData); + if (Array.isArray(maybeRoot)) { + items = maybeRoot; + } + } + return items; + }, + buildTable (items) { + const table = document.createElement("table"); + const tbody = document.createElement("tbody"); + for (let idx = 0; idx < items.length; idx += 1) { + const row = this.getTableRow(items[idx]); + tbody.appendChild(row); + } + if (this.config.descriptiveRow) { + const headerEl = table.createTHead(); + headerEl.innerHTML = this.config.descriptiveRow; + } + table.appendChild(tbody); + return table; + }, getDom () { const wrapper = document.createElement("div"); wrapper.className = "xsmall"; - - if (!this.jsonData) { - wrapper.innerHTML = "Awaiting json data..."; + try { + if (!this.jsonData) { + wrapper.innerHTML = "Awaiting json data..."; + return wrapper; + } + const items = this.resolveItemsFromConfig(); + if (!Array.isArray(items)) { + wrapper.innerHTML = this.config.noDataText; + return wrapper; + } + const table = this.buildTable(items); + wrapper.appendChild(table); + return wrapper; + } catch { + wrapper.innerHTML = "Error rendering table."; return wrapper; } - - const table = document.createElement("table"); - const tbody = document.createElement("tbody"); - - let items = []; - if (this.config.arrayName) { - items = this.jsonData[this.config.arrayName]; + }, + buildCell (key, value) { + const cell = document.createElement("td"); + let valueToDisplay = ""; + if (key === "icon") { + cell.classList.add("fa", value); + } else if (this.config.tryFormatDate) { + valueToDisplay = this.getFormattedValue(value); } else { - items = this.jsonData; + valueToDisplay = value; } - - // Check if items is of type array - if (!(items instanceof Array)) { - wrapper.innerHTML = this.config.noDataText; - return wrapper; + let textContent = ""; + if (valueToDisplay === null || typeof valueToDisplay === "undefined") { + textContent = ""; + } else { + textContent = String(valueToDisplay); } - - items.forEach((element) => { - const row = this.getTableRow(element); - tbody.appendChild(row); - }); - - // Add in Descriptive Row Header - if (this.config.descriptiveRow) { - const header = table.createTHead(); - header.innerHTML = this.config.descriptiveRow; + const textNode = document.createTextNode(textContent); + if (this.config.size > 0 && this.config.size < 9) { + const headingEl = document.createElement(`H${String(this.config.size)}`); + headingEl.appendChild(textNode); + cell.appendChild(headingEl); + } else { + cell.appendChild(textNode); } - - table.appendChild(tbody); - wrapper.appendChild(table); - return wrapper; + return cell; }, - getTableRow (jsonObject) { const row = document.createElement("tr"); - Object.entries(jsonObject).forEach(([key, value]) => { - const cell = document.createElement("td"); - - let valueToDisplay = ""; - let cellValue = ""; - - if (value.constructor === Object) { - if ("value" in value) { - cellValue = value.value; - } else { - cellValue = ""; + let entries = []; + const hasKeep = Array.isArray(this.config.keepColumns) && this.config.keepColumns.length > 0; + if (hasKeep) { + const filtered = []; + for (let idx = 0; idx < this.config.keepColumns.length; idx += 1) { + const key = this.config.keepColumns[idx]; + if (Object.hasOwn(jsonObject || {}, key)) { + filtered.push([key, jsonObject[key]]); } - - if ("color" in value) { - cell.style.color = value.color; - } - } else { - cellValue = value; - } - - if (key === "icon") { - cell.classList.add("fa", cellValue); - } else if (this.config.tryFormatDate) { - valueToDisplay = this.getFormattedValue(cellValue); - } else if ( - this.config.keepColumns.length === 0 || - this.config.keepColumns.indexOf(key) >= 0 - ) { - valueToDisplay = cellValue; } - - const cellText = document.createTextNode(valueToDisplay); - - if (this.config.size > 0 && this.config.size < 9) { - const heading = document.createElement(`H${this.config.size}`); - heading.appendChild(cellText); - cell.appendChild(heading); - } else { - cell.appendChild(cellText); - } - + entries = filtered; + } else { + entries = Object.entries(jsonObject || {}); + } + for (let idx = 0; idx < entries.length; idx += 1) { + const [key, value] = entries[idx]; + const cell = this.buildCell(key, value); row.appendChild(cell); - }); + } return row; }, - - // Format a date string or return the input getFormattedValue (input) { const momentObj = moment(input); if (typeof input === "string" && momentObj.isValid()) { - // Show a formatted time if it occures today - if ( - momentObj.isSame(new Date(Date.now()), "day") && - momentObj.hours() !== 0 && - momentObj.minutes() !== 0 && - momentObj.seconds() !== 0 - ) { + const isToday = momentObj.isSame(new Date(), "day"); + const notMidnight = momentObj.hours() !== 0 || momentObj.minutes() !== 0 || momentObj.seconds() !== 0; + if (isToday && notMidnight) { return momentObj.format("HH:mm:ss"); } return momentObj.format("YYYY-MM-DD"); diff --git a/node_helper.js b/node_helper.js index 911a1de..99dbeba 100644 --- a/node_helper.js +++ b/node_helper.js @@ -1,29 +1,278 @@ const NodeHelper = require("node_helper"); const Log = require("logger"); +const https = require("node:https"); +const http = require("node:http"); +const {URL} = require("node:url"); + +const getLib = (isHttps) => { + if (isHttps) { + return https; + } return http; +}; + +const defaultPort = ({parsed, isHttps}) => { + let {port} = parsed; + if (!port) { + if (isHttps) { + port = 443; + } else { + port = 80; + } + } + return port; +}; + +const collectBody = (res) => new Promise((resolve) => { + let body = ""; res.setEncoding("utf8"); + res.on("data", (chunk) => { + body += chunk; + }); + res.on("end", () => { + resolve(body); + }); +}); + +const handleRedirect = ({currentUrl, redirectsLeft, res, visited}) => { + const output = {nextUrl: null, + error: null}; + const hasLocation = Boolean(res && res.headers && res.headers.location); + if (!hasLocation) { + return output; + } + if (redirectsLeft <= 0) { + output.error = new Error(`Too many redirects. Last: ${currentUrl}`); return output; + } + const nextUrl = new URL(res.headers.location, currentUrl).toString(); + if (Array.isArray(visited)) { + visited.push(currentUrl); + } + output.nextUrl = nextUrl; return output; +}; + +const makeRequestOptions = ({parsed, isHttpsFlag, headers, tlsInsecure}) => { + const options = { + protocol: parsed.protocol, + hostname: parsed.hostname, + port: defaultPort({parsed, + isHttps: isHttpsFlag}), + path: parsed.pathname + (parsed.search || ""), + method: "GET", + headers: headers || {} + }; + if (isHttpsFlag && tlsInsecure) { + options.rejectUnauthorized = false; + } + return options; +}; + +const parseJsonOrError = (body) => { + try { + return {ok: true, + data: JSON.parse(body)}; + } catch (err) { + let msg = ""; if (err && err.message) { + msg = err.message; + } else { + msg = String(err); + } + return {ok: false, + error: new Error(`Failed to parse JSON: ${msg}`)}; + } +}; + +const failWithStatus = async ({res, statusCode, reject}) => { + const body = await collectBody(res); + let snippet = ""; if (body) { + snippet = body.slice(0, 300); + } else { + snippet = ""; + } + reject(new Error(`HTTP ${statusCode}: ${snippet}`)); +}; + +const handle2xx = async ({res, resolve, reject}) => { + const body = await collectBody(res); + const parsed = parseJsonOrError(body); + if (parsed.ok) { + resolve(parsed.data); + } else { + reject(parsed.error); + } +}; + +const handle3xxOrContinue = ({currentUrl, redirectsLeft, res, visited, requestFn, resolve, reject}) => { + const {nextUrl, error} = handleRedirect({currentUrl, + redirectsLeft, + res, + visited}); + if (error) { + res.resume(); reject(error); return true; + } + if (nextUrl) { + res.resume(); requestFn({currentUrl: nextUrl, + redirectsLeft: redirectsLeft - 1, + visited, + resolve, + reject}); return true; + } + return false; +}; + +const handleResponse = async ({res, currentUrl, redirectsLeft, visited, requestFn, resolve, reject}) => { + const {statusCode = 0} = res; + if (statusCode >= 300 && statusCode < 400) { + const redirected = handle3xxOrContinue({currentUrl, + redirectsLeft, + res, + visited, + requestFn, + resolve, + reject}); if (redirected) { + return; + } + } + if (statusCode < 200 || statusCode >= 300) { + await failWithStatus({res, + statusCode, + reject}); return; + } + await handle2xx({res, + resolve, + reject}); +}; + +const buildResponseHandler = (args) => (res) => handleResponse({...args, + res}); + +const tryParseUrl = (currentUrl) => { + try { + return {ok: true, + parsed: new URL(currentUrl)}; + } catch { + return {ok: false, + error: new Error(`Invalid URL: ${currentUrl}`)}; + } +}; + +const doRequest = ({lib, requestOptions, timeout, onResponse, reject}) => { + const req = lib.request(requestOptions, onResponse); + req.on("error", (err) => { + reject(err); + }); + req.setTimeout(timeout, () => { + req.destroy(new Error(`Request timeout after ${timeout}ms`)); + }); + req.end(); +}; + +const requestOnce = ({currentUrl, redirectsLeft, timeout, headers, tlsInsecure, visited, resolve, reject}) => { + const parsedResult = tryParseUrl(currentUrl); + if (!parsedResult.ok) { + reject(parsedResult.error); return; + } + const {parsed} = parsedResult; + const isHttpsFlag = parsed.protocol === "https:"; + const lib = getLib(isHttpsFlag); + const requestOptions = makeRequestOptions({parsed, + isHttpsFlag, + headers, + tlsInsecure}); + const onResponse = buildResponseHandler({ + currentUrl, + redirectsLeft, + visited, + requestFn: (args) => requestOnce({currentUrl: args.currentUrl, + redirectsLeft: args.redirectsLeft, + timeout, + headers, + tlsInsecure, + visited, + resolve, + reject}), + resolve, + reject + }); + doRequest({lib, + requestOptions, + timeout, + onResponse, + reject}); +}; + +const normalizeOptions = (payload) => { + const dataIn = payload || {}; + const {id, url, options = {}} = dataIn; + const {timeout: timeoutRaw = 15000, headers = {}, tlsInsecure: tlsInsecureRaw = false, maxRedirects: maxRedirectsRaw = 3} = options; + let timeout = 15000; if (typeof timeoutRaw === "number") { + timeout = timeoutRaw; + } + const tlsInsecure = Boolean(tlsInsecureRaw); + let maxRedirects = 3; if (typeof maxRedirectsRaw === "number") { + maxRedirects = maxRedirectsRaw; + } + return {id, + url, + timeout, + headers, + tlsInsecure, + maxRedirects}; +}; module.exports = NodeHelper.create({ start () { - Log.log("MMM-JsonTable helper started..."); + Log.log("MMM-JsonTable helper started…"); }, - getJson (payload) { - const self = this; - - fetch(payload.url) - .then((response) => response.json()) - .then((json) => { - // Send the json data back with the url to distinguish it on the receiving part - self.sendSocketNotification("MMM-JsonTable_JSON_RESULT", { - id: payload.id, - data: json - }); - }); - }, - - // Subclass socketNotificationReceived received. socketNotificationReceived (notification, payload) { if (notification === "MMM-JsonTable_GET_JSON") { this.getJson(payload); } + }, + + async getJson (payload) { + const {id, url, timeout, headers, tlsInsecure, maxRedirects} = normalizeOptions(payload); + if (!url) { + Log.error("[MMM-JsonTable] getJson: missing url"); this.sendSocketNotification("MMM-JsonTable_JSON_ERROR", {id, + url, + error: "Missing URL"}); return; + } + Log.log(`[MMM-JsonTable] Fetching: ${url}`); + try { + const data = await this.httpGetJson(url, {timeout, + headers, + tlsInsecure, + maxRedirects}); + this.sendSocketNotification("MMM-JsonTable_JSON_RESULT", {id, + url, + data}); + Log.log(`[MMM-JsonTable] OK: ${url}`); + } catch (err) { + let msg = ""; if (err && err.message) { + msg = err.message; + } else { + msg = String(err); + } + Log.error(`[MMM-JsonTable] ERROR fetching ${url}: ${msg}`); + this.sendSocketNotification("MMM-JsonTable_JSON_ERROR", {id, + url, + error: msg}); + } + }, + + httpGetJson (targetUrl, {timeout, headers, tlsInsecure, maxRedirects}) { + return new Promise((resolve, reject) => { + const visited = []; + let startRedirects = 3; if (typeof maxRedirects === "number") { + startRedirects = maxRedirects; + } + requestOnce({currentUrl: targetUrl, + redirectsLeft: startRedirects, + timeout, + headers, + tlsInsecure, + visited, + resolve, + reject}); + }); } });