diff --git a/bpmn/Dockerfile b/bpmn/Dockerfile index bc0933e98..ae5be8d9c 100644 --- a/bpmn/Dockerfile +++ b/bpmn/Dockerfile @@ -11,7 +11,9 @@ WORKDIR /usr/local/kroki/ RUN mkdir -p /usr/local/kroki/node && chown kroki:kroki -R /usr/local/kroki -ENV KROKI_EXCALIDRAW_PAGE_URL=file:///usr/local/kroki/assets/index.html +ENV KROKI_BPMN_PAGE_URL=file:///usr/local/kroki/assets/index.html +# 15 seconds +ENV KROKI_BPMN_CONVERT_TIMEOUT=15000 ENV PUPPETEER_EXECUTABLE_PATH=/usr/lib/chromium/chrome #ENV DEBUG="puppeteer:*" ENV LEVEL="info" @@ -25,4 +27,3 @@ RUN npm i --omit=dev EXPOSE 8003 ENTRYPOINT ["node", "src/index.js"] - diff --git a/bpmn/package-lock.json b/bpmn/package-lock.json index 427365d35..95c9ccfdd 100644 --- a/bpmn/package-lock.json +++ b/bpmn/package-lock.json @@ -11,6 +11,8 @@ "dependencies": { "bpmn-js": "14.0.0", "micro": "10.0.1", + "pino": "^8.15.0", + "pino-debug": "^2.0.0", "puppeteer": "20.6.0" }, "bin": { @@ -244,6 +246,17 @@ "@types/node": "*" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.10.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", @@ -468,6 +481,14 @@ "has-symbols": "^1.0.3" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", @@ -1736,6 +1757,22 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -1772,6 +1809,19 @@ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" }, + "node_modules/fast-redact": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.3.0.tgz", + "integrity": "sha512-6T5V1QK1u4oF+ATxs1lWUmlEk6P2T9HqJG3e2DnHOdVgZy2rFJBoEnrIedcTXlkAHU/zKC+7KETJ+KGGKwxgMQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" + }, "node_modules/fastq": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", @@ -1831,6 +1881,11 @@ "node": ">=12.0.0" } }, + "node_modules/flatstr": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/flatstr/-/flatstr-1.0.12.tgz", + "integrity": "sha512-4zPxDyhCyiN2wIAtSLI6gc82/EjqZc1onI4Mz/l0pWrAlsSfYH/2ZIcU+e3oA2wDwbzIWNKwa23F8rh6+DRWkw==" + }, "node_modules/flatted": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", @@ -3075,6 +3130,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.0.tgz", + "integrity": "sha512-VuCaZZAjReZ3vUwgOB8LxAosIurDiAW0s13rI1YwmaP++jvcxP77AWoQvenZebpCA2m8WC1/EosPYPMjnRAp/w==" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -3256,6 +3316,126 @@ "node": ">=6" } }, + "node_modules/pino": { + "version": "8.15.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-8.15.1.tgz", + "integrity": "sha512-Cp4QzUQrvWCRJaQ8Lzv0mJzXVk4z2jlq8JNKMGaixC2Pz5L4l2p95TkuRvYbrEbe85NQsDKrAd4zalf7Ml6WiA==", + "dependencies": { + "atomic-sleep": "^1.0.0", + "fast-redact": "^3.1.1", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "v1.1.0", + "pino-std-serializers": "^6.0.0", + "process-warning": "^2.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^3.1.0", + "thread-stream": "^2.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-1.1.0.tgz", + "integrity": "sha512-lsleG3/2a/JIWUtf9Q5gUNErBqwIu1tUKTT3dUzaf5DySw9ra1wcqKjJjLX1VTY64Wk1eEOYsVGSaGfCK85ekA==", + "dependencies": { + "readable-stream": "^4.0.0", + "split2": "^4.0.0" + } + }, + "node_modules/pino-abstract-transport/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/pino-abstract-transport/node_modules/readable-stream": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.4.2.tgz", + "integrity": "sha512-Lk/fICSyIhodxy1IDK2HazkeGjSmezAWX2egdtJnYhtzKEsBPJowlI6F6LPb5tqIQILrMbx22S5o3GuJavPusA==", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/pino-debug": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-debug/-/pino-debug-2.0.0.tgz", + "integrity": "sha512-n6Rrr2s6LhGSJH5tuz4NwgtH1KvGVrq2swIDiWhfrntCAg8P7fFB9uQo3r0zOuXdVUYIijtLOxi8OZhW7B+kqg==", + "dependencies": { + "pino": "^6.0.2" + }, + "peerDependencies": { + "debug": ">=2" + } + }, + "node_modules/pino-debug/node_modules/pino": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-6.14.0.tgz", + "integrity": "sha512-iuhEDel3Z3hF9Jfe44DPXR8l07bhjuFY3GMHIXbjnY9XcafbyDDwl2sN2vw2GjMPf5Nkoe+OFao7ffn9SXaKDg==", + "dependencies": { + "fast-redact": "^3.0.0", + "fast-safe-stringify": "^2.0.8", + "flatstr": "^1.0.12", + "pino-std-serializers": "^3.1.0", + "process-warning": "^1.0.0", + "quick-format-unescaped": "^4.0.3", + "sonic-boom": "^1.0.2" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-debug/node_modules/pino-std-serializers": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-3.2.0.tgz", + "integrity": "sha512-EqX4pwDPrt3MuOAAUBMU0Tk5kR/YcCM5fNPEzgCO2zJ5HfX0vbiH9HbJglnyeQsN96Kznae6MWD47pZB5avTrg==" + }, + "node_modules/pino-debug/node_modules/process-warning": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-1.0.0.tgz", + "integrity": "sha512-du4wfLyj4yCZq1VupnVSZmRsPJsNuxoDQFdCFHLaYiEbFBD7QE0a+I4D7hOxrVnh78QE/YipFAj9lXHiXocV+Q==" + }, + "node_modules/pino-debug/node_modules/sonic-boom": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-1.4.1.tgz", + "integrity": "sha512-LRHh/A8tpW7ru89lrlkU4AszXt1dbwSjVWguGrmlxE7tawVmDBlI1PILMkXAxJTwqhgsEeTHzj36D5CmHgQmNg==", + "dependencies": { + "atomic-sleep": "^1.0.0", + "flatstr": "^1.0.12" + } + }, + "node_modules/pino-std-serializers": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-6.2.2.tgz", + "integrity": "sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA==" + }, "node_modules/pkg-conf": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/pkg-conf/-/pkg-conf-3.1.0.tgz", @@ -3347,6 +3527,19 @@ "node": ">= 0.8.0" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-warning": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-2.2.0.tgz", + "integrity": "sha512-/1WZ8+VQjR6avWOgHeEPd7SDQmFQ1B5mC1eRXsCm5TarlNmx/wCsa5GEaxGm05BORRtyG/Ex/3xq3TuRvq57qg==" + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -3465,6 +3658,11 @@ } ] }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" + }, "node_modules/raw-body": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.1.tgz", @@ -3498,6 +3696,14 @@ "node": ">= 6" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.3.tgz", @@ -3679,6 +3885,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-stable-stringify": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz", + "integrity": "sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -3778,6 +3992,14 @@ "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==" }, + "node_modules/sonic-boom": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-3.3.0.tgz", + "integrity": "sha512-LYxp34KlZ1a2Jb8ZQgFCK3niIHzibdwtwNUWKg0qQRzsDoJ3Gfgkf8KdBTFU3SkejDEIlWwnSnpVdOZIhFMl/g==", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -3787,6 +4009,14 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/standard": { "version": "17.1.0", "resolved": "https://registry.npmjs.org/standard/-/standard-17.1.0.tgz", @@ -4033,6 +4263,14 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "node_modules/thread-stream": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-2.4.0.tgz", + "integrity": "sha512-xZYtOtmnA63zj04Q+F9bdEay5r47bvpo1CaNqsKi7TpoJHcotUez8Fkfo2RJWpW91lnnaApdpRbVwCWsy+ifcw==", + "dependencies": { + "real-require": "^0.2.0" + } + }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", diff --git a/bpmn/package.json b/bpmn/package.json index 7f48dfde3..54e79d33c 100644 --- a/bpmn/package.json +++ b/bpmn/package.json @@ -2,6 +2,7 @@ "name": "@kroki/bpmn-server", "version": "1.0.0", "description": "A micro server built on top of the BPMN diagram library.", + "type": "module", "main": "src/index.js", "bin": "src/index.js", "scripts": { @@ -20,6 +21,8 @@ "dependencies": { "bpmn-js": "14.0.0", "micro": "10.0.1", + "pino": "^8.15.0", + "pino-debug": "^2.0.0", "puppeteer": "20.6.0" }, "devDependencies": { diff --git a/bpmn/src/browser-instance.js b/bpmn/src/browser-instance.js index e42235aa6..601ade8e3 100644 --- a/bpmn/src/browser-instance.js +++ b/bpmn/src/browser-instance.js @@ -1,15 +1,19 @@ -const puppeteer = require('puppeteer') +import puppeteer from 'puppeteer' const createBrowser = async () => { const browser = await puppeteer.launch({ headless: 'new', args: [ + // allow to access files from file:// protocol + '--allow-file-access-from-files', // Disables GPU hardware acceleration. // If software renderer is not in place, then the GPU process won't launch. '--disable-gpu', '--disable-translate', // Disable the setuid sandbox (Linux only) '--disable-setuid-sandbox', + // disable web security to access local files + '--disable-web-security', // Run in headless mode, i.e., without a UI or display server dependencies '--headless', // Prevents creating scrollbars for web content. Useful for taking consistent screenshots. @@ -34,8 +38,6 @@ const createBrowser = async () => { } } -module.exports = { - create: async () => { - return createBrowser() - } +export async function create () { + return createBrowser() } diff --git a/bpmn/src/index.js b/bpmn/src/index.js index 1ed629c28..7122905fe 100644 --- a/bpmn/src/index.js +++ b/bpmn/src/index.js @@ -1,13 +1,16 @@ -const http = require('node:http') -const Worker = require('./worker') -const Task = require('./task') -const instance = require('./browser-instance') -const micro = require('micro') +// must be declared first +import { logger } from './logger.js' +import http from 'node:http' +import {TimeoutError as PuppeteerTimeoutError} from 'puppeteer' +import micro from 'micro' +import Task from './task.js' +import { create } from './browser-instance.js' +import { SyntaxError, TimeoutError, Worker } from './worker.js' -;(async () => { +(async () => { // QUESTION: should we create a pool of Chrome instances ? - const browser = await instance.create() - console.log(`Chrome accepting connections on endpoint ${browser.wsEndpoint()}`) + const browser = await create() + logger.info(`Chrome accepting connections on endpoint ${browser.wsEndpoint()}`) const worker = new Worker(browser) const server = new http.Server( micro.serve(async (req, res) => { @@ -19,16 +22,46 @@ const micro = require('micro') const svg = await worker.convert(new Task(diagramSource)) res.setHeader('Content-Type', 'image/svg+xml') return micro.send(res, 200, svg) - } catch (e) { - console.log('e', e) - return micro.send(res, 400, 'Unable to convert the diagram') + } catch (err) { + if (err instanceof PuppeteerTimeoutError || err instanceof TimeoutError) { + return micro.send(res, 408, { + error: { + message: `Request timeout: ${err.message}`, + name: 'TimeoutError', + stacktrace: err.stack + } + }) + } else if (err instanceof SyntaxError) { + return micro.send(res, 400, { + error: { + message: err.message, + name: err.name, + stacktrace: err.stack + } + }) + } else { + logger.warn({ err }, 'Exception during convert') + return micro.send(res, 500, { + error: { + message: `An error occurred while converting the diagram: ${err.message}`, + name: err.name || '', + stacktrace: err.stack || '' + } + }) + } } } - micro.send(res, 400, 'Body must not be empty.') + return micro.send(res, 400, { + error: { + message: 'Body must not be empty.', + name: '', + stacktrace: '' + } + }) }) ) server.listen(8003) -})().catch(error => { - console.error('Unable to start the service', error) +})().catch(err => { + logger.error({ err }, 'Unable to start the service') process.exit(1) }) diff --git a/bpmn/src/logger.js b/bpmn/src/logger.js new file mode 100644 index 000000000..ff13b685b --- /dev/null +++ b/bpmn/src/logger.js @@ -0,0 +1,18 @@ +import pinoDebug from 'pino-debug' +import pino from 'pino' + +export const logger = pino({ + level: process.env.LEVEL || 'info', + formatters: { + level: (label) => { + return { level: label } + } + } +}) +pinoDebug(logger, { + auto: true, // default + map: { + 'puppeteer:*': 'debug', + '*': 'trace' // everything else - trace + } +}) diff --git a/bpmn/src/task.js b/bpmn/src/task.js index 1557afd0e..3750827c3 100644 --- a/bpmn/src/task.js +++ b/bpmn/src/task.js @@ -1,8 +1,6 @@ -class Task { +export default class Task { constructor (source) { this.source = source this.bpmnConfig = {} } } - -module.exports = Task diff --git a/bpmn/src/worker.js b/bpmn/src/worker.js index 274499425..e1cfd1689 100644 --- a/bpmn/src/worker.js +++ b/bpmn/src/worker.js @@ -1,10 +1,29 @@ -const path = require('node:path') -const puppeteer = require('puppeteer') +import path from 'node:path' +import { URL, fileURLToPath } from 'node:url' +import puppeteer from 'puppeteer' -class Worker { +import { logger } from './logger.js' + +const __dirname = fileURLToPath(new URL('.', import.meta.url)) + +export class TimeoutError extends Error { + constructor (timeoutDurationMs, action = 'convert') { + super(`Timeout error: ${action} took more than ${timeoutDurationMs}ms`) + } +} + +export class SyntaxError extends Error { + constructor (err) { + logger.error({ err }) + super(`Syntax error in graph: ${JSON.stringify(err)}`) + } +} + +export class Worker { constructor (browserInstance) { this.browserWSEndpoint = browserInstance.wsEndpoint() this.pageUrl = process.env.KROKI_BPMN_PAGE_URL || `file://${path.join(__dirname, '..', 'assets', 'index.html')}` + this.convertTimeout = process.env.KROKI_BPMN_CONVERT_TIMEOUT || '15000' } async convert (task) { @@ -16,58 +35,37 @@ class Worker { try { await page.setViewport({ height: 800, width: 600 }) await page.goto(this.pageUrl) - return await page.$eval('#container', (container, bpmnXML, options) => { - container.innerHTML = bpmnXML - /* global BpmnJS */ - const viewer = new BpmnJS({ container: container }) - - function loadDiagram () { - return new Promise((resolve, reject) => { - viewer.importXML(bpmnXML, function (err) { - if (err) { - reject(err) - } else { - resolve() - } - }) - }) - } - - function exportSVG () { - return new Promise((resolve, reject) => { - viewer.saveSVG((err, svg) => { - if (err) { - console.log('Failed to export', err) - reject(err) - } else { - resolve(svg) - } - }) - }) - } - - return loadDiagram().then(() => { - return exportSVG() - }).catch((err) => { - throw err - }) - }, task.source, task.bpmnConfig) - } catch (e) { - console.error('Unable to convert the diagram', e) - throw e + const evalResult = await Promise.race([ + page.evaluate(async (bpmnXML, options) => { + try { + const container = document.getElementById('container') + container.innerHTML = bpmnXML + /* global BpmnJS */ + const viewer = new BpmnJS({ container }) + await viewer.importXML(bpmnXML) + const { svg, err } = await viewer.saveSVG() + return { svg, error: err } + } catch (err) { + return { svg: null, error: err } + } + }, task.source, task.bpmnConfig), + new Promise((resolve, reject) => setTimeout(() => reject(new TimeoutError(this.convertTimeout)), this.convertTimeout)) + ]) + if (evalResult && evalResult.error) { + throw new SyntaxError(evalResult.error) + } + return evalResult.svg } finally { try { await page.close() - } catch (e) { - console.warn('Unable to close the page', e) + } catch (err) { + logger.warn({ err }, 'Unable to close the page') } try { await browser.disconnect() - } catch (e) { - console.warn('Unable to disconnect from the browser', e) + } catch (err) { + logger.warn({ err }, 'Unable to disconnect from the browser') } } } } - -module.exports = Worker diff --git a/diagrams.net/Dockerfile b/diagrams.net/Dockerfile index 3b2a9c92e..ea067aed2 100644 --- a/diagrams.net/Dockerfile +++ b/diagrams.net/Dockerfile @@ -11,7 +11,7 @@ WORKDIR /usr/local/kroki/ RUN mkdir -p /usr/local/kroki/node && chown kroki:kroki -R /usr/local/kroki -ENV KROKI_EXCALIDRAW_PAGE_URL=file:///usr/local/kroki/assets/index.html +ENV KROKI_DIAGRAMSNET_PAGE_URL=file:///usr/local/kroki/assets/index.html # 15 seconds ENV KROKI_DIAGRAMSNET_CONVERT_TIMEOUT=15000 ENV PUPPETEER_EXECUTABLE_PATH=/usr/lib/chromium/chrome @@ -27,5 +27,3 @@ RUN npm i --omit=dev EXPOSE 8005 ENTRYPOINT ["node", "src/index.js"] - - diff --git a/diagrams.net/package-lock.json b/diagrams.net/package-lock.json index 53261c98f..61ade995d 100644 --- a/diagrams.net/package-lock.json +++ b/diagrams.net/package-lock.json @@ -10,6 +10,8 @@ "license": "MIT", "dependencies": { "micro": "10.0.1", + "pino": "^8.15.0", + "pino-debug": "^2.0.0", "puppeteer": "20.6.0" }, "bin": { @@ -284,6 +286,17 @@ "@types/node": "*" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.8.2", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", @@ -472,6 +485,14 @@ "node": ">=4" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", @@ -1625,6 +1646,22 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -1661,6 +1698,19 @@ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=" }, + "node_modules/fast-redact": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.3.0.tgz", + "integrity": "sha512-6T5V1QK1u4oF+ATxs1lWUmlEk6P2T9HqJG3e2DnHOdVgZy2rFJBoEnrIedcTXlkAHU/zKC+7KETJ+KGGKwxgMQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" + }, "node_modules/fastq": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", @@ -1719,6 +1769,11 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/flatstr": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/flatstr/-/flatstr-1.0.12.tgz", + "integrity": "sha512-4zPxDyhCyiN2wIAtSLI6gc82/EjqZc1onI4Mz/l0pWrAlsSfYH/2ZIcU+e3oA2wDwbzIWNKwa23F8rh6+DRWkw==" + }, "node_modules/flatted": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.4.tgz", @@ -2756,6 +2811,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.0.tgz", + "integrity": "sha512-VuCaZZAjReZ3vUwgOB8LxAosIurDiAW0s13rI1YwmaP++jvcxP77AWoQvenZebpCA2m8WC1/EosPYPMjnRAp/w==" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -2920,6 +2980,126 @@ "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==" }, + "node_modules/pino": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-8.15.0.tgz", + "integrity": "sha512-olUADJByk4twxccmAxb1RiGKOSvddHugCV3wkqjyv+3Sooa2KLrmXrKEWOKi0XPCLasRR5jBXxioE1jxUa4KzQ==", + "dependencies": { + "atomic-sleep": "^1.0.0", + "fast-redact": "^3.1.1", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "v1.0.0", + "pino-std-serializers": "^6.0.0", + "process-warning": "^2.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^3.1.0", + "thread-stream": "^2.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-1.0.0.tgz", + "integrity": "sha512-c7vo5OpW4wIS42hUVcT5REsL8ZljsUfBjqV/e2sFxmFEFZiq1XLUp5EYLtuDH6PEHq9W1egWqRbnLUP5FuZmOA==", + "dependencies": { + "readable-stream": "^4.0.0", + "split2": "^4.0.0" + } + }, + "node_modules/pino-abstract-transport/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/pino-abstract-transport/node_modules/readable-stream": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.4.2.tgz", + "integrity": "sha512-Lk/fICSyIhodxy1IDK2HazkeGjSmezAWX2egdtJnYhtzKEsBPJowlI6F6LPb5tqIQILrMbx22S5o3GuJavPusA==", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/pino-debug": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-debug/-/pino-debug-2.0.0.tgz", + "integrity": "sha512-n6Rrr2s6LhGSJH5tuz4NwgtH1KvGVrq2swIDiWhfrntCAg8P7fFB9uQo3r0zOuXdVUYIijtLOxi8OZhW7B+kqg==", + "dependencies": { + "pino": "^6.0.2" + }, + "peerDependencies": { + "debug": ">=2" + } + }, + "node_modules/pino-debug/node_modules/pino": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-6.14.0.tgz", + "integrity": "sha512-iuhEDel3Z3hF9Jfe44DPXR8l07bhjuFY3GMHIXbjnY9XcafbyDDwl2sN2vw2GjMPf5Nkoe+OFao7ffn9SXaKDg==", + "dependencies": { + "fast-redact": "^3.0.0", + "fast-safe-stringify": "^2.0.8", + "flatstr": "^1.0.12", + "pino-std-serializers": "^3.1.0", + "process-warning": "^1.0.0", + "quick-format-unescaped": "^4.0.3", + "sonic-boom": "^1.0.2" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-debug/node_modules/pino-std-serializers": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-3.2.0.tgz", + "integrity": "sha512-EqX4pwDPrt3MuOAAUBMU0Tk5kR/YcCM5fNPEzgCO2zJ5HfX0vbiH9HbJglnyeQsN96Kznae6MWD47pZB5avTrg==" + }, + "node_modules/pino-debug/node_modules/process-warning": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-1.0.0.tgz", + "integrity": "sha512-du4wfLyj4yCZq1VupnVSZmRsPJsNuxoDQFdCFHLaYiEbFBD7QE0a+I4D7hOxrVnh78QE/YipFAj9lXHiXocV+Q==" + }, + "node_modules/pino-debug/node_modules/sonic-boom": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-1.4.1.tgz", + "integrity": "sha512-LRHh/A8tpW7ru89lrlkU4AszXt1dbwSjVWguGrmlxE7tawVmDBlI1PILMkXAxJTwqhgsEeTHzj36D5CmHgQmNg==", + "dependencies": { + "atomic-sleep": "^1.0.0", + "flatstr": "^1.0.12" + } + }, + "node_modules/pino-std-serializers": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-6.2.2.tgz", + "integrity": "sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA==" + }, "node_modules/pkg-conf": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/pkg-conf/-/pkg-conf-3.1.0.tgz", @@ -3055,6 +3235,19 @@ "node": ">= 0.8.0" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-warning": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-2.2.0.tgz", + "integrity": "sha512-/1WZ8+VQjR6avWOgHeEPd7SDQmFQ1B5mC1eRXsCm5TarlNmx/wCsa5GEaxGm05BORRtyG/Ex/3xq3TuRvq57qg==" + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -3173,6 +3366,11 @@ } ] }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" + }, "node_modules/raw-body": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.1.tgz", @@ -3206,6 +3404,14 @@ "node": ">= 6" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/regexp.prototype.flags": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.0.tgz", @@ -3349,6 +3555,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-stable-stringify": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz", + "integrity": "sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -3443,6 +3657,14 @@ "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==" }, + "node_modules/sonic-boom": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-3.3.0.tgz", + "integrity": "sha512-LYxp34KlZ1a2Jb8ZQgFCK3niIHzibdwtwNUWKg0qQRzsDoJ3Gfgkf8KdBTFU3SkejDEIlWwnSnpVdOZIhFMl/g==", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -3452,6 +3674,14 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/standard": { "version": "17.1.0", "resolved": "https://registry.npmjs.org/standard/-/standard-17.1.0.tgz", @@ -3706,6 +3936,14 @@ "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", "dev": true }, + "node_modules/thread-stream": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-2.4.0.tgz", + "integrity": "sha512-xZYtOtmnA63zj04Q+F9bdEay5r47bvpo1CaNqsKi7TpoJHcotUez8Fkfo2RJWpW91lnnaApdpRbVwCWsy+ifcw==", + "dependencies": { + "real-require": "^0.2.0" + } + }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", diff --git a/diagrams.net/package.json b/diagrams.net/package.json index e6b667412..63be6650c 100644 --- a/diagrams.net/package.json +++ b/diagrams.net/package.json @@ -2,6 +2,7 @@ "name": "@kroki/diagramsnet-server", "version": "1.0.0", "description": "A micro server built on top of the diagrams.net service.", + "type": "module", "main": "src/index.js", "bin": "src/index.js", "scripts": { @@ -17,6 +18,8 @@ }, "dependencies": { "micro": "10.0.1", + "pino": "^8.15.0", + "pino-debug": "^2.0.0", "puppeteer": "20.6.0" }, "devDependencies": { diff --git a/diagrams.net/src/browser-instance.js b/diagrams.net/src/browser-instance.js index e0a770cf2..601ade8e3 100644 --- a/diagrams.net/src/browser-instance.js +++ b/diagrams.net/src/browser-instance.js @@ -1,4 +1,4 @@ -const puppeteer = require('puppeteer') +import puppeteer from 'puppeteer' const createBrowser = async () => { const browser = await puppeteer.launch({ @@ -38,17 +38,6 @@ const createBrowser = async () => { } } -const connect = async (browserInstance) => { - this.browserWSEndpoint = browserInstance.wsEndpoint() - return puppeteer.connect({ - browserWSEndpoint: this.browserWSEndpoint, - ignoreHTTPSErrors: true - }) -} - -module.exports = { - create: async () => { - return createBrowser() - }, - connect +export async function create () { + return createBrowser() } diff --git a/diagrams.net/src/index.js b/diagrams.net/src/index.js index e98938f1c..460bbcd6d 100644 --- a/diagrams.net/src/index.js +++ b/diagrams.net/src/index.js @@ -1,14 +1,16 @@ -const http = require('node:http') -const { Worker, SyntaxError } = require('./worker') -const Task = require('./task') -const instance = require('./browser-instance') -const micro = require('micro') -const puppeteer = require('puppeteer') +// must be declared first +import { logger } from './logger.js' +import http from 'node:http' +import {TimeoutError as PuppeteerTimeoutError} from 'puppeteer' +import micro from 'micro' +import Task from './task.js' +import { create } from './browser-instance.js' +import { SyntaxError, TimeoutError, Worker } from './worker.js' -;(async () => { +(async () => { // QUESTION: should we create a pool of Chrome instances ? - const browser = await instance.create() - console.log(`Chrome accepting connections on endpoint ${browser.wsEndpoint()}`) + const browser = await create() + logger.info(`Chrome accepting connections on endpoint ${browser.wsEndpoint()}`) const worker = new Worker(browser) const server = new http.Server( micro.serve(async (req, res) => { @@ -25,23 +27,53 @@ const puppeteer = require('puppeteer') res.setHeader('Content-Type', isPng ? 'image/png' : 'image/svg+xml') return micro.send(res, 200, output) } catch (err) { - if (err instanceof puppeteer.errors.TimeoutError) { - return micro.send(res, 408, 'Request timeout') + if (err instanceof PuppeteerTimeoutError || err instanceof TimeoutError) { + return micro.send(res, 408, { + error: { + message: `Request timeout: ${err.message}`, + name: 'TimeoutError', + stacktrace: err.stack + } + }) } else if (err instanceof SyntaxError) { - return micro.send(res, 400, err.message) + return micro.send(res, 400, { + error: { + message: err.message, + name: err.name, + stacktrace: err.stack + } + }) } else { - console.log('Exception during convert', err) - return micro.send(res, 500, 'An error occurred while converting the diagram') + logger.warn({ err }, 'Exception during convert') + return micro.send(res, 500, { + error: { + message: `An error occurred while converting the diagram: ${err.message}`, + name: err.name || '', + stacktrace: err.stack || '' + } + }) } } } - return micro.send(res, 400, 'Body must not be empty.') + return micro.send(res, 400, { + error: { + message: 'Body must not be empty.', + name: '', + stacktrace: '' + } + }) } - return micro.send(res, 400, 'Available endpoints are /svg and /png.') + return micro.send(res, 400, { + error: { + message: 'Available endpoints are /svg and /png.', + name: '', + stacktrace: '' + } + }) }) ) server.listen(8005) -})().catch(error => { - console.error('Unable to start the service', error) +})().catch(err => { + logger.error({ err }, 'Unable to start the service') process.exit(1) }) diff --git a/diagrams.net/src/logger.js b/diagrams.net/src/logger.js new file mode 100644 index 000000000..ff13b685b --- /dev/null +++ b/diagrams.net/src/logger.js @@ -0,0 +1,18 @@ +import pinoDebug from 'pino-debug' +import pino from 'pino' + +export const logger = pino({ + level: process.env.LEVEL || 'info', + formatters: { + level: (label) => { + return { level: label } + } + } +}) +pinoDebug(logger, { + auto: true, // default + map: { + 'puppeteer:*': 'debug', + '*': 'trace' // everything else - trace + } +}) diff --git a/diagrams.net/src/task.js b/diagrams.net/src/task.js index d5ea2701a..17305316f 100644 --- a/diagrams.net/src/task.js +++ b/diagrams.net/src/task.js @@ -1,8 +1,6 @@ -class Task { +export default class Task { constructor (source, isPng = false) { this.source = source this.isPng = isPng } } - -module.exports = Task diff --git a/diagrams.net/src/worker.js b/diagrams.net/src/worker.js index d07466193..dae355fab 100644 --- a/diagrams.net/src/worker.js +++ b/diagrams.net/src/worker.js @@ -1,15 +1,26 @@ /* global XMLSerializer */ -const path = require('node:path') -const puppeteer = require('puppeteer') +import path from 'node:path' +import { URL, fileURLToPath } from 'node:url' +import puppeteer from 'puppeteer' -class SyntaxError extends Error { +import { logger } from './logger.js' + +const __dirname = fileURLToPath(new URL('.', import.meta.url)) + +export class TimeoutError extends Error { + constructor (timeoutDurationMs, action = 'convert') { + super(`Timeout error: ${action} took more than ${timeoutDurationMs}ms`) + } +} + +export class SyntaxError extends Error { constructor (err) { - console.log({ err }) + logger.error({ err }) super(`Syntax error in graph: ${JSON.stringify(err)}`) } } -class Worker { +export class Worker { constructor (browserInstance) { this.browserWSEndpoint = browserInstance.wsEndpoint() this.pageUrl = process.env.KROKI_DIAGRAMSNET_PAGE_URL || `file://${path.join(__dirname, '..', 'assets', 'index.html')}` @@ -25,8 +36,6 @@ class Worker { try { await page.setViewport({ height: 800, width: 600 }) await page.goto(this.pageUrl) - // QUESTION: should we reuse the page for performance reason ? - const evalResult = await Promise.race([ page.evaluate((source) => { /* global render */ @@ -36,14 +45,13 @@ class Worker { format: 'svg' }).getSvg() const s = new XMLSerializer() - console.log({ s }) return { svg: s.serializeToString(svgRoot), error: null } } catch (err) { - console.log({ err }) + logger.log({ err }) return { svg: null, error: err } } }, task.source), - page.waitForTimeout(this.convertTimeout) + new Promise((resolve, reject) => setTimeout(() => reject(new TimeoutError(this.convertTimeout)), this.convertTimeout)) ]) if (evalResult && evalResult.error) { @@ -68,19 +76,14 @@ class Worker { } finally { try { await page.close() - } catch (e) { - console.warn('Unable to close the page', e) + } catch (err) { + logger.warn({ err }, 'Unable to close the page') } try { await browser.disconnect() - } catch (e) { - console.warn('Unable to disconnect from the browser', e) + } catch (err) { + logger.warn({ err }, 'Unable to disconnect from the browser') } } } } - -module.exports = { - Worker, - SyntaxError -} diff --git a/excalidraw/src/index.js b/excalidraw/src/index.js index 781f15fd3..9f7fb5875 100644 --- a/excalidraw/src/index.js +++ b/excalidraw/src/index.js @@ -6,7 +6,8 @@ import Worker from './worker.js' import Task from './task.js' import { create } from './browser-instance.js' -;(async () => { + +(async () => { // QUESTION: should we create a pool of Chrome instances ? const browser = await create() logger.info(`Chrome accepting connections on endpoint ${browser.wsEndpoint()}`) @@ -23,10 +24,22 @@ import { create } from './browser-instance.js' return micro.send(res, 200, svg) } catch (err) { logger.warn({ err }, 'Exception during convert') - return micro.send(res, 400, 'Unable to convert the diagram') + return micro.send(res, 400, { + error: { + message: `Unable to convert the diagram: ${err.message}`, + name: err.name || '', + stacktrace: err.stack || '', + } + }) } } - micro.send(res, 400, 'Body must not be empty.') + micro.send(res, 400, { + error: { + message: 'Body must not be empty.', + name: '', + stacktrace: '', + } + }) }) ) server.listen(8004) diff --git a/mermaid/src/index.js b/mermaid/src/index.js index 5279f6ef5..1bae561b8 100644 --- a/mermaid/src/index.js +++ b/mermaid/src/index.js @@ -4,7 +4,7 @@ import './apm.js' import http from 'node:http' import micro from 'micro' -import { SyntaxError, Worker } from './worker.js' +import { SyntaxError, TimeoutError, Worker } from './worker.js' import Task from './task.js' import { create } from './browser-instance.js' @@ -36,17 +36,49 @@ import { create } from './browser-instance.js' res.setHeader('Content-Type', isPng ? 'image/png' : 'image/svg+xml') return micro.send(res, 200, output) } catch (err) { - if (err instanceof SyntaxError) { - return micro.send(res, 400, err.message) + if (err instanceof TimeoutError) { + return micro.send(res, 408, { + error: { + message: `Request timeout: ${err.message}`, + name: 'TimeoutError', + stacktrace: err.stack + } + }) + } else if (err instanceof SyntaxError) { + return micro.send(res, 400, { + error: { + message: err.message, + name: err.name, + stacktrace: err.stack + } + }) } else { logger.warn({ err }, 'Exception during convert') - return micro.send(res, 500, 'An error occurred while converting the diagram') + return micro.send(res, 500, { + error: { + message: 'An error occurred while converting the diagram', + name: err.name || 'Error', + stacktrace: err.stack || '' + } + }) } } } - return micro.send(res, 400, 'Body must not be empty.') + return micro.send(res, 400, { + error: { + message: 'Body must not be empty.', + name: 'Error', + stacktrace: '' + } + }) } - return micro.send(res, 400, 'Available endpoints are /svg and /png.') + return micro.send(res, 400, { + error: { + message: 'Available endpoints are /svg and /png.', + name: 'Error', + stacktrace: '' + } + }) }) ) server.listen(8002) diff --git a/mermaid/src/worker.js b/mermaid/src/worker.js index dc1ac2b19..da264d894 100644 --- a/mermaid/src/worker.js +++ b/mermaid/src/worker.js @@ -8,9 +8,17 @@ import { failureSpan, startSpan, successfulSpan } from './apm.js' const __dirname = fileURLToPath(new URL('.', import.meta.url)) +export class TimeoutError extends Error { + constructor (timeoutDurationMs, action = 'convert') { + super(`Timeout error: ${action} took more than ${timeoutDurationMs}ms`) + } +} + export class SyntaxError extends Error { constructor (err) { - super(`Syntax error in graph: ${JSON.stringify(err)}`) + super(`Syntax error in graph: ${err.message}`) + this.name = err.name + this.stack = err.stack } } @@ -18,6 +26,7 @@ export class Worker { constructor (browserInstance) { this.browserWSEndpoint = browserInstance.wsEndpoint() this.pageUrl = process.env.KROKI_MERMAID_PAGE_URL || `file://${path.join(__dirname, '..', 'assets', 'index.html')}` + this.convertTimeout = process.env.KROKI_MERMAID_CONVERT_TIMEOUT || '10000' } async convert (task, config) { @@ -68,24 +77,27 @@ export class Worker { } ) try { - const result = await page.evaluate(async (definition, mermaidConfig) => { - window.mermaid.initialize(mermaidConfig) - try { - const { svg } = await window.mermaid.render('container', definition) - return { svg, error: null } - } catch (err) { - return { - svg: null, - error: { - name: 'name' in err && err.name, - message: 'message' in err && err.message, - stack: 'stack' in err && err.stack + const evalResult = await Promise.race([ + page.evaluate(async (definition, mermaidConfig) => { + window.mermaid.initialize(mermaidConfig) + try { + const { svg } = await window.mermaid.render('container', definition) + return { svg, error: null } + } catch (err) { + return { + svg: null, + error: { + name: 'name' in err && err.name, + message: 'message' in err && err.message, + stack: 'stack' in err && err.stack + } } } - } - }, task.source, mermaidConfig) + }, task.source, mermaidConfig), + new Promise((resolve, reject) => setTimeout(() => reject(new TimeoutError(this.convertTimeout)), this.convertTimeout)) + ]) successfulSpan(span) - return result + return evalResult } catch (err) { if (span) { // add source to troubleshoot diff --git a/mermaid/test/convert-test.mjs b/mermaid/test/convert-test.mjs index c97a30b2c..494e0e38c 100644 --- a/mermaid/test/convert-test.mjs +++ b/mermaid/test/convert-test.mjs @@ -96,7 +96,7 @@ describe('#convert', function () { await new Worker(browser).convert(new Task('not a valid mermaid code', testCase.isPng)) expect.fail('Should throw a SyntaxError exception') } catch (err) { - expect(err.message).to.be.a('string').and.satisfy(msg => msg.startsWith('Syntax error in graph: {"name":"UnknownDiagramError"'), 'Error message should starts with \'Syntax error in graph: {"name":"UnknownDiagramError\'') + expect(err.message).to.be.a('string').and.satisfy(msg => msg.startsWith('Syntax error in graph:'), 'Error message should starts with \'Syntax error in graph:\'') } finally { await browser.close() } diff --git a/server/src/main/java/io/kroki/server/action/Delegator.java b/server/src/main/java/io/kroki/server/action/Delegator.java index 806d429a7..d01ab0066 100644 --- a/server/src/main/java/io/kroki/server/action/Delegator.java +++ b/server/src/main/java/io/kroki/server/action/Delegator.java @@ -18,8 +18,6 @@ import org.slf4j.LoggerFactory; import java.net.ConnectException; -import java.util.Map; -import java.util.function.Consumer; public class Delegator { @@ -48,11 +46,23 @@ public static Handler>> createHandler(String ho } else { logging.delegate(httpResponse, host, port, requestURI); String contentType = httpResponse.getHeader(HttpHeaders.CONTENT_TYPE.toString()); - if (HttpHeaderValues.APPLICATION_JSON.contentEquals(contentType)) { + if (contentType != null && contentType.toLowerCase().startsWith(HttpHeaderValues.APPLICATION_JSON.toString())) { try { JsonObject json = httpResponse.bodyAsJsonObject(); if (json != null) { - handler.handle(new Failure(new BadRequestException(json.getString("error", "Unexpected error")))); + final String errorMessage; + Object error = json.getValue("error"); + if (error instanceof String) { + errorMessage = (String) error; + } else if (error instanceof JsonObject) { + String errorName = ((JsonObject) error).getString("name", "Error"); + String message = ((JsonObject) error).getString("message", "Unexpected error"); + String stackTrace = ((JsonObject) error).getString("stacktrace", ""); + errorMessage = errorName + ": " + message + "\n" + stackTrace; + } else { + errorMessage = "Unexpected error"; + } + handler.handle(new Failure(new BadRequestException(errorMessage, httpResponse.statusCode()))); } else { handler.handle(new Failure(new HttpException(httpResponse.statusCode()))); } diff --git a/server/src/main/java/io/kroki/server/error/BadRequestException.java b/server/src/main/java/io/kroki/server/error/BadRequestException.java index 19ce93e3a..59e1bdd95 100644 --- a/server/src/main/java/io/kroki/server/error/BadRequestException.java +++ b/server/src/main/java/io/kroki/server/error/BadRequestException.java @@ -2,15 +2,28 @@ public class BadRequestException extends RuntimeException { + private final int statusCode; + public BadRequestException(String message) { super(message); + this.statusCode = -1; + } + + public BadRequestException(String message, int statusCode) { + super(message); + this.statusCode = statusCode; } public BadRequestException(String message, Throwable cause) { super(message, cause); + this.statusCode = -1; } public String getMessageHTML() { return getMessage(); } + + public int getStatusCode() { + return statusCode; + } } diff --git a/server/src/main/java/io/kroki/server/error/ErrorHandler.java b/server/src/main/java/io/kroki/server/error/ErrorHandler.java index a13b0ff65..53d964f4f 100644 --- a/server/src/main/java/io/kroki/server/error/ErrorHandler.java +++ b/server/src/main/java/io/kroki/server/error/ErrorHandler.java @@ -96,6 +96,12 @@ public void handle(RoutingContext context) { statusMessage = "Not Found"; errorMessage = statusMessage; } else if (failure instanceof BadRequestException || failure instanceof IllegalStateException) { + if (failure instanceof BadRequestException) { + int statusCode = ((BadRequestException) failure).getStatusCode(); + if (statusCode != -1) { + errorCode = statusCode; + } + } if (errorCode < 400 || errorCode >= 500) { errorCode = 400; statusMessage = "Bad Request";