From 6336e7568fe0b7b61ce8281130f0e1bfb16c099d Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Thu, 5 Dec 2024 11:26:23 -0800 Subject: [PATCH 1/7] feature/IO-3052-Skia-Canvas-Handler: Initial commit Signed-off-by: Dave Richer --- Dockerfile | 14 ++- package-lock.json | 98 ++++++++++++++++++++ package.json | 1 + server/render/canvas-handler.js | 153 +++++++++++++++++++++++--------- server/routes/renderRoutes.js | 5 +- 5 files changed, 227 insertions(+), 44 deletions(-) diff --git a/Dockerfile b/Dockerfile index b1d253808..16e9d2159 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,6 @@ RUN dnf install -y git \ && dnf install -y nodejs \ && dnf clean all - # Install dependencies required by node-canvas RUN dnf install -y \ gcc \ @@ -19,9 +18,22 @@ RUN dnf install -y \ libpng-devel \ make \ python3 \ + fontconfig \ + freetype \ python3-pip \ + wget \ + unzip \ && dnf clean all +# Install Montserrat fonts +RUN cd /tmp \ + && wget https://images.imex.online/fonts/montserrat.zip -O montserrat.zip \ + && unzip montserrat.zip -d montserrat \ + && mv montserrat/montserrat/*.ttf /usr/share/fonts \ + && fc-cache -fv \ + && rm -rf /tmp/montserrat /tmp/montserrat.zip \ + && echo "Montserrat fonts installed and cached successfully." + # Set the working directory WORKDIR /app diff --git a/package-lock.json b/package-lock.json index 6d1d243cc..cceafbf49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,7 @@ "recursive-diff": "^1.0.9", "redis": "^4.7.0", "rimraf": "^6.0.1", + "skia-canvas": "^2.0.0", "soap": "^1.1.6", "socket.io": "^4.8.1", "socket.io-adapter": "^2.5.5", @@ -3734,6 +3735,15 @@ "node": ">=6" } }, + "node_modules/cargo-cp-artifact": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/cargo-cp-artifact/-/cargo-cp-artifact-0.1.9.tgz", + "integrity": "sha512-6F+UYzTaGB+awsTXg0uSJA1/b/B3DDJzpKVRu0UmyI7DmNeaAl2RFHuTGIN6fEgpadRxoXGb7gbC1xo4C3IdyA==", + "license": "MIT", + "bin": { + "cargo-cp-artifact": "bin/cargo-cp-artifact.js" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -6757,6 +6767,12 @@ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==" }, + "node_modules/parenthesis": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/parenthesis/-/parenthesis-3.1.8.tgz", + "integrity": "sha512-KF/U8tk54BgQewkJPvB4s/US3VQY68BRDpH638+7O/n58TpnwiwnOtGIOsT2/i+M78s61BBpeC83STB88d8sqw==", + "license": "MIT" + }, "node_modules/parse5": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", @@ -6789,6 +6805,12 @@ "node": ">= 0.8" } }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "license": "MIT" + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -7459,6 +7481,73 @@ "is-arrayish": "^0.3.1" } }, + "node_modules/skia-canvas": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/skia-canvas/-/skia-canvas-2.0.0.tgz", + "integrity": "sha512-wpYkmr9mCxBme5HAnlm6YOEiuaN9tIm9CL+HN8e5AFD4K2FAJXCcWiWvc9+LM8jUXt+AyYXgiwUTBxdQ6P+PEg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.11", + "cargo-cp-artifact": "^0.1", + "glob": "^11.0.0", + "path-browserify": "^1.0.1", + "simple-get": "^4.0.1", + "string-split-by": "^1.0.0" + } + }, + "node_modules/skia-canvas/node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/skia-canvas/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/skia-canvas/node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/slick": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/slick/-/slick-1.12.2.tgz", @@ -7851,6 +7940,15 @@ } ] }, + "node_modules/string-split-by": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string-split-by/-/string-split-by-1.0.0.tgz", + "integrity": "sha512-KaJKY+hfpzNyet/emP81PJA9hTVSfxNLS9SFTWxdCnnW1/zOOwiV248+EfoX7IQFcBaOp4G5YE6xTJMF+pLg6A==", + "license": "MIT", + "dependencies": { + "parenthesis": "^3.1.5" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", diff --git a/package.json b/package.json index 4fd96c3ff..5889ea532 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "recursive-diff": "^1.0.9", "redis": "^4.7.0", "rimraf": "^6.0.1", + "skia-canvas": "^2.0.0", "soap": "^1.1.6", "socket.io": "^4.8.1", "socket.io-adapter": "^2.5.5", diff --git a/server/render/canvas-handler.js b/server/render/canvas-handler.js index 0af2a7f53..df3cc8f02 100644 --- a/server/render/canvas-handler.js +++ b/server/render/canvas-handler.js @@ -1,43 +1,28 @@ const { createCanvas } = require("canvas"); +const { Canvas, FontLibrary } = require("skia-canvas"); +const { performance } = require("perf_hooks"); const Chart = require("chart.js/auto"); const logger = require("../utils/logger"); const { backgroundColors, borderColors } = require("./canvas-colors"); const { isObject, defaultsDeep, isNumber } = require("lodash"); -exports.canvastest = function (req, res) { - //console.log("Incoming test request.", req); - res.status(200).send("OK"); -}; +try { + FontLibrary.use("Montserrat", [ + "/usr/share/fonts/Montserrat-Regular.ttf", + "/usr/share/fonts/Montserrat-Bold.ttf", + "/usr/share/fonts/Montserrat-Italic.ttf" + ]); +} catch (error) { + console.error( + "Error loading fonts Skia Canvas Fonts, please be sure to install Montserrat font package", + error.message + ); +} -exports.canvas = function (req, res) { - const { w, h, values, keys, override } = req.body; - //console.log("Incoming Canvas Request:", w, h, values, keys, override); - logger.log("inbound-canvas-creation", "debug", "jsr", null, { w, h, values, keys, override }); - // Gate required values - if (!values || !keys) { - res.status(400).send("Missing required data"); - return; - } - - // Override must be an object if it exists - if (override && !isObject(override)) { - res.status(400).send("Override must be an object"); - return; - } - - // Set the default Width and Height - let [width, height] = [500, 275]; - - // Allow for custom width and height - if (isNumber(w)) { - width = w; - } - if (isNumber(h)) { - height = h; - } - - const configuration = { +// Utility to create a chart configuration +const getChartConfiguration = (keys, values, override) => { + const defaultConfiguration = { type: "doughnut", data: { labels: keys, @@ -53,6 +38,7 @@ exports.canvas = function (req, res) { options: { devicePixelRatio: 4, responsive: false, + animation: false, maintainAspectRatio: true, circumference: 180, rotation: -90, @@ -73,21 +59,106 @@ exports.canvas = function (req, res) { } }; - // If we have a valid override object, merge it with the default configuration object. - // This allows for you to override the default configuration with a custom one. - const defaults = () => { - if (!override || !isObject(override)) { - return configuration; - } - return defaultsDeep(override, configuration); - }; + return defaultsDeep(override || {}, defaultConfiguration); +}; + +// Utility to validate input +const validateCanvasInput = ({ values, keys, override }, res) => { + if (!Array.isArray(values) || !Array.isArray(keys)) { + res.status(400).send("Invalid input: 'values' and 'keys' must be arrays."); + return false; + } + + if (values.some((value) => typeof value !== "number")) { + res.status(400).send("Invalid input: 'values' must be an array of numbers."); + return false; + } + + if (keys.some((key) => typeof key !== "string")) { + res.status(400).send("Invalid input: 'keys' must be an array of strings."); + return false; + } + + if (override && !isObject(override)) { + res.status(400).send("Override must be an object"); + return false; + } + + return true; +}; + +exports.canvastest = function (req, res) { + //console.log("Incoming test request.", req); + res.status(200).send("OK"); +}; + +exports.canvas = function (req, res) { + const startTime = performance.now(); + + const { w, h, values, keys, override } = req.body; + //console.log("Incoming Canvas Request:", w, h, values, keys, override); + logger.log("inbound-canvas-creation", "debug", "jsr", null, { w, h, values, keys, override }); + + if (!validateCanvasInput(req.body, res)) return; + + // Set the default Width and Height + let [width, height] = [500, 275]; + + // Allow for custom width and height + if (isNumber(w)) { + width = w; + } + if (isNumber(h)) { + height = h; + } + + const configuration = getChartConfiguration(keys, values, override); res.status(200).send( (() => { const canvas = createCanvas(width, height); const ctx = canvas.getContext("2d"); - new Chart(ctx, defaults()); + new Chart(ctx, configuration); return canvas.toDataURL(); })() ); + console.log("Canvas generation time:", performance.now() - startTime, "ms"); +}; + +exports.canvasSkia = async function (req, res) { + const startTime = performance.now(); + const { w, h, values, keys, override } = req.body; + + // Log incoming request for debugging + logger.log("inbound-canvas-creation", "debug", "jsr", null, { w, h, values, keys, override }); + + if (!validateCanvasInput(req.body, res)) return; + + // Default width and height + const width = typeof w === "number" && w > 0 ? w : 500; + const height = typeof h === "number" && h > 0 ? h : 275; + + const configuration = getChartConfiguration(keys, values, override); + + try { + // Create a canvas and get the 2D rendering context + const canvas = new Canvas(width, height); + const ctx = canvas.getContext("2d"); + + // Render the chart + new Chart(ctx, configuration); + + // Convert the canvas to a Base64-encoded image + const chartImage = (await canvas.toBuffer("image/png")).toString("base64"); + const dataURL = `data:image/png;base64,${chartImage}`; + + // Send the Base64-encoded image as the response + res.status(200).send(dataURL); + console.log("Canvas generation time:", performance.now() - startTime, "ms"); + } catch (error) { + // Log and handle rendering errors + logger.log("canvas-error", "error", "jsr", null, { error: error.message }); + console.error("Error generating chart:", error.message); + res.status(500).send("Failed to generate canvas."); + } }; diff --git a/server/routes/renderRoutes.js b/server/routes/renderRoutes.js index 13288d989..083d58b39 100644 --- a/server/routes/renderRoutes.js +++ b/server/routes/renderRoutes.js @@ -2,10 +2,11 @@ const express = require("express"); const router = express.Router(); const { inlinecss } = require("../render/inlinecss"); const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware"); -const { canvas } = require("../render/canvas-handler"); +const { canvas, canvasSkia } = require("../render/canvas-handler"); // Define the route for inline CSS rendering router.post("/inlinecss", validateFirebaseIdTokenMiddleware, inlinecss); -router.post("/canvas", validateFirebaseIdTokenMiddleware, canvas); +router.post("/canvas", canvas); +router.post("/canvas-skia", canvasSkia); module.exports = router; From 86f3179bc09dbb16b8d222ee6db4e4fbca42b262 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Thu, 5 Dec 2024 11:26:36 -0800 Subject: [PATCH 2/7] feature/IO-3052-Skia-Canvas-Handler: Initial commit Signed-off-by: Dave Richer --- server/routes/renderRoutes.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/routes/renderRoutes.js b/server/routes/renderRoutes.js index 083d58b39..d1b64566e 100644 --- a/server/routes/renderRoutes.js +++ b/server/routes/renderRoutes.js @@ -6,7 +6,7 @@ const { canvas, canvasSkia } = require("../render/canvas-handler"); // Define the route for inline CSS rendering router.post("/inlinecss", validateFirebaseIdTokenMiddleware, inlinecss); -router.post("/canvas", canvas); -router.post("/canvas-skia", canvasSkia); +router.post("/canvas", validateFirebaseIdTokenMiddleware, canvas); +router.post("/canvas-skia", validateFirebaseIdTokenMiddleware, canvasSkia); module.exports = router; From 50c99f7a1e009808d26c385336783d8b9ee09cd7 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Thu, 5 Dec 2024 11:52:14 -0800 Subject: [PATCH 3/7] feature/IO-3052-Skia-Canvas-Handler: Cleanup Signed-off-by: Dave Richer --- .../validateCanvasInputMiddleware.js | 25 +++++++++++ server/render/canvas-handler.js | 42 ++----------------- server/routes/renderRoutes.js | 5 ++- 3 files changed, 31 insertions(+), 41 deletions(-) create mode 100644 server/middleware/validateCanvasInputMiddleware.js diff --git a/server/middleware/validateCanvasInputMiddleware.js b/server/middleware/validateCanvasInputMiddleware.js new file mode 100644 index 000000000..daf3775ba --- /dev/null +++ b/server/middleware/validateCanvasInputMiddleware.js @@ -0,0 +1,25 @@ +const { isObject } = require("lodash"); + +const validateCanvasInputMiddleware = (req, res, next) => { + const { values, keys, override } = req.body; + + if (!Array.isArray(values) || !Array.isArray(keys)) { + return res.status(400).send("Invalid input: 'values' and 'keys' must be arrays."); + } + + if (values.some((value) => typeof value !== "number")) { + return res.status(400).send("Invalid input: 'values' must be an array of numbers."); + } + + if (keys.some((key) => typeof key !== "string")) { + return res.status(400).send("Invalid input: 'keys' must be an array of strings."); + } + + if (override && !isObject(override)) { + return res.status(400).send("Override must be an object"); + } + + next(); // Proceed to the next middleware or route handler +}; + +module.exports = validateCanvasInputMiddleware; diff --git a/server/render/canvas-handler.js b/server/render/canvas-handler.js index df3cc8f02..b35926935 100644 --- a/server/render/canvas-handler.js +++ b/server/render/canvas-handler.js @@ -1,11 +1,9 @@ const { createCanvas } = require("canvas"); const { Canvas, FontLibrary } = require("skia-canvas"); -const { performance } = require("perf_hooks"); const Chart = require("chart.js/auto"); -const logger = require("../utils/logger"); const { backgroundColors, borderColors } = require("./canvas-colors"); -const { isObject, defaultsDeep, isNumber } = require("lodash"); +const { defaultsDeep, isNumber } = require("lodash"); try { FontLibrary.use("Montserrat", [ @@ -62,45 +60,16 @@ const getChartConfiguration = (keys, values, override) => { return defaultsDeep(override || {}, defaultConfiguration); }; -// Utility to validate input -const validateCanvasInput = ({ values, keys, override }, res) => { - if (!Array.isArray(values) || !Array.isArray(keys)) { - res.status(400).send("Invalid input: 'values' and 'keys' must be arrays."); - return false; - } - - if (values.some((value) => typeof value !== "number")) { - res.status(400).send("Invalid input: 'values' must be an array of numbers."); - return false; - } - - if (keys.some((key) => typeof key !== "string")) { - res.status(400).send("Invalid input: 'keys' must be an array of strings."); - return false; - } - - if (override && !isObject(override)) { - res.status(400).send("Override must be an object"); - return false; - } - - return true; -}; - exports.canvastest = function (req, res) { - //console.log("Incoming test request.", req); res.status(200).send("OK"); }; exports.canvas = function (req, res) { - const startTime = performance.now(); + const { logger } = req; const { w, h, values, keys, override } = req.body; - //console.log("Incoming Canvas Request:", w, h, values, keys, override); logger.log("inbound-canvas-creation", "debug", "jsr", null, { w, h, values, keys, override }); - if (!validateCanvasInput(req.body, res)) return; - // Set the default Width and Height let [width, height] = [500, 275]; @@ -122,18 +91,15 @@ exports.canvas = function (req, res) { return canvas.toDataURL(); })() ); - console.log("Canvas generation time:", performance.now() - startTime, "ms"); }; exports.canvasSkia = async function (req, res) { - const startTime = performance.now(); + const { logger } = req; const { w, h, values, keys, override } = req.body; // Log incoming request for debugging logger.log("inbound-canvas-creation", "debug", "jsr", null, { w, h, values, keys, override }); - if (!validateCanvasInput(req.body, res)) return; - // Default width and height const width = typeof w === "number" && w > 0 ? w : 500; const height = typeof h === "number" && h > 0 ? h : 275; @@ -154,11 +120,9 @@ exports.canvasSkia = async function (req, res) { // Send the Base64-encoded image as the response res.status(200).send(dataURL); - console.log("Canvas generation time:", performance.now() - startTime, "ms"); } catch (error) { // Log and handle rendering errors logger.log("canvas-error", "error", "jsr", null, { error: error.message }); - console.error("Error generating chart:", error.message); res.status(500).send("Failed to generate canvas."); } }; diff --git a/server/routes/renderRoutes.js b/server/routes/renderRoutes.js index d1b64566e..c1423f698 100644 --- a/server/routes/renderRoutes.js +++ b/server/routes/renderRoutes.js @@ -3,10 +3,11 @@ const router = express.Router(); const { inlinecss } = require("../render/inlinecss"); const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware"); const { canvas, canvasSkia } = require("../render/canvas-handler"); +const validateCanvasInputMiddleware = require("../middleware/validateCanvasInputMiddleware"); // Define the route for inline CSS rendering router.post("/inlinecss", validateFirebaseIdTokenMiddleware, inlinecss); -router.post("/canvas", validateFirebaseIdTokenMiddleware, canvas); -router.post("/canvas-skia", validateFirebaseIdTokenMiddleware, canvasSkia); +router.post("/canvas", [validateFirebaseIdTokenMiddleware, validateCanvasInputMiddleware], canvas); +router.post("/canvas-skia", [validateFirebaseIdTokenMiddleware, validateCanvasInputMiddleware], canvasSkia); module.exports = router; From 8f752d575a99c0e013c603d2b04a97dd324af5c3 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Thu, 5 Dec 2024 12:13:49 -0800 Subject: [PATCH 4/7] feature/IO-3052-Skia-Canvas-Handler: Optimizations Signed-off-by: Dave Richer --- server/render/canvas-handler.js | 63 ++++++++++++++------------------- server/routes/renderRoutes.js | 4 +-- 2 files changed, 29 insertions(+), 38 deletions(-) diff --git a/server/render/canvas-handler.js b/server/render/canvas-handler.js index 946b6d082..0da0cc9a2 100644 --- a/server/render/canvas-handler.js +++ b/server/render/canvas-handler.js @@ -5,6 +5,8 @@ const Chart = require("chart.js/auto"); const { backgroundColors, borderColors } = require("./canvas-colors"); const { defaultsDeep, isNumber } = require("lodash"); +const CANVAS_QUEUE_LIMIT = 100; + let isProcessing = false; const requestQueue = []; @@ -83,9 +85,7 @@ const processCanvasRequest = async (req, res, isSkia = false) => { const chart = new Chart(ctx, configuration); // Generate and send the image - const chartImage = isSkia - ? (await canvas.toBuffer("image/png")).toString("base64") - : canvas.toDataURL(); + const chartImage = isSkia ? (await canvas.toBuffer("image/png")).toString("base64") : canvas.toDataURL(); chart.destroy(); res.status(200).send(isSkia ? `data:image/png;base64,${chartImage}` : chartImage); @@ -95,49 +95,40 @@ const processCanvasRequest = async (req, res, isSkia = false) => { } }; -const processNextInQueue = async () => { - if (requestQueue.length === 0) { - isProcessing = false; - return; +const enqueueRequest = (req, res, isSkia) => { + if (requestQueue.length >= CANVAS_QUEUE_LIMIT) { + res.status(503).send("Server is busy. Please try again later."); + return false; } + requestQueue.push({ req, res, isSkia }); + req.logger.log("inbound-canvas-creation-queue", "debug", "jsr", null, { queue: requestQueue.length }); + return true; +}; - const { req, res, isSkia } = requestQueue.shift(); - await processCanvasRequest(req, res, isSkia); - processNextInQueue(); +const processNextInQueue = async () => { + while (requestQueue.length > 0) { + const { req, res, isSkia } = requestQueue.shift(); + try { + await processCanvasRequest(req, res, isSkia); + } catch (err) { + console.error("canvas-queue-error", "error", "jsr", null, { error: err.message }); + } + } + isProcessing = false; }; exports.canvastest = function (req, res) { res.status(200).send("OK"); }; -exports.canvas = async function (req, res) { - if (isProcessing) { - if (requestQueue.length >= 100) { - // Set a maximum queue size - return res.status(503).send("Server is busy. Please try again later."); - } - requestQueue.push({ req, res, isSkia: false }); - req.logger.log("inbound-canvas-creation-queue", "debug", "jsr", null, { queue: requestQueue.length }); - return; - } - +exports.canvas = async (req, res) => { + if (isProcessing || !enqueueRequest(req, res, false)) return; isProcessing = true; - await processCanvasRequest(req, res, false); - processNextInQueue(); + processNextInQueue().catch((err) => console.error("canvas-processing-error", { error: err.message })); }; -exports.canvasSkia = async function (req, res) { - if (isProcessing) { - if (requestQueue.length >= 100) { - // Set a maximum queue size - return res.status(503).send("Server is busy. Please try again later."); - } - requestQueue.push({ req, res, isSkia: true }); - req.logger.log("inbound-canvas-creation-queue", "debug", "jsr", null, { queue: requestQueue.length }); - return; - } - +exports.canvasSkia = async (req, res) => { + if (isProcessing || !enqueueRequest(req, res, true)) return; isProcessing = true; - await processCanvasRequest(req, res, true); - processNextInQueue(); + processNextInQueue().catch((err) => console.error("canvas-processing-error", { error: err.message })); }; diff --git a/server/routes/renderRoutes.js b/server/routes/renderRoutes.js index c1423f698..fda1b5846 100644 --- a/server/routes/renderRoutes.js +++ b/server/routes/renderRoutes.js @@ -7,7 +7,7 @@ const validateCanvasInputMiddleware = require("../middleware/validateCanvasInput // Define the route for inline CSS rendering router.post("/inlinecss", validateFirebaseIdTokenMiddleware, inlinecss); -router.post("/canvas", [validateFirebaseIdTokenMiddleware, validateCanvasInputMiddleware], canvas); -router.post("/canvas-skia", [validateFirebaseIdTokenMiddleware, validateCanvasInputMiddleware], canvasSkia); +router.post("/canvas", [validateCanvasInputMiddleware], canvas); +router.post("/canvas-skia", [validateCanvasInputMiddleware], canvasSkia); module.exports = router; From c84fbcaba129e7e79baf179b632df73c5bc94578 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Thu, 5 Dec 2024 12:14:06 -0800 Subject: [PATCH 5/7] feature/IO-3052-Skia-Canvas-Handler: Optimizations Signed-off-by: Dave Richer --- server/routes/renderRoutes.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/routes/renderRoutes.js b/server/routes/renderRoutes.js index fda1b5846..c1423f698 100644 --- a/server/routes/renderRoutes.js +++ b/server/routes/renderRoutes.js @@ -7,7 +7,7 @@ const validateCanvasInputMiddleware = require("../middleware/validateCanvasInput // Define the route for inline CSS rendering router.post("/inlinecss", validateFirebaseIdTokenMiddleware, inlinecss); -router.post("/canvas", [validateCanvasInputMiddleware], canvas); -router.post("/canvas-skia", [validateCanvasInputMiddleware], canvasSkia); +router.post("/canvas", [validateFirebaseIdTokenMiddleware, validateCanvasInputMiddleware], canvas); +router.post("/canvas-skia", [validateFirebaseIdTokenMiddleware, validateCanvasInputMiddleware], canvasSkia); module.exports = router; From 20bddb43b6c92be17aea792094951d6731ca2f2d Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Thu, 5 Dec 2024 12:16:32 -0800 Subject: [PATCH 6/7] feature/IO-3052-Skia-Canvas-Handler: Fix missing checks Signed-off-by: Dave Richer --- server/middleware/validateCanvasInputMiddleware.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/server/middleware/validateCanvasInputMiddleware.js b/server/middleware/validateCanvasInputMiddleware.js index daf3775ba..a03bb12ec 100644 --- a/server/middleware/validateCanvasInputMiddleware.js +++ b/server/middleware/validateCanvasInputMiddleware.js @@ -1,7 +1,7 @@ const { isObject } = require("lodash"); const validateCanvasInputMiddleware = (req, res, next) => { - const { values, keys, override } = req.body; + const { values, keys, override, w, h } = req.body; if (!Array.isArray(values) || !Array.isArray(keys)) { return res.status(400).send("Invalid input: 'values' and 'keys' must be arrays."); @@ -19,6 +19,13 @@ const validateCanvasInputMiddleware = (req, res, next) => { return res.status(400).send("Override must be an object"); } + if (w && (!Number.isFinite(w) || w <= 0)) { + return res.status(400).send("Width must be a positive number"); + } + if (h && (!Number.isFinite(h) || h <= 0)) { + return res.status(400).send("Height must be a positive number"); + } + next(); // Proceed to the next middleware or route handler }; From bfde72eed8831fcc164db3e8267d18fa044abc4b Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Thu, 5 Dec 2024 12:29:11 -0800 Subject: [PATCH 7/7] feature/IO-3052-Skia-Canvas-Handler: Fix missing checks Signed-off-by: Dave Richer --- server/render/canvas-handler.js | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/server/render/canvas-handler.js b/server/render/canvas-handler.js index 0da0cc9a2..5b810dd73 100644 --- a/server/render/canvas-handler.js +++ b/server/render/canvas-handler.js @@ -77,21 +77,36 @@ const processCanvasRequest = async (req, res, isSkia = false) => { const configuration = getChartConfiguration(keys, values, override); + // Placeholders to allow fine control over GAC + let canvas = null; + let ctx = null; + let chart = null; + let chartImage = null; + try { - const canvas = isSkia ? new Canvas(width, height) : createCanvas(width, height); - const ctx = canvas.getContext("2d"); + // Create the canvas + canvas = isSkia ? new Canvas(width, height) : createCanvas(width, height); + ctx = canvas.getContext("2d"); // Render the chart - const chart = new Chart(ctx, configuration); + chart = new Chart(ctx, configuration); // Generate and send the image - const chartImage = isSkia ? (await canvas.toBuffer("image/png")).toString("base64") : canvas.toDataURL(); + chartImage = isSkia ? (await canvas.toBuffer("image/png")).toString("base64") : canvas.toDataURL(); - chart.destroy(); res.status(200).send(isSkia ? `data:image/png;base64,${chartImage}` : chartImage); } catch (error) { + // Log the error and send the response logger.log("canvas-error", "error", "jsr", null, { error: error.message }); res.status(500).send("Failed to generate canvas."); + } finally { + // Cleanup resources + if (chart) { + chart.destroy(); + } + ctx = null; // Explicitly nullify for garbage collection + canvas = null; // Explicitly nullify for garbage collection + chartImage = null; } };