diff --git a/CHANGELOG.md b/CHANGELOG.md index 108af87df..8508880cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # tileserver-gl changelog +## 5.1.0-pre.0 +* Upgrade Express to v5 + Canvas to v3 + code cleanup (https://github.com/maptiler/tileserver-gl/pull/1429) by @acalcutt +* Terrain Preview and simple Elevation Query (https://github.com/maptiler/tileserver-gl/pull/1425 and https://github.com/maptiler/tileserver-gl/pull/1432) by @okimiko +* add progressive rendering option for static jpeg images (#1397) by @samuel-git + ## 5.0.0 * Update Maplibre-Native to [v6.0.0](https://github.com/maplibre/maplibre-native/releases/tag/node-v6.0.0) release by @acalcutt in https://github.com/maptiler/tileserver-gl/pull/1376 and @dependabot in https://github.com/maptiler/tileserver-gl/pull/1381 * This first release that use Metal for rendering instead of OpenGL (ES) for macOS. diff --git a/package-lock.json b/package-lock.json index 1210376fd..7fcd23e53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tileserver-gl", - "version": "5.0.0", + "version": "5.1.0-pre.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tileserver-gl", - "version": "5.0.0", + "version": "5.1.0-pre.0", "license": "BSD-2-Clause", "dependencies": { "@jsse/pbfont": "^0.2.2", @@ -19,13 +19,13 @@ "@sindresorhus/fnv1a": "3.1.0", "advanced-pool": "0.3.3", "axios": "^1.7.7", - "canvas": "2.11.2", + "canvas": "3.0.1", "chokidar": "3.6.0", "clone": "2.1.2", "color": "4.2.3", "commander": "12.1.0", "cors": "2.8.5", - "express": "4.19.2", + "express": "5.0.1", "handlebars": "4.7.8", "http-shutdown": "1.2.2", "morgan": "1.10.0", @@ -1722,17 +1722,44 @@ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" }, "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.53.0.tgz", + "integrity": "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.0.tgz", + "integrity": "sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==", "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" + "mime-db": "^1.53.0" }, "engines": { "node": ">= 0.6" } }, + "node_modules/accepts/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.12.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", @@ -1925,9 +1952,9 @@ } }, "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-3.0.0.tgz", + "integrity": "sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA==" }, "node_modules/array-ify": { "version": "1.0.0", @@ -2012,6 +2039,25 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/basic-auth": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", @@ -2041,42 +2087,69 @@ "node": ">=8" } }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.0.2.tgz", + "integrity": "sha512-SNMk0OONlQ01uk8EPeiBvTW7W4ovpL5b1O3t1sjpPgfxOQ6BqQJ6XjxinDPR79Z6HdcD5zBBwr5ssiTlgdNztQ==", "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", + "debug": "3.1.0", "destroy": "1.2.0", "http-errors": "2.0.0", - "iconv-lite": "0.4.24", + "iconv-lite": "0.5.2", "on-finished": "2.4.1", - "qs": "6.11.0", - "raw-body": "2.5.2", - "type-is": "~1.6.18", - "unpipe": "1.0.0" + "qs": "6.13.0", + "raw-body": "^3.0.0", + "type-is": "~1.6.18" }, "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "node": ">=18" } }, "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", "dependencies": { "ms": "2.0.0" } }, + "node_modules/body-parser/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/body-parser/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/body-parser/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -2102,6 +2175,29 @@ "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", "dev": true }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "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.1.13" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2157,12 +2253,19 @@ } }, "node_modules/call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "license": "MIT", "dependencies": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -2202,19 +2305,24 @@ } }, "node_modules/canvas": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/canvas/-/canvas-2.11.2.tgz", - "integrity": "sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.0.1.tgz", + "integrity": "sha512-PcpVF4f8RubAeN/jCQQ/UymDKzOiLmRPph8fOTzDnlsUihkO/AUlxuhaa7wGRc3vMcCbV1fzuvyu5cWZlIcn1w==", "hasInstallScript": true, "dependencies": { - "@mapbox/node-pre-gyp": "^1.0.0", - "nan": "^2.17.0", + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.1", "simple-get": "^3.0.3" }, "engines": { - "node": ">=6" + "node": "^18.12.0 || >= 20.9.0" } }, + "node_modules/canvas/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==" + }, "node_modules/chai": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.1.tgz", @@ -2493,9 +2601,9 @@ "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==" }, "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", "dependencies": { "safe-buffer": "5.2.1" }, @@ -2566,17 +2674,21 @@ } }, "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "engines": { + "node": ">=6.6.0" + } }, "node_modules/cookiejar": { "version": "2.1.4", @@ -2737,12 +2849,37 @@ "node": ">=6" } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/define-properties": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz", @@ -2868,9 +3005,9 @@ "dev": true }, "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "engines": { "node": ">= 0.8" } @@ -2896,6 +3033,14 @@ "node": ">=0.10.0" } }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -2977,6 +3122,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz", @@ -3445,59 +3611,74 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "engines": { + "node": ">=6" + } + }, "node_modules/express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.2", - "content-disposition": "0.5.4", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.0.1.tgz", + "integrity": "sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ==", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.0.1", + "content-disposition": "^1.0.0", "content-type": "~1.0.4", - "cookie": "0.6.0", - "cookie-signature": "1.0.6", - "debug": "2.6.9", + "cookie": "0.7.1", + "cookie-signature": "^1.2.1", + "debug": "4.3.6", "depd": "2.0.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.2.0", - "fresh": "0.5.2", + "finalhandler": "^2.0.0", + "fresh": "2.0.0", "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", + "merge-descriptors": "^2.0.0", "methods": "~1.1.2", + "mime-types": "^3.0.0", "on-finished": "2.4.1", + "once": "1.4.0", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", "proxy-addr": "~2.0.7", - "qs": "6.11.0", + "qs": "6.13.0", "range-parser": "~1.2.1", + "router": "^2.0.0", "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", + "send": "^1.1.0", + "serve-static": "^2.1.0", "setprototypeof": "1.2.0", "statuses": "2.0.1", - "type-is": "~1.6.18", + "type-is": "^2.0.0", "utils-merge": "1.0.1", "vary": "~1.1.2" }, "engines": { - "node": ">= 0.10.0" + "node": ">= 18" } }, - "node_modules/express/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" + "node_modules/express/node_modules/mime-db": { + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.53.0.tgz", + "integrity": "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==", + "engines": { + "node": ">= 0.6" } }, - "node_modules/express/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + "node_modules/express/node_modules/mime-types": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.0.tgz", + "integrity": "sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==", + "dependencies": { + "mime-db": "^1.53.0" + }, + "engines": { + "node": ">= 0.6" + } }, "node_modules/extend-shallow": { "version": "2.0.1", @@ -3600,9 +3781,9 @@ } }, "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.0.0.tgz", + "integrity": "sha512-MX6Zo2adDViYh+GcxxB1dpO43eypOGUOL12rLCOTMQv/DfIbpSJUy4oQIIZhVZkH9e+bZWKMon0XHFEju16tkQ==", "dependencies": { "debug": "2.6.9", "encodeurl": "~1.0.2", @@ -3624,6 +3805,14 @@ "ms": "2.0.0" } }, + "node_modules/finalhandler/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/finalhandler/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -3736,13 +3925,18 @@ } }, "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" + }, "node_modules/fs-minipass": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", @@ -3773,9 +3967,13 @@ } }, "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/function.prototype.name": { "version": "1.1.5", @@ -3878,13 +4076,19 @@ } }, "node_modules/get-intrinsic": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz", - "integrity": "sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "license": "MIT", "dependencies": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-symbols": "^1.0.3" + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -3954,6 +4158,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" + }, "node_modules/glob": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", @@ -4147,11 +4356,12 @@ } }, "node_modules/has-property-descriptors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz", - "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", "dependencies": { - "get-intrinsic": "^1.1.1" + "es-define-property": "^1.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4198,6 +4408,18 @@ "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -4296,9 +4518,9 @@ } }, "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.5.2.tgz", + "integrity": "sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag==", "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -4306,6 +4528,25 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/ignore": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", @@ -4633,6 +4874,11 @@ "node": ">=0.10.0" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" + }, "node_modules/is-regex": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", @@ -5306,11 +5552,11 @@ } }, "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/memorystream": { @@ -5383,9 +5629,15 @@ } }, "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/merge-stream": { "version": "2.0.0", @@ -5428,17 +5680,6 @@ "node": ">=8.6" } }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -5623,6 +5864,11 @@ "node": ">=10" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" + }, "node_modules/mocha": { "version": "10.7.3", "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.7.3.tgz", @@ -5812,10 +6058,10 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, - "node_modules/nan": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.17.0.tgz", - "integrity": "sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ==" + "node_modules/napi-build-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", + "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==" }, "node_modules/natural-compare": { "version": "1.4.0", @@ -5909,6 +6155,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "optional": true, "engines": { "node": ">= 0.6" } @@ -5923,6 +6170,17 @@ "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==" }, + "node_modules/node-abi": { + "version": "3.71.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.71.0.tgz", + "integrity": "sha512-SZ40vRiy/+wRTf21hxkkEjPJZpARzUMVcJoQse2EF8qkUWbbO2z7vd5oA/H6bVH6SZQ5STGcu0KRDS7biNRfxw==", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-addon-api": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.1.0.tgz", @@ -6319,9 +6577,13 @@ } }, "node_modules/object-inspect": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", - "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", + "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -6544,9 +6806,12 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "engines": { + "node": ">=16" + } }, "node_modules/path-type": { "version": "4.0.0", @@ -6617,6 +6882,80 @@ "fflate": "^0.8.0" } }, + "node_modules/prebuild-install": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.2.tgz", + "integrity": "sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ==", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^1.0.1", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prebuild-install/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==", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/prebuild-install/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==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/prebuild-install/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" + } + ], + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6703,6 +7042,15 @@ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, + "node_modules/pump": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", + "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.2.0.tgz", @@ -6713,11 +7061,12 @@ } }, "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.4" + "side-channel": "^1.0.6" }, "engines": { "node": ">=0.6" @@ -6777,19 +7126,57 @@ } }, "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", + "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", - "iconv-lite": "0.4.24", + "iconv-lite": "0.6.3", "unpipe": "1.0.0" }, "engines": { "node": ">= 0.8" } }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/read-pkg": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", @@ -7087,6 +7474,23 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/router": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.0.0.tgz", + "integrity": "sha512-dIM5zVoG8xhC6rnSN8uoAgFARwTE7BQs8YwHEvK0VCmfxQXMaOuA1uiR1IPwsW7JyK5iTt7Od/TC9StasS2NPQ==", + "dependencies": { + "array-flatten": "3.0.0", + "is-promise": "4.0.0", + "methods": "~1.1.2", + "parseurl": "~1.3.3", + "path-to-regexp": "^8.0.0", + "setprototypeof": "1.2.0", + "utils-merge": "1.0.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -7187,41 +7591,35 @@ } }, "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.1.0.tgz", + "integrity": "sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==", "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" + "debug": "^4.3.5", + "destroy": "^1.2.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^0.5.2", + "http-errors": "^2.0.0", + "mime-types": "^2.1.35", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 18" } }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" + "node_modules/send/node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" } }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, "node_modules/send/node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -7237,17 +7635,17 @@ } }, "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.1.0.tgz", + "integrity": "sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA==", "dependencies": { - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.18.0" + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.0.0" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 18" } }, "node_modules/set-blocking": { @@ -7255,6 +7653,23 @@ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/set-value": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", @@ -7407,13 +7822,18 @@ "dev": true }, "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -7980,6 +8400,37 @@ "node": ">=10" } }, + "node_modules/tar-fs": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", + "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tar/node_modules/minipass": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", @@ -8086,6 +8537,17 @@ "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", "devOptional": true }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -8111,12 +8573,32 @@ } }, "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.0.tgz", + "integrity": "sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw==", "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.53.0.tgz", + "integrity": "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.0.tgz", + "integrity": "sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==", + "dependencies": { + "mime-db": "^1.53.0" }, "engines": { "node": ">= 0.6" diff --git a/package.json b/package.json index 86cf087de..3d40623a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tileserver-gl", - "version": "5.0.0", + "version": "5.1.0-pre.0", "description": "Map tile server for JSON GL styles - vector and server side generated raster tiles", "main": "src/main.js", "bin": "src/main.js", @@ -28,13 +28,13 @@ "@sindresorhus/fnv1a": "3.1.0", "advanced-pool": "0.3.3", "axios": "^1.7.7", - "canvas": "2.11.2", + "canvas": "3.0.1", "chokidar": "3.6.0", "clone": "2.1.2", "color": "4.2.3", "commander": "12.1.0", "cors": "2.8.5", - "express": "4.19.2", + "express": "5.0.1", "handlebars": "4.7.8", "http-shutdown": "1.2.2", "morgan": "1.10.0", diff --git a/src/main.js b/src/main.js index 7523aa937..b1f14a239 100644 --- a/src/main.js +++ b/src/main.js @@ -1,6 +1,12 @@ #!/usr/bin/env node 'use strict'; +import os from 'os'; + +const envSize = parseInt(process.env.UV_THREADPOOL_SIZE, 10); +process.env.UV_THREADPOOL_SIZE = Math.ceil( + Math.max(4, isNaN(envSize) ? os.cpus().length * 1.5 : envSize), +); import fs from 'node:fs'; import fsp from 'node:fs/promises'; diff --git a/src/serve_data.js b/src/serve_data.js index b2a0f5a7c..cd2e6bbf7 100644 --- a/src/serve_data.js +++ b/src/serve_data.js @@ -11,350 +11,336 @@ import SphericalMercator from '@mapbox/sphericalmercator'; import { Image, createCanvas } from 'canvas'; import sharp from 'sharp'; -import { fixTileJSONCenter, getTileUrls, isValidHttpUrl } from './utils.js'; import { - getPMtilesInfo, - getPMtilesTile, - openPMtiles, -} from './pmtiles_adapter.js'; + fixTileJSONCenter, + getTileUrls, + isValidHttpUrl, + fetchTileData, +} from './utils.js'; +import { getPMtilesInfo, openPMtiles } from './pmtiles_adapter.js'; import { gunzipP, gzipP } from './promises.js'; import { openMbTilesWrapper } from './mbtiles_wrapper.js'; export const serve_data = { - init: (options, repo) => { + /** + * Initializes the serve_data module. + * @param {object} options Configuration options. + * @param {object} repo Repository object. + * @param {object} programOpts - An object containing the program options + * @returns {express.Application} The initialized Express application. + */ + init: function (options, repo, programOpts) { + const { verbose } = programOpts; const app = express().disable('x-powered-by'); - app.get( - '/:id/:z(\\d+)/:x(\\d+)/:y(\\d+).:format([\\w.]+)', - async (req, res, next) => { - const item = repo[req.params.id]; - if (!item) { - return res.sendStatus(404); - } - const tileJSONFormat = item.tileJSON.format; - const z = req.params.z | 0; - const x = req.params.x | 0; - const y = req.params.y | 0; - let format = req.params.format; - if (format === options.pbfAlias) { - format = 'pbf'; - } - if ( - format !== tileJSONFormat && - !(format === 'geojson' && tileJSONFormat === 'pbf') - ) { - return res.status(404).send('Invalid format'); - } - if ( - z < item.tileJSON.minzoom || - 0 || - x < 0 || - y < 0 || - z > item.tileJSON.maxzoom || - x >= Math.pow(2, z) || - y >= Math.pow(2, z) - ) { - return res.status(404).send('Out of bounds'); - } - if (item.sourceType === 'pmtiles') { - let tileinfo = await getPMtilesTile(item.source, z, x, y); - if (tileinfo == undefined || tileinfo.data == undefined) { - return res.status(404).send('Not found'); - } else { - let data = tileinfo.data; - let headers = tileinfo.header; - if (tileJSONFormat === 'pbf') { - if (options.dataDecoratorFunc) { - data = options.dataDecoratorFunc(id, 'data', data, z, x, y); - } - } - if (format === 'pbf') { - headers['Content-Type'] = 'application/x-protobuf'; - } else if (format === 'geojson') { - headers['Content-Type'] = 'application/json'; - const tile = new VectorTile(new Pbf(data)); - const geojson = { - type: 'FeatureCollection', - features: [], - }; - for (const layerName in tile.layers) { - const layer = tile.layers[layerName]; - for (let i = 0; i < layer.length; i++) { - const feature = layer.feature(i); - const featureGeoJSON = feature.toGeoJSON(x, y, z); - featureGeoJSON.properties.layer = layerName; - geojson.features.push(featureGeoJSON); - } - } - data = JSON.stringify(geojson); - } - delete headers['ETag']; // do not trust the tile ETag -- regenerate - headers['Content-Encoding'] = 'gzip'; - res.set(headers); + /** + * Handles requests for tile data, responding with the tile image. + * @param {object} req - Express request object. + * @param {object} res - Express response object. + * @param {string} req.params.id - ID of the tile. + * @param {string} req.params.z - Z coordinate of the tile. + * @param {string} req.params.x - X coordinate of the tile. + * @param {string} req.params.y - Y coordinate of the tile. + * @param {string} req.params.format - Format of the tile. + * @returns {Promise} + */ + app.get('/:id/:z/:x/:y.:format', async (req, res) => { + if (verbose) { + console.log( + `Handling tile request for: /data/%s/%s/%s/%s.%s`, + String(req.params.id).replace(/\n|\r/g, ''), + String(req.params.z).replace(/\n|\r/g, ''), + String(req.params.x).replace(/\n|\r/g, ''), + String(req.params.y).replace(/\n|\r/g, ''), + String(req.params.format).replace(/\n|\r/g, ''), + ); + } + const item = repo[req.params.id]; + if (!item) { + return res.sendStatus(404); + } + const tileJSONFormat = item.tileJSON.format; + const z = parseInt(req.params.z, 10); + const x = parseInt(req.params.x, 10); + const y = parseInt(req.params.y, 10); + if (isNaN(z) || isNaN(x) || isNaN(y)) { + return res.status(404).send('Invalid Tile'); + } - data = await gzipP(data); + let format = req.params.format; + if (format === options.pbfAlias) { + format = 'pbf'; + } + if ( + format !== tileJSONFormat && + !(format === 'geojson' && tileJSONFormat === 'pbf') + ) { + return res.status(404).send('Invalid format'); + } + if ( + z < item.tileJSON.minzoom || + x < 0 || + y < 0 || + z > item.tileJSON.maxzoom || + x >= Math.pow(2, z) || + y >= Math.pow(2, z) + ) { + return res.status(404).send('Out of bounds'); + } - return res.status(200).send(data); + const fetchTile = await fetchTileData( + item.source, + item.sourceType, + z, + x, + y, + ); + if (fetchTile == null) return res.status(204).send(); + + let data = fetchTile.data; + let headers = fetchTile.headers; + let isGzipped = data.slice(0, 2).indexOf(Buffer.from([0x1f, 0x8b])) === 0; + + if (tileJSONFormat === 'pbf') { + if (options.dataDecoratorFunc) { + if (isGzipped) { + data = await gunzipP(data); + isGzipped = false; } - } else if (item.sourceType === 'mbtiles') { - item.source.getTile(z, x, y, async (err, data, headers) => { - let isGzipped; - if (err) { - if (/does not exist/.test(err.message)) { - return res.status(204).send(); - } else { - return res - .status(500) - .header('Content-Type', 'text/plain') - .send(err.message); - } - } else { - if (data == null) { - return res.status(404).send('Not found'); - } else { - if (tileJSONFormat === 'pbf') { - isGzipped = - data.slice(0, 2).indexOf(Buffer.from([0x1f, 0x8b])) === 0; - if (options.dataDecoratorFunc) { - if (isGzipped) { - data = await gunzipP(data); - isGzipped = false; - } - data = options.dataDecoratorFunc(id, 'data', data, z, x, y); - } - } - if (format === 'pbf') { - headers['Content-Type'] = 'application/x-protobuf'; - } else if (format === 'geojson') { - headers['Content-Type'] = 'application/json'; - - if (isGzipped) { - data = await gunzipP(data); - isGzipped = false; - } - - const tile = new VectorTile(new Pbf(data)); - const geojson = { - type: 'FeatureCollection', - features: [], - }; - for (const layerName in tile.layers) { - const layer = tile.layers[layerName]; - for (let i = 0; i < layer.length; i++) { - const feature = layer.feature(i); - const featureGeoJSON = feature.toGeoJSON(x, y, z); - featureGeoJSON.properties.layer = layerName; - geojson.features.push(featureGeoJSON); - } - } - data = JSON.stringify(geojson); - } - delete headers['ETag']; // do not trust the tile ETag -- regenerate - headers['Content-Encoding'] = 'gzip'; - res.set(headers); - - if (!isGzipped) { - data = await gzipP(data); - } - - return res.status(200).send(data); - } - } - }); + data = options.dataDecoratorFunc( + req.params.id, + 'data', + data, + z, + x, + y, + ); } - }, - ); - - app.get( - '^/:id/elevation/:z([0-9]+)/:x([-.0-9]+)/:y([-.0-9]+)', - async (req, res, next) => { - try { - const item = repo?.[req.params.id]; - if (!item) return res.sendStatus(404); - if (!item.source) return res.status(404).send('Missing source'); - if (!item.tileJSON) return res.status(404).send('Missing tileJSON'); - if (!item.sourceType) - return res.status(404).send('Missing sourceType'); - - const { source, tileJSON, sourceType } = item; - - if (sourceType !== 'pmtiles' && sourceType !== 'mbtiles') { - return res - .status(400) - .send('Invalid sourceType. Must be pmtiles or mbtiles.'); - } + } - const encoding = tileJSON?.encoding; - if (encoding == null) { - return res.status(400).send('Missing tileJSON.encoding'); - } else if (encoding !== 'terrarium' && encoding !== 'mapbox') { - return res - .status(400) - .send('Invalid encoding. Must be terrarium or mapbox.'); + if (format === 'pbf') { + headers['Content-Type'] = 'application/x-protobuf'; + } else if (format === 'geojson') { + headers['Content-Type'] = 'application/json'; + const tile = new VectorTile(new Pbf(data)); + const geojson = { + type: 'FeatureCollection', + features: [], + }; + for (const layerName in tile.layers) { + const layer = tile.layers[layerName]; + for (let i = 0; i < layer.length; i++) { + const feature = layer.feature(i); + const featureGeoJSON = feature.toGeoJSON(x, y, z); + featureGeoJSON.properties.layer = layerName; + geojson.features.push(featureGeoJSON); } + } + data = JSON.stringify(geojson); + } + if (headers) { + delete headers['ETag']; + } + headers['Content-Encoding'] = 'gzip'; + res.set(headers); - const format = tileJSON?.format; - if (format == null) { - return res.status(400).send('Missing tileJSON.format'); - } else if (format !== 'webp' && format !== 'png') { - return res.status(400).send('Invalid format. Must be webp or png.'); - } + if (!isGzipped) { + data = await gzipP(data); + } - const z = parseInt(req.params.z, 10); - const x = parseFloat(req.params.x); - const y = parseFloat(req.params.y); + return res.status(200).send(data); + }); - if (tileJSON.minzoom == null || tileJSON.maxzoom == null) { - return res.status(404).send(JSON.stringify(tileJSON)); + /** + * Handles requests for elevation data. + * @param {object} req - Express request object. + * @param {object} res - Express response object. + * @param {string} req.params.id - ID of the elevation data. + * @param {string} req.params.z - Z coordinate of the tile. + * @param {string} req.params.x - X coordinate of the tile (either integer or float). + * @param {string} req.params.y - Y coordinate of the tile (either integer or float). + * @returns {Promise} + */ + app.get('/:id/elevation/:z/:x/:y', async (req, res, next) => { + try { + if (verbose) { + console.log( + `Handling elevation request for: /data/%s/elevation/%s/%s/%s`, + String(req.params.id).replace(/\n|\r/g, ''), + String(req.params.z).replace(/\n|\r/g, ''), + String(req.params.x).replace(/\n|\r/g, ''), + String(req.params.y).replace(/\n|\r/g, ''), + ); + } + const item = repo?.[req.params.id]; + if (!item) return res.sendStatus(404); + if (!item.source) return res.status(404).send('Missing source'); + if (!item.tileJSON) return res.status(404).send('Missing tileJSON'); + if (!item.sourceType) return res.status(404).send('Missing sourceType'); + const { source, tileJSON, sourceType } = item; + if (sourceType !== 'pmtiles' && sourceType !== 'mbtiles') { + return res + .status(400) + .send('Invalid sourceType. Must be pmtiles or mbtiles.'); + } + const encoding = tileJSON?.encoding; + if (encoding == null) { + return res.status(400).send('Missing tileJSON.encoding'); + } else if (encoding !== 'terrarium' && encoding !== 'mapbox') { + return res + .status(400) + .send('Invalid encoding. Must be terrarium or mapbox.'); + } + const format = tileJSON?.format; + if (format == null) { + return res.status(400).send('Missing tileJSON.format'); + } else if (format !== 'webp' && format !== 'png') { + return res.status(400).send('Invalid format. Must be webp or png.'); + } + const z = parseInt(req.params.z, 10); + const x = parseFloat(req.params.x); + const y = parseFloat(req.params.y); + if (tileJSON.minzoom == null || tileJSON.maxzoom == null) { + return res.status(404).send(JSON.stringify(tileJSON)); + } + const TILE_SIZE = tileJSON.tileSize || 512; + let bbox; + let xy; + var zoom = z; + + if (Number.isInteger(x) && Number.isInteger(y)) { + const intX = parseInt(req.params.x, 10); + const intY = parseInt(req.params.y, 10); + if ( + zoom < tileJSON.minzoom || + zoom > tileJSON.maxzoom || + intX < 0 || + intY < 0 || + intX >= Math.pow(2, zoom) || + intY >= Math.pow(2, zoom) + ) { + return res.status(404).send('Out of bounds'); } + xy = [intX, intY]; + bbox = new SphericalMercator().bbox(intX, intY, zoom); + } else { + //no zoom limit with coordinates + if (zoom < tileJSON.minzoom) { + zoom = tileJSON.minzoom; + } + if (zoom > tileJSON.maxzoom) { + zoom = tileJSON.maxzoom; + } + bbox = [x, y, x + 0.1, y + 0.1]; + const { minX, minY } = new SphericalMercator().xyz(bbox, zoom); + xy = [minX, minY]; + } - const TILE_SIZE = tileJSON.tileSize || 512; - let bbox; - let xy; - var zoom = z; - - if (Number.isInteger(x) && Number.isInteger(y)) { - const intX = parseInt(req.params.x, 10); - const intY = parseInt(req.params.y, 10); - + const fetchTile = await fetchTileData( + source, + sourceType, + zoom, + xy[0], + xy[1], + ); + if (fetchTile == null) return res.status(204).send(); + + let data = fetchTile.data; + const image = new Image(); + await new Promise(async (resolve, reject) => { + image.onload = async () => { + const canvas = createCanvas(TILE_SIZE, TILE_SIZE); + const context = canvas.getContext('2d'); + context.drawImage(image, 0, 0); + const long = bbox[0]; + const lat = bbox[1]; + + // calculate pixel coordinate of tile, + // see https://developers.google.com/maps/documentation/javascript/examples/map-coordinates + let siny = Math.sin((lat * Math.PI) / 180); + // Truncating to 0.9999 effectively limits latitude to 89.189. This is + // about a third of a tile past the edge of the world tile. + siny = Math.min(Math.max(siny, -0.9999), 0.9999); + const xWorld = TILE_SIZE * (0.5 + long / 360); + const yWorld = + TILE_SIZE * + (0.5 - Math.log((1 + siny) / (1 - siny)) / (4 * Math.PI)); + + const scale = 1 << zoom; + + const xTile = Math.floor((xWorld * scale) / TILE_SIZE); + const yTile = Math.floor((yWorld * scale) / TILE_SIZE); + + const xPixel = Math.floor(xWorld * scale) - xTile * TILE_SIZE; + const yPixel = Math.floor(yWorld * scale) - yTile * TILE_SIZE; if ( - zoom < tileJSON.minzoom || - zoom > tileJSON.maxzoom || - intX < 0 || - intY < 0 || - intX >= Math.pow(2, zoom) || - intY >= Math.pow(2, zoom) + xPixel < 0 || + yPixel < 0 || + xPixel >= TILE_SIZE || + yPixel >= TILE_SIZE ) { - return res.status(404).send('Out of bounds'); + return reject('Out of bounds Pixel'); } - xy = [intX, intY]; - bbox = new SphericalMercator().bbox(intX, intY, zoom); - } else { - //no zoom limit with coordinates - if (zoom < tileJSON.minzoom) { - zoom = tileJSON.minzoom; + const imgdata = context.getImageData(xPixel, yPixel, 1, 1); + const red = imgdata.data[0]; + const green = imgdata.data[1]; + const blue = imgdata.data[2]; + let elevation; + if (encoding === 'mapbox') { + elevation = -10000 + (red * 256 * 256 + green * 256 + blue) * 0.1; + } else if (encoding === 'terrarium') { + elevation = red * 256 + green + blue / 256 - 32768; + } else { + elevation = 'invalid encoding'; } - if (zoom > tileJSON.maxzoom) { - zoom = tileJSON.maxzoom; + resolve( + res.status(200).send({ + z: zoom, + x: xy[0], + y: xy[1], + red, + green, + blue, + latitude: lat, + longitude: long, + elevation, + }), + ); + }; + image.onerror = (err) => reject(err); + if (format === 'webp') { + try { + const img = await sharp(data).toFormat('png').toBuffer(); + image.src = img; + } catch (err) { + reject(err); } - - bbox = [x, y, x + 0.1, y + 0.1]; - const { minX, minY } = new SphericalMercator().xyz(bbox, zoom); - xy = [minX, minY]; - } - - let data; - if (sourceType === 'pmtiles') { - const tileinfo = await getPMtilesTile(source, zoom, xy[0], xy[1]); - if (!tileinfo?.data) return res.status(204).send(); - data = tileinfo.data; } else { - data = await new Promise((resolve, reject) => { - source.getTile(zoom, xy[0], xy[1], (err, tileData) => { - if (err) { - return /does not exist/.test(err.message) - ? resolve(null) - : reject(err); - } - resolve(tileData); - }); - }); + image.src = data; } - if (data == null) return res.status(204).send(); - if (!data) return res.status(404).send('Not found'); - - const image = new Image(); - await new Promise(async (resolve, reject) => { - image.onload = async () => { - const canvas = createCanvas(TILE_SIZE, TILE_SIZE); - const context = canvas.getContext('2d'); - context.drawImage(image, 0, 0); - - const long = bbox[0]; - const lat = bbox[1]; - - // calculate pixel coordinate of tile, - // see https://developers.google.com/maps/documentation/javascript/examples/map-coordinates - let siny = Math.sin((lat * Math.PI) / 180); - // Truncating to 0.9999 effectively limits latitude to 89.189. This is - // about a third of a tile past the edge of the world tile. - siny = Math.min(Math.max(siny, -0.9999), 0.9999); - - const xWorld = TILE_SIZE * (0.5 + long / 360); - const yWorld = - TILE_SIZE * - (0.5 - Math.log((1 + siny) / (1 - siny)) / (4 * Math.PI)); - - const scale = 1 << zoom; - - const xTile = Math.floor((xWorld * scale) / TILE_SIZE); - const yTile = Math.floor((yWorld * scale) / TILE_SIZE); - - const xPixel = Math.floor(xWorld * scale) - xTile * TILE_SIZE; - const yPixel = Math.floor(yWorld * scale) - yTile * TILE_SIZE; - - if ( - xPixel < 0 || - yPixel < 0 || - xPixel >= TILE_SIZE || - yPixel >= TILE_SIZE - ) { - return reject('Pixel is out of bounds'); - } - - const imgdata = context.getImageData(xPixel, yPixel, 1, 1); - const red = imgdata.data[0]; - const green = imgdata.data[1]; - const blue = imgdata.data[2]; - - let elevation; - if (encoding === 'mapbox') { - elevation = - -10000 + (red * 256 * 256 + green * 256 + blue) * 0.1; - } else if (encoding === 'terrarium') { - elevation = red * 256 + green + blue / 256 - 32768; - } else { - elevation = 'invalid encoding'; - } - - resolve( - res.status(200).send({ - z: zoom, - x: xy[0], - y: xy[1], - red, - green, - blue, - latitude: lat, - longitude: long, - elevation, - }), - ); - }; - - image.onerror = (err) => reject(err); - - if (format === 'webp') { - try { - const img = await sharp(data).toFormat('png').toBuffer(); - image.src = img; - } catch (err) { - reject(err); - } - } else { - image.src = data; - } - }); - } catch (err) { - return res - .status(500) - .header('Content-Type', 'text/plain') - .send(err.message); - } - }, - ); + }); + } catch (err) { + return res + .status(500) + .header('Content-Type', 'text/plain') + .send(err.message); + } + }); - app.get('/:id.json', (req, res, next) => { + /** + * Handles requests for tilejson for the data tiles. + * @param {object} req - Express request object. + * @param {object} res - Express response object. + * @param {string} req.params.id - ID of the data source. + * @returns {Promise} + */ + app.get('/:id.json', (req, res) => { + if (verbose) { + console.log( + `Handling tilejson request for: /data/%s.json`, + String(req.params.id).replace(/\n|\r/g, ''), + ); + } const item = repo[req.params.id]; if (!item) { return res.sendStatus(404); @@ -377,7 +363,20 @@ export const serve_data = { return app; }, - add: async (options, repo, params, id, publicUrl) => { + /** + * Adds a new data source to the repository. + * @param {object} options Configuration options. + * @param {object} repo Repository object. + * @param {object} params Parameters object. + * @param {string} id ID of the data source. + * @param {object} programOpts - An object containing the program options + * @param {string} programOpts.publicUrl Public URL for the data. + * @param {boolean} programOpts.verbose Whether verbose logging should be used. + * @param {Function} dataResolver Function to resolve data. + * @returns {Promise} + */ + add: async function (options, repo, params, id, programOpts) { + const { publicUrl } = programOpts; let inputFile; let inputType; if (params.pmtiles) { diff --git a/src/serve_font.js b/src/serve_font.js index 02f46dc05..a42246bed 100644 --- a/src/serve_font.js +++ b/src/serve_font.js @@ -4,7 +4,15 @@ import express from 'express'; import { getFontsPbf, listFonts } from './utils.js'; -export const serve_font = async (options, allowedFonts) => { +/** + * Initializes and returns an Express app that serves font files. + * @param {object} options - Configuration options for the server. + * @param {object} allowedFonts - An object containing allowed fonts. + * @param {object} programOpts - An object containing the program options. + * @returns {Promise} - A promise that resolves to the Express app. + */ +export async function serve_font(options, allowedFonts, programOpts) { + const { verbose } = programOpts; const app = express().disable('x-powered-by'); const lastModified = new Date().toUTCString(); @@ -13,31 +21,74 @@ export const serve_font = async (options, allowedFonts) => { const existingFonts = {}; - app.get( - '/fonts/:fontstack/:range([\\d]+-[\\d]+).pbf', - async (req, res, next) => { - const fontstack = decodeURI(req.params.fontstack); - const range = req.params.range; - - try { - const concatenated = await getFontsPbf( - options.serveAllFonts ? null : allowedFonts, - fontPath, - fontstack, - range, - existingFonts, - ); - - res.header('Content-type', 'application/x-protobuf'); - res.header('Last-Modified', lastModified); - return res.send(concatenated); - } catch (err) { - res.status(400).header('Content-Type', 'text/plain').send(err); + /** + * Handles requests for a font file. + * @param {object} req - Express request object. + * @param {object} res - Express response object. + * @param {string} req.params.fontstack - Name of the font stack. + * @param {string} req.params.range - The range of the font (e.g. 0-255). + * @returns {Promise} + */ + app.get('/fonts/:fontstack/:range.pbf', async (req, res) => { + const sRange = String(req.params.range).replace(/\n|\r/g, ''); + const sFontStack = String(decodeURI(req.params.fontstack)).replace( + /\n|\r/g, + '', + ); + + if (verbose) { + console.log( + `Handling font request for: /fonts/%s/%s.pbf`, + sFontStack, + sRange, + ); + } + + const modifiedSince = req.get('if-modified-since'); + const cc = req.get('cache-control'); + if (modifiedSince && (!cc || cc.indexOf('no-cache') === -1)) { + if ( + new Date(lastModified).getTime() === new Date(modifiedSince).getTime() + ) { + return res.sendStatus(304); } - }, - ); + } + + try { + const concatenated = await getFontsPbf( + options.serveAllFonts ? null : allowedFonts, + fontPath, + sFontStack, + sRange, + existingFonts, + ); + res.header('Content-type', 'application/x-protobuf'); + res.header('Last-Modified', lastModified); + return res.send(concatenated); + } catch (err) { + console.error( + `Error serving font: %s/%s.pbf, Error: %s`, + sFontStack, + sRange, + String(err), + ); + return res + .status(400) + .header('Content-Type', 'text/plain') + .send('Error serving font'); + } + }); - app.get('/fonts.json', (req, res, next) => { + /** + * Handles requests for a list of all available fonts. + * @param {object} req - Express request object. + * @param {object} res - Express response object. + * @returns {void} + */ + app.get('/fonts.json', (req, res) => { + if (verbose) { + console.log('Handling list font request for /fonts.json'); + } res.header('Content-type', 'application/json'); return res.send( Object.keys(options.serveAllFonts ? existingFonts : allowedFonts).sort(), @@ -47,4 +98,4 @@ export const serve_font = async (options, allowedFonts) => { const fonts = await listFonts(options.paths.fonts); Object.assign(existingFonts, fonts); return app; -}; +} diff --git a/src/serve_light.js b/src/serve_light.js index 474a78111..7e49c4929 100644 --- a/src/serve_light.js +++ b/src/serve_light.js @@ -3,7 +3,7 @@ 'use strict'; export const serve_rendered = { - init: (options, repo) => {}, - add: (options, repo, params, id, publicUrl, dataResolver) => {}, + init: (options, repo, programOpts) => {}, + add: (options, repo, params, id, programOpts, dataResolver) => {}, remove: (repo, id) => {}, }; diff --git a/src/serve_rendered.js b/src/serve_rendered.js index 3e5c94eaa..af928d993 100644 --- a/src/serve_rendered.js +++ b/src/serve_rendered.js @@ -13,7 +13,6 @@ import '@maplibre/maplibre-gl-native'; // SECTION END import advancedPool from 'advanced-pool'; -import fs from 'node:fs'; import path from 'path'; import url from 'url'; import util from 'util'; @@ -28,29 +27,45 @@ import polyline from '@mapbox/polyline'; import proj4 from 'proj4'; import axios from 'axios'; import { + allowedScales, + allowedTileSizes, getFontsPbf, listFonts, getTileUrls, isValidHttpUrl, fixTileJSONCenter, + fetchTileData, + readFile, } from './utils.js'; -import { - openPMtiles, - getPMtilesInfo, - getPMtilesTile, -} from './pmtiles_adapter.js'; +import { openPMtiles, getPMtilesInfo } from './pmtiles_adapter.js'; import { renderOverlay, renderWatermark, renderAttribution } from './render.js'; import fsp from 'node:fs/promises'; import { existsP, gunzipP } from './promises.js'; import { openMbTilesWrapper } from './mbtiles_wrapper.js'; -const FLOAT_PATTERN = '[+-]?(?:\\d+|\\d+.?\\d+)'; +const FLOAT_PATTERN = '[+-]?(?:\\d+|\\d*\\.\\d+)'; + +const staticTypeRegex = new RegExp( + `^` + + `(?:` + + // Format 1: {lon},{lat},{zoom}[@{bearing}[,{pitch}]] + `(?${FLOAT_PATTERN}),(?${FLOAT_PATTERN}),(?${FLOAT_PATTERN})` + + `(?:@(?${FLOAT_PATTERN})(?:,(?${FLOAT_PATTERN}))?)?` + + `|` + + // Format 2: {minx},{miny},{maxx},{maxy} + `(?${FLOAT_PATTERN}),(?${FLOAT_PATTERN}),(?${FLOAT_PATTERN}),(?${FLOAT_PATTERN})` + + `|` + + // Format 3: auto + `(?auto)` + + `)` + + `$`, +); + const PATH_PATTERN = /^((fill|stroke|width)\:[^\|]+\|)*(enc:.+|-?\d+(\.\d*)?,-?\d+(\.\d*)?(\|-?\d+(\.\d*)?,-?\d+(\.\d*)?)+)/; const httpTester = /^https?:\/\//i; const mercator = new SphericalMercator(); -const getScale = (scale) => (scale || '@1x').slice(1, 2) | 0; mlgl.on('message', (e) => { if (e.severity === 'WARNING' || e.severity === 'ERROR') { @@ -81,6 +96,7 @@ const cachedEmptyResponses = { * @param {string} format The format (a sharp format or 'pbf'). * @param {string} color The background color (or empty string for transparent). * @param {Function} callback The mlgl callback. + * @returns {void} */ function createEmptyResponse(format, color, callback) { if (!format || format === 'pbf') { @@ -103,33 +119,42 @@ function createEmptyResponse(format, color, callback) { } // create an "empty" response image - color = new Color(color); - const array = color.array(); - const channels = array.length === 4 && format !== 'jpeg' ? 4 : 3; - sharp(Buffer.from(array), { - raw: { - width: 1, - height: 1, - channels, - }, - }) - .toFormat(format) - .toBuffer((err, buffer, info) => { - if (!err) { + try { + color = new Color(color); + const array = color.array(); + const channels = array.length === 4 && format !== 'jpeg' ? 4 : 3; + sharp(Buffer.from(array), { + raw: { + width: 1, + height: 1, + channels, + }, + }) + .toFormat(format) + .toBuffer((err, buffer, info) => { + if (err) { + console.error('Error creating image with Sharp:', err); + callback(err, null); + return; + } cachedEmptyResponses[cacheKey] = buffer; - } - callback(null, { data: buffer }); - }); + callback(null, { data: buffer }); + }); + } catch (error) { + console.error('Error during image processing setup:', error); + callback(error, null); + } } /** * Parses coordinate pair provided to pair of floats and ensures the resulting * pair is a longitude/latitude combination depending on lnglat query parameter. - * @param {List} coordinatePair Coordinate pair. + * @param {Array} coordinatePair Coordinate pair. * @param coordinates * @param {object} query Request query parameters. + * @returns {Array|null} Parsed coordinate pair as [longitude, latitude] or null if invalid */ -const parseCoordinatePair = (coordinates, query) => { +function parseCoordinatePair(coordinates, query) { const firstCoordinate = parseFloat(coordinates[0]); const secondCoordinate = parseFloat(coordinates[1]); @@ -145,15 +170,16 @@ const parseCoordinatePair = (coordinates, query) => { } return [firstCoordinate, secondCoordinate]; -}; +} /** * Parses a coordinate pair from query arguments and optionally transforms it. - * @param {List} coordinatePair Coordinate pair. + * @param {Array} coordinatePair Coordinate pair. * @param {object} query Request query parameters. * @param {Function} transformer Optional transform function. + * @returns {Array|null} Transformed coordinate pair or null if invalid. */ -const parseCoordinates = (coordinatePair, query, transformer) => { +function parseCoordinates(coordinatePair, query, transformer) { const parsedCoordinates = parseCoordinatePair(coordinatePair, query); // Transform coordinates @@ -162,14 +188,15 @@ const parseCoordinates = (coordinatePair, query, transformer) => { } return parsedCoordinates; -}; +} /** * Parses paths provided via query into a list of path objects. * @param {object} query Request query parameters. * @param {Function} transformer Optional transform function. + * @returns {Array>>} Array of paths. */ -const extractPathsFromQuery = (query, transformer) => { +function extractPathsFromQuery(query, transformer) { // Initiate paths array const paths = []; // Return an empty list if no paths have been provided @@ -221,17 +248,17 @@ const extractPathsFromQuery = (query, transformer) => { } } return paths; -}; - +} /** * Parses marker options provided via query and sets corresponding attributes * on marker object. * Options adhere to the following format * [optionName]:[optionValue] - * @param {List[String]} optionsList List of option strings. + * @param {Array} optionsList List of option strings. * @param {object} marker Marker object to configure. + * @returns {void} */ -const parseMarkerOptions = (optionsList, marker) => { +function parseMarkerOptions(optionsList, marker) { for (const options of optionsList) { const optionParts = options.split(':'); // Ensure we got an option name and value @@ -258,15 +285,16 @@ const parseMarkerOptions = (optionsList, marker) => { break; } } -}; +} /** * Parses markers provided via query into a list of marker objects. * @param {object} query Request query parameters. * @param {object} options Configuration options. * @param {Function} transformer Optional transform function. + * @returns {Array} An array of marker objects. */ -const extractMarkersFromQuery = (query, options, transformer) => { +function extractMarkersFromQuery(query, options, transformer) { // Return an empty list if no markers have been provided if (!query.marker) { return []; @@ -342,9 +370,16 @@ const extractMarkersFromQuery = (query, options, transformer) => { markers.push(marker); } return markers; -}; - -const calcZForBBox = (bbox, w, h, query) => { +} +/** + * Calculates the zoom level for a given bounding box. + * @param {Array} bbox Bounding box as [minx, miny, maxx, maxy]. + * @param {number} w Width of the image. + * @param {number} h Height of the image. + * @param {object} query Request query parameters. + * @returns {number} Calculated zoom level. + */ +function calcZForBBox(bbox, w, h, query) { let z = 25; const padding = query.padding !== undefined ? parseFloat(query.padding) : 0.1; @@ -363,9 +398,27 @@ const calcZForBBox = (bbox, w, h, query) => { z = Math.max(Math.log(Math.max(w, h) / 256) / Math.LN2, Math.min(25, z)); return z; -}; +} -const respondImage = ( +/** + * Responds with an image. + * @param {object} options Configuration options. + * @param {object} item Item object containing map and other information. + * @param {number} z Zoom level. + * @param {number} lon Longitude of the center. + * @param {number} lat Latitude of the center. + * @param {number} bearing Map bearing. + * @param {number} pitch Map pitch. + * @param {number} width Width of the image. + * @param {number} height Height of the image. + * @param {number} scale Scale factor. + * @param {string} format Image format. + * @param {object} res Express response object. + * @param {Buffer|null} overlay Optional overlay image. + * @param {string} mode Rendering mode ('tile' or 'static'). + * @returns {Promise} + */ +async function respondImage( options, item, z, @@ -380,7 +433,7 @@ const respondImage = ( res, overlay = null, mode = 'tile', -) => { +) { if ( Math.abs(lon) > 180 || Math.abs(lat) > 85.06 || @@ -413,7 +466,8 @@ const respondImage = ( } else { pool = item.map.renderersStatic[scale]; } - pool.acquire((err, renderer) => { + + pool.acquire(async (err, renderer) => { // For 512px tiles, use the actual maplibre-native zoom. For 256px tiles, use zoom - 1 let mlglZ; if (width === 512) { @@ -472,8 +526,8 @@ const respondImage = ( height: height * scale, }); } - // HACK(Part 2) 256px tiles are a zoom level lower than maplibre-native default tiles. this hack allows tileserver-gl to support zoom 0 256px tiles, which would actually be zoom -1 in maplibre-native. Since zoom -1 isn't supported, a double sized zoom 0 tile is requested and resized here. + if (z === 0 && width === 256) { image.resize(width * scale, height * scale); } @@ -547,320 +601,410 @@ const respondImage = ( }); }); }); -}; +} -const existingFonts = {}; -let maxScaleFactor = 2; +/** + * Handles requests for tile images. + * @param {object} options - Configuration options for the server. + * @param {object} repo - The repository object holding style data. + * @param {object} req - Express request object. + * @param {string} req.params.id - The id of the style. + * @param {string} req.params.p1 - The tile size parameter, if available. + * @param {string} req.params.p2 - The z parameter. + * @param {string} req.params.p3 - The x parameter. + * @param {string} req.params.p4 - The y parameter. + * @param {string} req.params.scale - The scale parameter. + * @param {string} req.params.format - The format of the image. + * @param {object} res - Express response object. + * @param {Function} next - Express next middleware function. + * @param {number} maxScaleFactor - The maximum scale factor allowed. + * @param defailtTileSize + * @returns {Promise} + */ +async function handleTileRequest( + options, + repo, + req, + res, + next, + maxScaleFactor, + defailtTileSize, +) { + const { + id, + p1: tileSize, + p2: zParam, + p3: xParam, + p4: yParam, + scale: scaleParam, + format, + } = req.params; + const item = repo[id]; + if (!item) { + return res.sendStatus(404); + } -export const serve_rendered = { - init: async (options, repo) => { - maxScaleFactor = Math.min(Math.floor(options.maxScaleFactor || 3), 9); - let scalePattern = ''; - for (let i = 2; i <= maxScaleFactor; i++) { - scalePattern += i.toFixed(); + const modifiedSince = req.get('if-modified-since'); + const cc = req.get('cache-control'); + if (modifiedSince && (!cc || cc.indexOf('no-cache') === -1)) { + if ( + new Date(item.lastModified).getTime() === + new Date(modifiedSince).getTime() + ) { + return res.sendStatus(304); } - scalePattern = `@[${scalePattern}]x`; - - const app = express().disable('x-powered-by'); + } + const z = parseFloat(zParam) | 0; + const x = parseFloat(xParam) | 0; + const y = parseFloat(yParam) | 0; + const scale = allowedScales(scaleParam, maxScaleFactor); - app.get( - `/:id/(:tileSize(256|512)/)?:z(\\d+)/:x(\\d+)/:y(\\d+):scale(${scalePattern})?.:format([\\w]+)`, - (req, res, next) => { - const item = repo[req.params.id]; - if (!item) { - return res.sendStatus(404); - } + let parsedTileSize = parseInt(defailtTileSize, 10); + if (tileSize) { + parsedTileSize = parseInt(allowedTileSizes(tileSize), 10); - const modifiedSince = req.get('if-modified-since'); - const cc = req.get('cache-control'); - if (modifiedSince && (!cc || cc.indexOf('no-cache') === -1)) { - if (new Date(item.lastModified) <= new Date(modifiedSince)) { - return res.sendStatus(304); - } - } + if (parsedTileSize == null) { + return res.status(400).send('Invalid Tile Size'); + } + } - const z = req.params.z | 0; - const x = req.params.x | 0; - const y = req.params.y | 0; - const scale = getScale(req.params.scale); - const format = req.params.format; - const tileSize = parseInt(req.params.tileSize, 10) || 256; - - if ( - z < 0 || - x < 0 || - y < 0 || - z > 22 || - x >= Math.pow(2, z) || - y >= Math.pow(2, z) - ) { - return res.status(404).send('Out of bounds'); - } + if ( + scale == null || + z < 0 || + x < 0 || + y < 0 || + z > 22 || + x >= Math.pow(2, z) || + y >= Math.pow(2, z) + ) { + return res.status(400).send('Out of bounds'); + } - const tileCenter = mercator.ll( - [ - ((x + 0.5) / (1 << z)) * (256 << z), - ((y + 0.5) / (1 << z)) * (256 << z), - ], - z, - ); + const tileCenter = mercator.ll( + [((x + 0.5) / (1 << z)) * (256 << z), ((y + 0.5) / (1 << z)) * (256 << z)], + z, + ); - // prettier-ignore - return respondImage( - options, item, z, tileCenter[0], tileCenter[1], 0, 0, tileSize, tileSize, scale, format, res, - ); - }, - ); + // prettier-ignore + return await respondImage( + options, item, z, tileCenter[0], tileCenter[1], 0, 0, parsedTileSize, parsedTileSize, scale, format, res, + ); +} - if (options.serveStaticMaps !== false) { - const staticPattern = `/:id/static/:raw(raw)?/%s/:width(\\d+)x:height(\\d+):scale(${scalePattern})?.:format([\\w]+)`; +/** + * Handles requests for static map images. + * @param {object} options - Configuration options for the server. + * @param {object} repo - The repository object holding style data. + * @param {object} req - Express request object. + * @param {object} res - Express response object. + * @param {string} req.params.p2 - The raw or static parameter. + * @param {string} req.params.p3 - The staticType parameter. + * @param {string} req.params.p4 - The width parameter. + * @param {string} req.params.p5 - The height parameter. + * @param {string} req.params.scale - The scale parameter. + * @param {string} req.params.format - The format of the image. + * @param {Function} next - Express next middleware function. + * @param {number} maxScaleFactor - The maximum scale factor allowed. + * @param verbose + * @returns {Promise} + */ +async function handleStaticRequest( + options, + repo, + req, + res, + next, + maxScaleFactor, +) { + const { + id, + p2: raw, + p3: staticType, + p4: widthAndHeight, + scale: scaleParam, + format, + } = req.params; + const item = repo[id]; + + let parsedWidth = null; + let parsedHeight = null; + if (widthAndHeight) { + const sizeMatch = widthAndHeight.match(/^(\d+)x(\d+)$/); + if (sizeMatch) { + const width = parseInt(sizeMatch[1], 10); + const height = parseInt(sizeMatch[2], 10); + if ( + isNaN(width) || + isNaN(height) || + width !== parseFloat(sizeMatch[1]) || + height !== parseFloat(sizeMatch[2]) + ) { + return res + .status(400) + .send('Invalid width or height provided in size parameter'); + } + parsedWidth = width; + parsedHeight = height; + } else { + return res + .status(400) + .send('Invalid width or height provided in size parameter'); + } + } else { + return res + .status(400) + .send('Invalid width or height provided in size parameter'); + } - const centerPattern = util.format( - ':x(%s),:y(%s),:z(%s)(@:bearing(%s)(,:pitch(%s))?)?', - FLOAT_PATTERN, - FLOAT_PATTERN, - FLOAT_PATTERN, - FLOAT_PATTERN, - FLOAT_PATTERN, - ); + const scale = allowedScales(scaleParam, maxScaleFactor); + let isRaw = raw === 'raw'; - app.get( - util.format(staticPattern, centerPattern), - async (req, res, next) => { - try { - const item = repo[req.params.id]; - if (!item) { - return res.sendStatus(404); - } - const raw = req.params.raw; - const z = +req.params.z; - let x = +req.params.x; - let y = +req.params.y; - const bearing = +(req.params.bearing || '0'); - const pitch = +(req.params.pitch || '0'); - const w = req.params.width | 0; - const h = req.params.height | 0; - const scale = getScale(req.params.scale); - const format = req.params.format; - - if (z < 0) { - return res.status(404).send('Invalid zoom'); - } + const staticTypeMatch = staticType.match(staticTypeRegex); + if (!item || !format || !scale || !staticTypeMatch?.groups) { + return res.sendStatus(404); + } - const transformer = raw - ? mercator.inverse.bind(mercator) - : item.dataProjWGStoInternalWGS; + if (staticTypeMatch.groups.lon) { + // Center Based Static Image + const z = parseFloat(staticTypeMatch.groups.zoom) || 0; + let x = parseFloat(staticTypeMatch.groups.lon) || 0; + let y = parseFloat(staticTypeMatch.groups.lat) || 0; + const bearing = parseFloat(staticTypeMatch.groups.bearing) || 0; + const pitch = parseInt(staticTypeMatch.groups.pitch) || 0; + if (z < 0) { + return res.status(404).send('Invalid zoom'); + } - if (transformer) { - const ll = transformer([x, y]); - x = ll[0]; - y = ll[1]; - } + const transformer = isRaw + ? mercator.inverse.bind(mercator) + : item.dataProjWGStoInternalWGS; - const paths = extractPathsFromQuery(req.query, transformer); - const markers = extractMarkersFromQuery( - req.query, - options, - transformer, - ); + if (transformer) { + const ll = transformer([x, y]); + x = ll[0]; + y = ll[1]; + } - // prettier-ignore - const overlay = await renderOverlay( - z, x, y, bearing, pitch, w, h, scale, paths, markers, req.query, - ); + const paths = extractPathsFromQuery(req.query, transformer); + const markers = extractMarkersFromQuery(req.query, options, transformer); + // prettier-ignore + const overlay = await renderOverlay( + z, x, y, bearing, pitch, parsedWidth, parsedHeight, scale, paths, markers, req.query, + ); + + // prettier-ignore + return await respondImage( + options, item, z, x, y, bearing, pitch, parsedWidth, parsedHeight, scale, format, res, overlay, 'static', + ); + } else if (staticTypeMatch.groups.minx) { + // Area Based Static Image + const minx = parseFloat(staticTypeMatch.groups.minx) || 0; + const miny = parseFloat(staticTypeMatch.groups.miny) || 0; + const maxx = parseFloat(staticTypeMatch.groups.maxx) || 0; + const maxy = parseFloat(staticTypeMatch.groups.maxy) || 0; + const bbox = [minx, miny, maxx, maxy]; + let center = [(bbox[0] + bbox[2]) / 2, (bbox[1] + bbox[3]) / 2]; + + const transformer = isRaw + ? mercator.inverse.bind(mercator) + : item.dataProjWGStoInternalWGS; + + if (transformer) { + const minCorner = transformer(bbox.slice(0, 2)); + const maxCorner = transformer(bbox.slice(2)); + bbox[0] = minCorner[0]; + bbox[1] = minCorner[1]; + bbox[2] = maxCorner[0]; + bbox[3] = maxCorner[1]; + center = transformer(center); + } - // prettier-ignore - return respondImage( - options, item, z, x, y, bearing, pitch, w, h, scale, format, res, overlay, 'static', - ); - } catch (e) { - next(e); - } - }, + const z = calcZForBBox(bbox, parsedWidth, parsedHeight, req.query); + const x = center[0]; + const y = center[1]; + const bearing = 0; + const pitch = 0; + + const paths = extractPathsFromQuery(req.query, transformer); + const markers = extractMarkersFromQuery(req.query, options, transformer); + // prettier-ignore + const overlay = await renderOverlay( + z, x, y, bearing, pitch, parsedWidth, parsedHeight, scale, paths, markers, req.query, ); - const serveBounds = async (req, res, next) => { - try { - const item = repo[req.params.id]; - if (!item) { - return res.sendStatus(404); - } - const raw = req.params.raw; - const bbox = [ - +req.params.minx, - +req.params.miny, - +req.params.maxx, - +req.params.maxy, - ]; - let center = [(bbox[0] + bbox[2]) / 2, (bbox[1] + bbox[3]) / 2]; - - const transformer = raw - ? mercator.inverse.bind(mercator) - : item.dataProjWGStoInternalWGS; - - if (transformer) { - const minCorner = transformer(bbox.slice(0, 2)); - const maxCorner = transformer(bbox.slice(2)); - bbox[0] = minCorner[0]; - bbox[1] = minCorner[1]; - bbox[2] = maxCorner[0]; - bbox[3] = maxCorner[1]; - center = transformer(center); - } - - const w = req.params.width | 0; - const h = req.params.height | 0; - const scale = getScale(req.params.scale); - const format = req.params.format; + // prettier-ignore + return await respondImage( + options, item, z, x, y, bearing, pitch, parsedWidth, parsedHeight, scale, format, res, overlay, 'static', + ); + } else if (staticTypeMatch.groups.auto) { + // Area Static Image + const bearing = 0; + const pitch = 0; + + const transformer = isRaw + ? mercator.inverse.bind(mercator) + : item.dataProjWGStoInternalWGS; + + const paths = extractPathsFromQuery(req.query, transformer); + const markers = extractMarkersFromQuery(req.query, options, transformer); + + // Extract coordinates from markers + const markerCoordinates = []; + for (const marker of markers) { + markerCoordinates.push(marker.location); + } - const z = calcZForBBox(bbox, w, h, req.query); - const x = center[0]; - const y = center[1]; - const bearing = 0; - const pitch = 0; + // Create array with coordinates from markers and path + const coords = [].concat(paths.flat()).concat(markerCoordinates); - const paths = extractPathsFromQuery(req.query, transformer); - const markers = extractMarkersFromQuery( - req.query, - options, - transformer, - ); - - // prettier-ignore - const overlay = await renderOverlay( - z, x, y, bearing, pitch, w, h, scale, paths, markers, req.query, - ); + // Check if we have at least one coordinate to calculate a bounding box + if (coords.length < 1) { + return res.status(400).send('No coordinates provided'); + } - // prettier-ignore - return respondImage( - options, item, z, x, y, bearing, pitch, w, h, scale, format, res, overlay, 'static', - ); - } catch (e) { - next(e); - } - }; + const bbox = [Infinity, Infinity, -Infinity, -Infinity]; + for (const pair of coords) { + bbox[0] = Math.min(bbox[0], pair[0]); + bbox[1] = Math.min(bbox[1], pair[1]); + bbox[2] = Math.max(bbox[2], pair[0]); + bbox[3] = Math.max(bbox[3], pair[1]); + } - const boundsPattern = util.format( - ':minx(%s),:miny(%s),:maxx(%s),:maxy(%s)', - FLOAT_PATTERN, - FLOAT_PATTERN, - FLOAT_PATTERN, - FLOAT_PATTERN, - ); + const bbox_ = mercator.convert(bbox, '900913'); + const center = mercator.inverse([ + (bbox_[0] + bbox_[2]) / 2, + (bbox_[1] + bbox_[3]) / 2, + ]); + + // Calculate zoom level + const maxZoom = parseFloat(req.query.maxzoom); + let z = calcZForBBox(bbox, parsedWidth, parsedHeight, req.query); + if (maxZoom > 0) { + z = Math.min(z, maxZoom); + } - app.get(util.format(staticPattern, boundsPattern), serveBounds); + const x = center[0]; + const y = center[1]; - app.get('/:id/static/', (req, res, next) => { - for (const key in req.query) { - req.query[key.toLowerCase()] = req.query[key]; - } - req.params.raw = true; - req.params.format = (req.query.format || 'image/png').split('/').pop(); - const bbox = (req.query.bbox || '').split(','); - req.params.minx = bbox[0]; - req.params.miny = bbox[1]; - req.params.maxx = bbox[2]; - req.params.maxy = bbox[3]; - req.params.width = req.query.width || '256'; - req.params.height = req.query.height || '256'; - if (req.query.scale) { - req.params.width /= req.query.scale; - req.params.height /= req.query.scale; - req.params.scale = `@${req.query.scale}`; - } + // prettier-ignore + const overlay = await renderOverlay( + z, x, y, bearing, pitch, parsedWidth, parsedHeight, scale, paths, markers, req.query, + ); - return serveBounds(req, res, next); - }); + // prettier-ignore + return await respondImage( + options, item, z, x, y, bearing, pitch, parsedWidth, parsedHeight, scale, format, res, overlay, 'static', + ); + } else { + return res.sendStatus(404); + } +} +const existingFonts = {}; +let maxScaleFactor = 2; - const autoPattern = 'auto'; +export const serve_rendered = { + /** + * Initializes the serve_rendered module. + * @param {object} options Configuration options. + * @param {object} repo Repository object. + * @param {object} programOpts - An object containing the program options. + * @returns {Promise} A promise that resolves to the Express app. + */ + init: async function (options, repo, programOpts) { + const { verbose, tileSize: defailtTileSize = 256 } = programOpts; + maxScaleFactor = Math.min(Math.floor(options.maxScaleFactor || 3), 9); + const app = express().disable('x-powered-by'); - app.get( - util.format(staticPattern, autoPattern), - async (req, res, next) => { - try { - const item = repo[req.params.id]; - if (!item) { - return res.sendStatus(404); - } - const raw = req.params.raw; - const w = req.params.width | 0; - const h = req.params.height | 0; - const bearing = 0; - const pitch = 0; - const scale = getScale(req.params.scale); - const format = req.params.format; - - const transformer = raw - ? mercator.inverse.bind(mercator) - : item.dataProjWGStoInternalWGS; - - const paths = extractPathsFromQuery(req.query, transformer); - const markers = extractMarkersFromQuery( - req.query, - options, - transformer, + /** + * Handles requests for tile images. + * @param {object} req - Express request object. + * @param {object} res - Express response object. + * @param {string} req.params.id - The id of the style. + * @param {string} [req.params.p1] - The tile size or static parameter, if available. + * @param {string} req.params.p2 - The z, static, or raw parameter. + * @param {string} req.params.p3 - The x or staticType parameter. + * @param {string} req.params.p4 - The y or width parameter. + * @param {string} req.params.scale - The scale parameter. + * @param {string} req.params.format - The format of the image. + * @returns {Promise} + */ + app.get( + `/:id{/:p1}/:p2/:p3/:p4{@:scale}{.:format}`, + async (req, res, next) => { + try { + const { p1, p2, id, p3, p4, scale, format } = req.params; + const requestType = + (!p1 && p2 === 'static') || (p1 === 'static' && p2 === 'raw') + ? 'static' + : 'tile'; + if (verbose) { + console.log( + `Handling rendered %s request for: /styles/%s%s/%s/%s/%s%s.%s`, + requestType, + String(id).replace(/\n|\r/g, ''), + p1 ? '/' + String(p1).replace(/\n|\r/g, '') : '', + String(p2).replace(/\n|\r/g, ''), + String(p3).replace(/\n|\r/g, ''), + String(p4).replace(/\n|\r/g, ''), + scale ? '@' + String(scale).replace(/\n|\r/g, '') : '', + String(format).replace(/\n|\r/g, ''), ); + } - // Extract coordinates from markers - const markerCoordinates = []; - for (const marker of markers) { - markerCoordinates.push(marker.location); - } - - // Create array with coordinates from markers and path - const coords = [].concat(paths.flat()).concat(markerCoordinates); - - // Check if we have at least one coordinate to calculate a bounding box - if (coords.length < 1) { - return res.status(400).send('No coordinates provided'); - } - - const bbox = [Infinity, Infinity, -Infinity, -Infinity]; - for (const pair of coords) { - bbox[0] = Math.min(bbox[0], pair[0]); - bbox[1] = Math.min(bbox[1], pair[1]); - bbox[2] = Math.max(bbox[2], pair[0]); - bbox[3] = Math.max(bbox[3], pair[1]); - } - - const bbox_ = mercator.convert(bbox, '900913'); - const center = mercator.inverse([ - (bbox_[0] + bbox_[2]) / 2, - (bbox_[1] + bbox_[3]) / 2, - ]); - - // Calculate zoom level - const maxZoom = parseFloat(req.query.maxzoom); - let z = calcZForBBox(bbox, w, h, req.query); - if (maxZoom > 0) { - z = Math.min(z, maxZoom); + if (requestType === 'static') { + // Route to static if p2 is static + if (options.serveStaticMaps !== false) { + return handleStaticRequest( + options, + repo, + req, + res, + next, + maxScaleFactor, + ); } - - const x = center[0]; - const y = center[1]; - - // prettier-ignore - const overlay = await renderOverlay( - z, x, y, bearing, pitch, w, h, scale, paths, markers, req.query, - ); - - // prettier-ignore - return respondImage( - options, item, z, x, y, bearing, pitch, w, h, scale, format, res, overlay, 'static', - ); - } catch (e) { - next(e); + return res.sendStatus(404); } - }, - ); - } - app.get('/(:tileSize(256|512)/)?:id.json', (req, res, next) => { + return handleTileRequest( + options, + repo, + req, + res, + next, + maxScaleFactor, + defailtTileSize, + ); + } catch (e) { + console.log(e); + return next(e); + } + }, + ); + + /** + * Handles requests for rendered tilejson endpoint. + * @param {object} req - Express request object. + * @param {object} res - Express response object. + * @param {string} req.params.id - The id of the tilejson + * @param {string} [req.params.tileSize] - The size of the tile, if specified. + * @returns {void} + */ + app.get('{/:tileSize}/:id.json', (req, res, next) => { const item = repo[req.params.id]; if (!item) { return res.sendStatus(404); } const tileSize = parseInt(req.params.tileSize, 10) || undefined; + if (verbose) { + console.log( + `Handling rendered tilejson request for: /styles/%s%s.json`, + req.params.tileSize + ? String(req.params.tileSize).replace(/\n|\r/g, '') + '/' + : '', + String(req.params.id).replace(/\n|\r/g, ''), + ); + } const info = clone(item.tileJSON); info.tiles = getTileUrls( req, @@ -877,7 +1021,17 @@ export const serve_rendered = { Object.assign(existingFonts, fonts); return app; }, - add: async (options, repo, params, id, publicUrl, dataResolver) => { + /** + * Adds a new item to the repository. + * @param {object} options Configuration options. + * @param {object} repo Repository object. + * @param {object} params Parameters object. + * @param {string} id ID of the item. + * @param {object} programOpts - An object containing the program options + * @param {Function} dataResolver Function to resolve data. + * @returns {Promise} + */ + add: async function (options, repo, params, id, programOpts, dataResolver) { const map = { renderers: [], renderersStatic: [], @@ -885,23 +1039,45 @@ export const serve_rendered = { sourceTypes: {}, }; + const { publicUrl, verbose } = programOpts; + let styleJSON; + /** + * Creates a pool of renderers. + * @param {number} ratio Pixel ratio + * @param {string} mode Rendering mode ('tile' or 'static'). + * @param {number} min Minimum pool size. + * @param {number} max Maximum pool size. + * @returns {object} The created pool + */ const createPool = (ratio, mode, min, max) => { + /** + * Creates a renderer + * @param {number} ratio Pixel ratio + * @param {Function} createCallback Function that returns the renderer when created + * @returns {void} + */ const createRenderer = (ratio, createCallback) => { const renderer = new mlgl.Map({ mode, ratio, request: async (req, callback) => { const protocol = req.url.split(':')[0]; - // console.log('Handling request:', req); + if (verbose) { + console.log('Handling request:', req); + } if (protocol === 'sprites') { const dir = options.paths[protocol]; const file = decodeURIComponent(req.url).substring( protocol.length + 3, ); - fs.readFile(path.join(dir, file), (err, data) => { - callback(err, { data: data }); - }); + readFile(path.join(dir, file)) + .then((data) => { + callback(null, { data: data }); + }) + .catch((err) => { + callback(err, null); + }); } else if (protocol === 'fonts') { const parts = req.url.split('/'); const fontstack = decodeURIComponent(parts[2]); @@ -931,88 +1107,57 @@ export const serve_rendered = { const y = parts[5].split('.')[0] | 0; const format = parts[5].split('.')[1]; - if (sourceType === 'pmtiles') { - let tileinfo = await getPMtilesTile(source, z, x, y); - let data = tileinfo.data; - let headers = tileinfo.header; - if (data == undefined) { - if (options.verbose) - console.log('MBTiles error, serving empty', err); - createEmptyResponse( - sourceInfo.format, - sourceInfo.color, - callback, + const fetchTile = await fetchTileData( + source, + sourceType, + z, + x, + y, + ); + if (fetchTile == null) { + if (verbose) { + console.log( + 'fetchTile error on %s, serving empty response', + req.url, ); - return; - } else { - const response = {}; - response.data = data; - if (headers['Last-Modified']) { - response.modified = new Date(headers['Last-Modified']); - } - - if (format === 'pbf') { - if (options.dataDecoratorFunc) { - response.data = options.dataDecoratorFunc( - sourceId, - 'data', - response.data, - z, - x, - y, - ); - } - } - - callback(null, response); } - } else if (sourceType === 'mbtiles') { - source.getTile(z, x, y, async (err, data, headers) => { - if (err) { - if (options.verbose) - console.log('MBTiles error, serving empty', err); - createEmptyResponse( - sourceInfo.format, - sourceInfo.color, - callback, - ); - return; - } - - const response = {}; - if (headers['Last-Modified']) { - response.modified = new Date(headers['Last-Modified']); - } - - if (format === 'pbf') { - try { - response.data = await gunzipP(data); - } catch (err) { - console.log( - 'Skipping incorrect header for tile mbtiles://%s/%s/%s/%s.pbf', - id, - z, - x, - y, - ); - } - if (options.dataDecoratorFunc) { - response.data = options.dataDecoratorFunc( - sourceId, - 'data', - response.data, - z, - x, - y, - ); - } - } else { - response.data = data; - } - - callback(null, response); - }); + createEmptyResponse( + sourceInfo.format, + sourceInfo.color, + callback, + ); + return; } + + const response = {}; + response.data = fetchTile.data; + let headers = fetchTile.headers; + + if (headers['Last-Modified']) { + response.modified = new Date(headers['Last-Modified']); + } + + if (format === 'pbf') { + let isGzipped = + response.data + .slice(0, 2) + .indexOf(Buffer.from([0x1f, 0x8b])) === 0; + if (isGzipped) { + response.data = await gunzipP(response.data); + } + if (options.dataDecoratorFunc) { + response.data = options.dataDecoratorFunc( + sourceId, + 'data', + response.data, + z, + x, + y, + ); + } + } + + callback(null, response); } else if (protocol === 'http' || protocol === 'https') { try { const response = await axios.get(req.url, { @@ -1055,9 +1200,13 @@ export const serve_rendered = { ); } - fs.readFile(file, (err, data) => { - callback(err, { data: data }); - }); + readFile(file) + .then((data) => { + callback(null, { data: data }); + }) + .catch((err) => { + callback(err, null); + }); } else { throw Error( `File does not exist: "${req.url}" - resolved to "${file}"`, @@ -1291,7 +1440,13 @@ export const serve_rendered = { ); } }, - remove: (repo, id) => { + /** + * Removes an item from the repository. + * @param {object} repo Repository object. + * @param {string} id ID of the item to remove. + * @returns {void} + */ + remove: function (repo, id) { const item = repo[id]; if (item) { item.map.renderers.forEach((pool) => { diff --git a/src/serve_style.js b/src/serve_style.js index 5d3b4699f..51b03d3de 100644 --- a/src/serve_style.js +++ b/src/serve_style.js @@ -7,85 +7,209 @@ import clone from 'clone'; import express from 'express'; import { validateStyleMin } from '@maplibre/maplibre-gl-style-spec'; -import { fixUrl, allowedOptions } from './utils.js'; +import { + allowedSpriteScales, + allowedSpriteFormats, + fixUrl, + readFile, +} from './utils.js'; const httpTester = /^https?:\/\//i; -const allowedSpriteScales = allowedOptions(['', '@2x', '@3x']); -const allowedSpriteFormats = allowedOptions(['png', 'json']); export const serve_style = { - init: (options, repo) => { + /** + * Initializes the serve_style module. + * @param {object} options Configuration options. + * @param {object} repo Repository object. + * @param {object} programOpts - An object containing the program options. + * @returns {express.Application} The initialized Express application. + */ + init: function (options, repo, programOpts) { + const { verbose } = programOpts; const app = express().disable('x-powered-by'); - + /** + * Handles requests for style.json files. + * @param {express.Request} req - Express request object. + * @param {express.Response} res - Express response object. + * @param {express.NextFunction} next - Express next function. + * @param {string} req.params.id - ID of the style. + * @returns {Promise} + */ app.get('/:id/style.json', (req, res, next) => { - const item = repo[req.params.id]; - if (!item) { - return res.sendStatus(404); + const { id } = req.params; + if (verbose) { + console.log( + 'Handling style request for: /styles/%s/style.json', + String(id).replace(/\n|\r/g, ''), + ); } - const styleJSON_ = clone(item.styleJSON); - for (const name of Object.keys(styleJSON_.sources)) { - const source = styleJSON_.sources[name]; - source.url = fixUrl(req, source.url, item.publicUrl); - if (typeof source.data == 'string') { - source.data = fixUrl(req, source.data, item.publicUrl); + try { + const item = repo[id]; + if (!item) { + return res.sendStatus(404); } - } - // mapbox-gl-js viewer cannot handle sprite urls with query - if (styleJSON_.sprite) { - if (Array.isArray(styleJSON_.sprite)) { - styleJSON_.sprite.forEach((spriteItem) => { - spriteItem.url = fixUrl(req, spriteItem.url, item.publicUrl); - }); - } else { - styleJSON_.sprite = fixUrl(req, styleJSON_.sprite, item.publicUrl); + const styleJSON_ = clone(item.styleJSON); + for (const name of Object.keys(styleJSON_.sources)) { + const source = styleJSON_.sources[name]; + source.url = fixUrl(req, source.url, item.publicUrl); + if (typeof source.data == 'string') { + source.data = fixUrl(req, source.data, item.publicUrl); + } } + if (styleJSON_.sprite) { + if (Array.isArray(styleJSON_.sprite)) { + styleJSON_.sprite.forEach((spriteItem) => { + spriteItem.url = fixUrl(req, spriteItem.url, item.publicUrl); + }); + } else { + styleJSON_.sprite = fixUrl(req, styleJSON_.sprite, item.publicUrl); + } + } + if (styleJSON_.glyphs) { + styleJSON_.glyphs = fixUrl(req, styleJSON_.glyphs, item.publicUrl); + } + return res.send(styleJSON_); + } catch (e) { + next(e); } - if (styleJSON_.glyphs) { - styleJSON_.glyphs = fixUrl(req, styleJSON_.glyphs, item.publicUrl); - } - return res.send(styleJSON_); }); + /** + * Handles GET requests for sprite images and JSON files. + * @param {express.Request} req - Express request object. + * @param {express.Response} res - Express response object. + * @param {express.NextFunction} next - Express next function. + * @param {string} req.params.id - ID of the sprite. + * @param {string} [req.params.spriteID='default'] - ID of the specific sprite image, defaults to 'default'. + * @param {string} [req.params.scale] - Scale of the sprite image, defaults to ''. + * @param {string} req.params.format - Format of the sprite file, 'png' or 'json'. + * @returns {Promise} + */ app.get( - '/:id/sprite(/:spriteID)?:scale(@[23]x)?.:format([\\w]+)', - (req, res, next) => { - const { spriteID = 'default', id } = req.params; - const scale = allowedSpriteScales(req.params.scale) || ''; - const format = allowedSpriteFormats(req.params.format); - - if (format) { - const item = repo[id]; - const sprite = item.spritePaths.find( - (sprite) => sprite.id === spriteID, + `/:id/sprite{/:spriteID}{@:scale}{.:format}`, + async (req, res, next) => { + const { spriteID = 'default', id, format, scale } = req.params; + const sanitizedId = String(id).replace(/\n|\r/g, ''); + const sanitizedScale = scale ? String(scale).replace(/\n|\r/g, '') : ''; + const sanitizedSpriteID = String(spriteID).replace(/\n|\r/g, ''); + const sanitizedFormat = format + ? '.' + String(format).replace(/\n|\r/g, '') + : ''; + if (verbose) { + console.log( + `Handling sprite request for: /styles/%s/sprite/%s%s%s`, + sanitizedId, + sanitizedSpriteID, + sanitizedScale, + sanitizedFormat, ); - if (sprite) { - const filename = `${sprite.path + scale}.${format}`; - return fs.readFile(filename, (err, data) => { - if (err) { - console.log('Sprite load error:', filename); - return res.sendStatus(404); - } else { - if (format === 'json') - res.header('Content-type', 'application/json'); - if (format === 'png') res.header('Content-type', 'image/png'); - return res.send(data); - } - }); - } else { - return res.status(400).send('Bad Sprite ID or Scale'); + } + const item = repo[id]; + const validatedFormat = allowedSpriteFormats(format); + if (!item || !validatedFormat) { + if (verbose) + console.error( + `Sprite item or format not found for: /styles/%s/sprite/%s%s%s`, + sanitizedId, + sanitizedSpriteID, + sanitizedScale, + sanitizedFormat, + ); + return res.sendStatus(404); + } + const sprite = item.spritePaths.find( + (sprite) => sprite.id === spriteID, + ); + const spriteScale = allowedSpriteScales(scale); + if (!sprite || spriteScale === null) { + if (verbose) + console.error( + `Bad Sprite ID or Scale for: /styles/%s/sprite/%s%s%s`, + sanitizedId, + sanitizedSpriteID, + sanitizedScale, + sanitizedFormat, + ); + return res.status(400).send('Bad Sprite ID or Scale'); + } + + const modifiedSince = req.get('if-modified-since'); + const cc = req.get('cache-control'); + if (modifiedSince && (!cc || cc.indexOf('no-cache') === -1)) { + if ( + new Date(item.lastModified).getTime() === + new Date(modifiedSince).getTime() + ) { + return res.sendStatus(304); } - } else { - return res.status(400).send('Bad Sprite Format'); + } + + const sanitizedSpritePath = sprite.path.replace(/^(\.\.\/)+/, ''); + const filename = `${sanitizedSpritePath}${spriteScale}.${validatedFormat}`; + if (verbose) console.log(`Loading sprite from: %s`, filename); + try { + const data = await readFile(filename); + + if (validatedFormat === 'json') { + res.header('Content-type', 'application/json'); + } else if (validatedFormat === 'png') { + res.header('Content-type', 'image/png'); + } + if (verbose) + console.log( + `Responding with sprite data for /styles/%s/sprite/%s%s%s`, + sanitizedId, + sanitizedSpriteID, + sanitizedScale, + sanitizedFormat, + ); + res.set({ 'Last-Modified': item.lastModified }); + return res.send(data); + } catch (err) { + if (verbose) { + console.error( + 'Sprite load error: %s, Error: %s', + filename, + String(err), + ); + } + return res.sendStatus(404); } }, ); return app; }, - remove: (repo, id) => { + /** + * Removes an item from the repository. + * @param {object} repo Repository object. + * @param {string} id ID of the item to remove. + * @returns {void} + */ + remove: function (repo, id) { delete repo[id]; }, - add: (options, repo, params, id, publicUrl, reportTiles, reportFont) => { + /** + * Adds a new style to the repository. + * @param {object} options Configuration options. + * @param {object} repo Repository object. + * @param {object} params Parameters object containing style path + * @param {string} id ID of the style. + * @param {object} programOpts - An object containing the program options + * @param {Function} reportTiles Function for reporting tile sources. + * @param {Function} reportFont Function for reporting font usage + * @returns {boolean} true if add is succesful + */ + add: function ( + options, + repo, + params, + id, + programOpts, + reportTiles, + reportFont, + ) { + const { publicUrl } = programOpts; const styleFile = path.resolve(options.paths.styles, params.style); let styleFileData; @@ -199,6 +323,7 @@ export const serve_style = { spritePaths, publicUrl, name: styleJSON.name, + lastModified: new Date().toUTCString(), }; return true; diff --git a/src/server.js b/src/server.js index 39808e318..682e07e9e 100644 --- a/src/server.js +++ b/src/server.js @@ -1,9 +1,6 @@ #!/usr/bin/env node 'use strict'; -import os from 'os'; -process.env.UV_THREADPOOL_SIZE = Math.ceil(Math.max(4, os.cpus().length * 1.5)); - import fs from 'node:fs'; import path from 'path'; import fnv1a from '@sindresorhus/fnv1a'; @@ -19,7 +16,12 @@ import morgan from 'morgan'; import { serve_data } from './serve_data.js'; import { serve_style } from './serve_style.js'; import { serve_font } from './serve_font.js'; -import { getTileUrls, getPublicUrl, isValidHttpUrl } from './utils.js'; +import { + allowedTileSizes, + getTileUrls, + getPublicUrl, + isValidHttpUrl, +} from './utils.js'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); @@ -34,10 +36,11 @@ const serve_rendered = ( ).serve_rendered; /** - * - * @param opts + * Starts the server. + * @param {object} opts - Configuration options for the server. + * @returns {Promise} - A promise that resolves to the server object. */ -function start(opts) { +async function start(opts) { console.log('Starting server'); const app = express().disable('x-powered-by'); @@ -73,7 +76,7 @@ function start(opts) { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch (e) { console.log('ERROR: Config file not found or invalid!'); - console.log(' See README.md for instructions and sample data.'); + console.log(' See README.md for instructions and sample data.'); process.exit(1); } } @@ -116,8 +119,9 @@ function start(opts) { * Recursively get all files within a directory. * Inspired by https://stackoverflow.com/a/45130990/10133863 * @param {string} directory Absolute path to a directory to get files from. + * @returns {Promise} - A promise that resolves to an array of file paths relative to the icon directory. */ - const getFiles = async (directory) => { + async function getFiles(directory) { // Fetch all entries of the directory and attach type information const dirEntries = await fs.promises.readdir(directory, { withFileTypes: true, @@ -136,7 +140,7 @@ function start(opts) { // Flatten the list of files to a single array return files.flat(); - }; + } // Load all available icons into a settings object startupPromises.push( @@ -159,18 +163,25 @@ function start(opts) { app.use(cors()); } - app.use('/data/', serve_data.init(options, serving.data)); + app.use('/data/', serve_data.init(options, serving.data, opts)); app.use('/files/', express.static(paths.files)); - app.use('/styles/', serve_style.init(options, serving.styles)); + app.use('/styles/', serve_style.init(options, serving.styles, opts)); if (!isLight) { startupPromises.push( - serve_rendered.init(options, serving.rendered).then((sub) => { + serve_rendered.init(options, serving.rendered, opts).then((sub) => { app.use('/styles/', sub); }), ); } - - const addStyle = (id, item, allowMoreData, reportFonts) => { + /** + * Adds a style to the server. + * @param {string} id - The ID of the style. + * @param {object} item - The style configuration object. + * @param {boolean} allowMoreData - Whether to allow adding more data sources. + * @param {boolean} reportFonts - Whether to report fonts. + * @returns {void} + */ + function addStyle(id, item, allowMoreData, reportFonts) { let success = true; if (item.serve_data !== false) { success = serve_style.add( @@ -178,7 +189,7 @@ function start(opts) { serving.styles, item, id, - opts.publicUrl, + opts, (styleSourceId, protocol) => { let dataItemId; for (const id of Object.keys(data)) { @@ -235,7 +246,7 @@ function start(opts) { serving.rendered, item, id, - opts.publicUrl, + opts, function dataResolver(styleSourceId) { let fileType; let inputFile; @@ -261,7 +272,7 @@ function start(opts) { item.serve_rendered = false; } } - }; + } for (const id of Object.keys(config.styles || {})) { const item = config.styles[id]; @@ -272,13 +283,11 @@ function start(opts) { addStyle(id, item, true, true); } - startupPromises.push( - serve_font(options, serving.fonts).then((sub) => { + serve_font(options, serving.fonts, opts).then((sub) => { app.use('/', sub); }), ); - for (const id of Object.keys(data)) { const item = data[id]; const fileType = Object.keys(data[id])[0]; @@ -288,12 +297,8 @@ function start(opts) { ); continue; } - - startupPromises.push( - serve_data.add(options, serving.data, item, id, opts.publicUrl), - ); + startupPromises.push(serve_data.add(options, serving.data, item, id, opts)); } - if (options.serveAllStyles) { fs.readdir(options.paths.styles, { withFileTypes: true }, (err, files) => { if (err) { @@ -333,7 +338,13 @@ function start(opts) { } }); } - + /** + * Handles requests for a list of available styles. + * @param {object} req - Express request object. + * @param {object} res - Express response object. + * @param {string} [req.query.key] - Optional API key. + * @returns {void} + */ app.get('/styles.json', (req, res, next) => { const result = []; const query = req.query.key @@ -354,7 +365,15 @@ function start(opts) { res.send(result); }); - const addTileJSONs = (arr, req, type, tileSize) => { + /** + * Adds TileJSON metadata to an array. + * @param {Array} arr - The array to add TileJSONs to + * @param {object} req - The express request object. + * @param {string} type - The type of resource + * @param {number} tileSize - The tile size. + * @returns {Array} - An array of TileJSON objects. + */ + function addTileJSONs(arr, req, type, tileSize) { for (const id of Object.keys(serving[type])) { const info = clone(serving[type][id].tileJSON); let path = ''; @@ -377,20 +396,42 @@ function start(opts) { arr.push(info); } return arr; - }; + } - app.get('/(:tileSize(256|512)/)?rendered.json', (req, res, next) => { - const tileSize = parseInt(req.params.tileSize, 10) || undefined; - res.send(addTileJSONs([], req, 'rendered', tileSize)); + /** + * Handles requests for a rendered tilejson endpoint. + * @param {object} req - Express request object. + * @param {object} res - Express response object. + * @param {string} req.params.tileSize - Optional tile size parameter. + * @returns {void} + */ + app.get('{/:tileSize}/rendered.json', (req, res, next) => { + const tileSize = allowedTileSizes(req.params['tileSize']); + res.send(addTileJSONs([], req, 'rendered', parseInt(tileSize, 10))); }); - app.get('/data.json', (req, res, next) => { + + /** + * Handles requests for a data tilejson endpoint. + * @param {object} req - Express request object. + * @param {object} res - Express response object. + * @returns {void} + */ + app.get('/data.json', (req, res) => { res.send(addTileJSONs([], req, 'data', undefined)); }); - app.get('/(:tileSize(256|512)/)?index.json', (req, res, next) => { - const tileSize = parseInt(req.params.tileSize, 10) || undefined; + + /** + * Handles requests for a combined rendered and data tilejson endpoint. + * @param {object} req - Express request object. + * @param {object} res - Express response object. + * @param {string} req.params.tileSize - Optional tile size parameter. + * @returns {void} + */ + app.get('{/:tileSize}/index.json', (req, res, next) => { + const tileSize = allowedTileSizes(req.params['tileSize']); res.send( addTileJSONs( - addTileJSONs([], req, 'rendered', tileSize), + addTileJSONs([], req, 'rendered', parseInt(tileSize, 10)), req, 'data', undefined, @@ -403,7 +444,15 @@ function start(opts) { app.use('/', express.static(path.join(__dirname, '../public/resources'))); const templates = path.join(__dirname, '../public/templates'); - const serveTemplate = (urlPath, template, dataGetter) => { + + /** + * Serves a Handlebars template. + * @param {string} urlPath - The URL path to serve the template at + * @param {string} template - The name of the template file + * @param {Function} dataGetter - A function to get data to be passed to the template. + * @returns {void} + */ + function serveTemplate(urlPath, template, dataGetter) { let templateFile = `${templates}/${template}.tmpl`; if (template === 'index') { if (options.frontPage === false) { @@ -415,24 +464,17 @@ function start(opts) { templateFile = path.resolve(paths.root, options.frontPage); } } - startupPromises.push( - new Promise((resolve, reject) => { - fs.readFile(templateFile, (err, content) => { - if (err) { - err = new Error(`Template not found: ${err.message}`); - reject(err); - return; - } - const compiled = handlebars.compile(content.toString()); - - app.use(urlPath, (req, res, next) => { - let data = {}; - if (dataGetter) { - data = dataGetter(req); - if (!data) { - return res.status(404).send('Not found'); - } - } + try { + const content = fs.readFileSync(templateFile, 'utf-8'); + const compiled = handlebars.compile(content.toString()); + app.get(urlPath, (req, res, next) => { + if (opts.verbose) { + console.log(`Serving template at path: ${urlPath}`); + } + let data = {}; + if (dataGetter) { + data = dataGetter(req); + if (data) { data['server_version'] = `${packageJson.name} v${packageJson.version}`; data['public_url'] = opts.publicUrl || '/'; @@ -445,14 +487,27 @@ function start(opts) { : ''; if (template === 'wmts') res.set('Content-Type', 'text/xml'); return res.status(200).send(compiled(data)); - }); - resolve(); - }); - }), - ); - }; + } else { + if (opts.verbose) { + console.log(`Forwarding request for: ${urlPath} to next route`); + } + next('route'); + } + } + }); + } catch (err) { + console.error(`Error reading template file: ${templateFile}`, err); + throw new Error(`Template not found: ${err.message}`); //throw an error so that the server doesnt start + } + } - serveTemplate('/$', 'index', (req) => { + /** + * Handles requests for the index page, providing a list of available styles and data. + * @param {object} req - Express request object. + * @param {object} res - Express response object. + * @returns {void} + */ + serveTemplate('/', 'index', (req) => { let styles = {}; for (const id of Object.keys(serving.styles || {})) { let style = { @@ -464,11 +519,15 @@ function start(opts) { if (style.serving_rendered) { const { center } = style.serving_rendered.tileJSON; if (center) { - style.viewer_hash = `#${center[2]}/${center[1].toFixed(5)}/${center[0].toFixed(5)}`; + style.viewer_hash = `#${center[2]}/${center[1].toFixed( + 5, + )}/${center[0].toFixed(5)}`; const centerPx = mercator.px([center[0], center[1]], center[2]); // Set thumbnail default size to be 256px x 256px - style.thumbnail = `${Math.floor(center[2])}/${Math.floor(centerPx[0] / 256)}/${Math.floor(centerPx[1] / 256)}.png`; + style.thumbnail = `${Math.floor(center[2])}/${Math.floor( + centerPx[0] / 256, + )}/${Math.floor(centerPx[1] / 256)}.png`; } const tileSize = 512; @@ -484,7 +543,6 @@ function start(opts) { styles[id] = style; } - let datas = {}; for (const id of Object.keys(serving.data || {})) { let data = Object.assign({}, serving.data[id]); @@ -525,7 +583,9 @@ function start(opts) { } if (center) { const centerPx = mercator.px([center[0], center[1]], center[2]); - data.thumbnail = `${Math.floor(center[2])}/${Math.floor(centerPx[0] / 256)}/${Math.floor(centerPx[1] / 256)}.${tileJSON.format}`; + data.thumbnail = `${Math.floor(center[2])}/${Math.floor( + centerPx[0] / 256, + )}/${Math.floor(centerPx[1] / 256)}.${tileJSON.format}`; } } @@ -542,24 +602,28 @@ function start(opts) { } data.formatted_filesize = `${size.toFixed(2)} ${suffix}`; } - datas[id] = data; } - return { styles: Object.keys(styles).length ? styles : null, data: Object.keys(datas).length ? datas : null, }; }); - serveTemplate('/styles/:id/$', 'viewer', (req) => { + /** + * Handles requests for a map viewer template for a specific style. + * @param {object} req - Express request object. + * @param {object} res - Express response object. + * @param {string} req.params.id - ID of the style. + * @returns {void} + */ + serveTemplate('/styles/:id/', 'viewer', (req) => { const { id } = req.params; const style = clone(((serving.styles || {})[id] || {}).styleJSON); if (!style) { return null; } - return { ...style, id, @@ -569,11 +633,13 @@ function start(opts) { }; }); - /* - app.use('/rendered/:id/$', function(req, res, next) { - return res.redirect(301, '/styles/' + req.params.id + '/'); - }); - */ + /** + * Handles requests for a Web Map Tile Service (WMTS) XML template. + * @param {object} req - Express request object. + * @param {object} res - Express response object. + * @param {string} req.params.id - ID of the style. + * @returns {void} + */ serveTemplate('/styles/:id/wmts.xml', 'wmts', (req) => { const { id } = req.params; const wmts = clone((serving.styles || {})[id]); @@ -605,9 +671,16 @@ function start(opts) { }; }); - serveTemplate('^/data/(:preview(preview)/)?:id/$', 'data', (req) => { - const id = req.params.id; - const preview = req.params.preview || undefined; + /** + * Handles requests for a data view template for a specific data source. + * @param {object} req - Express request object. + * @param {object} res - Express response object. + * @param {string} req.params.id - ID of the data source. + * @param {string} [req.params.view] - Optional view type. + * @returns {void} + */ + serveTemplate('/data{/:view}/:id/', 'data', (req) => { + const { id, view } = req.params; const data = serving.data[id]; if (!data) { @@ -616,7 +689,8 @@ function start(opts) { const is_terrain = (data.tileJSON.encoding === 'terrarium' || data.tileJSON.encoding === 'mapbox') && - preview === 'preview'; + view === 'preview'; + return { ...data, id, @@ -633,7 +707,13 @@ function start(opts) { startupComplete = true; }); - app.get('/health', (req, res, next) => { + /** + * Handles requests to see the health of the server. + * @param {object} req - Express request object. + * @param {object} res - Express response object. + * @returns {void} + */ + app.get('/health', (req, res) => { if (startupComplete) { return res.status(200).send('OK'); } else { @@ -662,10 +742,10 @@ function start(opts) { startupPromise, }; } - /** * Stop the server gracefully * @param {string} signal Name of the received signal + * @returns {void} */ function stopGracefully(signal) { console.log(`Caught signal ${signal}, stopping gracefully`); @@ -673,11 +753,12 @@ function stopGracefully(signal) { } /** - * - * @param opts + * Starts and manages the server + * @param {object} opts - Configuration options for the server. + * @returns {Promise} - A promise that resolves to the running server */ -export function server(opts) { - const running = start(opts); +export async function server(opts) { + const running = await start(opts); running.startupPromise.catch((err) => { console.error(err.message); @@ -697,6 +778,5 @@ export function server(opts) { running.app = restarted.app; }); }); - return running; } diff --git a/src/utils.js b/src/utils.js index 85dad1a1a..5dab80a2a 100644 --- a/src/utils.js +++ b/src/utils.js @@ -6,12 +6,18 @@ import fs from 'node:fs'; import clone from 'clone'; import { combine } from '@jsse/pbfont'; import { existsP } from './promises.js'; +import { getPMtilesTile } from './pmtiles_adapter.js'; + +export const allowedSpriteFormats = allowedOptions(['png', 'json']); + +export const allowedTileSizes = allowedOptions(['256', '512']); /** * Restrict user input to an allowed set of options. - * @param opts - * @param root0 - * @param root0.defaultValue + * @param {string[]} opts - An array of allowed option strings. + * @param {object} [config] - Optional configuration object. + * @param {string} [config.defaultValue] - The default value to return if input doesn't match. + * @returns {function(string): string} - A function that takes a value and returns it if valid or a default. */ export function allowedOptions(opts, { defaultValue } = {}) { const values = Object.fromEntries(opts.map((key) => [key, key])); @@ -19,10 +25,52 @@ export function allowedOptions(opts, { defaultValue } = {}) { } /** - * Replace local:// urls with public http(s):// urls - * @param req - * @param url - * @param publicUrl + * Parses a scale string to a number. + * @param {string} scale The scale string (e.g., '2x', '4x'). + * @param {number} maxScale Maximum allowed scale digit. + * @returns {number|null} The parsed scale as a number or null if invalid. + */ +export function allowedScales(scale, maxScale = 9) { + if (scale === undefined) { + return 1; + } + + // eslint-disable-next-line security/detect-non-literal-regexp + const regex = new RegExp(`^[2-${maxScale}]x$`); + if (!regex.test(scale)) { + return null; + } + + return parseInt(scale.slice(0, -1), 10); +} + +/** + * Checks if a string is a valid sprite scale and returns it if it is within the allowed range, and null if it does not conform. + * @param {string} scale - The scale string to validate (e.g., '2x', '3x'). + * @param {number} [maxScale] - The maximum scale value. If no value is passed in, it defaults to a value of 3. + * @returns {string|null} - The valid scale string or null if invalid. + */ +export function allowedSpriteScales(scale, maxScale = 3) { + if (!scale) { + return ''; + } + const match = scale?.match(/^([2-9]\d*)x$/); + if (!match) { + return null; + } + const parsedScale = parseInt(match[1], 10); + if (parsedScale <= maxScale) { + return `@${parsedScale}x`; + } + return null; +} + +/** + * Replaces local:// URLs with public http(s):// URLs. + * @param {object} req - Express request object. + * @param {string} url - The URL string to fix. + * @param {string} publicUrl - The public URL prefix to use for replacements. + * @returns {string} - The fixed URL string. */ export function fixUrl(req, url, publicUrl) { if (!url || typeof url !== 'string' || url.indexOf('local://') !== 0) { @@ -40,12 +88,11 @@ export function fixUrl(req, url, publicUrl) { } /** - * Generate new URL object - * @param req - * @params {object} req - Express request - * @returns {URL} object + * Generates a new URL object from the Express request. + * @param {object} req - Express request object. + * @returns {URL} - URL object with correct host and optionally path. */ -const getUrlObject = (req) => { +function getUrlObject(req) { const urlObject = new URL(`${req.protocol}://${req.headers.host}/`); // support overriding hostname by sending X-Forwarded-Host http header urlObject.hostname = req.hostname; @@ -62,16 +109,33 @@ const getUrlObject = (req) => { urlObject.pathname = path.posix.join(xForwardedPath, urlObject.pathname); } return urlObject; -}; +} -export const getPublicUrl = (publicUrl, req) => { +/** + * Gets the public URL, either from a provided publicUrl or generated from the request. + * @param {string} publicUrl - The optional public URL to use. + * @param {object} req - The Express request object. + * @returns {string} - The final public URL string. + */ +export function getPublicUrl(publicUrl, req) { if (publicUrl) { return publicUrl; } return getUrlObject(req).toString(); -}; +} -export const getTileUrls = ( +/** + * Generates an array of tile URLs based on given parameters. + * @param {object} req - Express request object. + * @param {string | string[]} domains - Domain(s) to use for tile URLs. + * @param {string} path - The base path for the tiles. + * @param {number} [tileSize] - The size of the tile (optional). + * @param {string} format - The format of the tiles (e.g., 'png', 'jpg'). + * @param {string} publicUrl - The public URL to use (if not using domains). + * @param {object} [aliases] - Aliases for format extensions. + * @returns {string[]} An array of tile URL strings. + */ +export function getTileUrls( req, domains, path, @@ -79,7 +143,7 @@ export const getTileUrls = ( format, publicUrl, aliases, -) => { +) { const urlObject = getUrlObject(req); if (domains) { if (domains.constructor === String && domains.length > 0) { @@ -144,9 +208,14 @@ export const getTileUrls = ( } return uris; -}; +} -export const fixTileJSONCenter = (tileJSON) => { +/** + * Fixes the center in the tileJSON if no center is available. + * @param {object} tileJSON - The tileJSON object to process. + * @returns {void} + */ +export function fixTileJSONCenter(tileJSON) { if (tileJSON.bounds && !tileJSON.center) { const fitWidth = 1024; const tiles = fitWidth / 256; @@ -159,59 +228,122 @@ export const fixTileJSONCenter = (tileJSON) => { ), ]; } -}; - -const getFontPbf = (allowedFonts, fontPath, name, range, fallbacks) => - new Promise((resolve, reject) => { - if (!allowedFonts || (allowedFonts[name] && fallbacks)) { - const filename = path.join(fontPath, name, `${range}.pbf`); - if (!fallbacks) { - fallbacks = clone(allowedFonts || {}); +} + +/** + * Reads a file and returns a Promise with the file data. + * @param {string} filename - Path to the file to read. + * @returns {Promise} - A Promise that resolves with the file data as a Buffer or rejects with an error. + */ +export function readFile(filename) { + return new Promise((resolve, reject) => { + const sanitizedFilename = path.normalize(filename); // Normalize path, remove .. + // eslint-disable-next-line security/detect-non-literal-fs-filename + fs.readFile(String(sanitizedFilename), (err, data) => { + if (err) { + reject(err); + } else { + resolve(data); } - delete fallbacks[name]; - fs.readFile(filename, (err, data) => { - if (err) { - console.error(`ERROR: Font not found: ${name}`); - if (fallbacks && Object.keys(fallbacks).length) { - let fallbackName; - - let fontStyle = name.split(' ').pop(); - if (['Regular', 'Bold', 'Italic'].indexOf(fontStyle) < 0) { - fontStyle = 'Regular'; - } - fallbackName = `Noto Sans ${fontStyle}`; - if (!fallbacks[fallbackName]) { - fallbackName = `Open Sans ${fontStyle}`; - if (!fallbacks[fallbackName]) { - fallbackName = Object.keys(fallbacks)[0]; - } - } - - console.error(`ERROR: Trying to use ${fallbackName} as a fallback`); - delete fallbacks[fallbackName]; - getFontPbf(null, fontPath, fallbackName, range, fallbacks).then( - resolve, - reject, - ); - } else { - reject(`Font load error: ${name}`); + }); + }); +} + +/** + * Retrieves font data for a given font and range. + * @param {object} allowedFonts - An object of allowed fonts. + * @param {string} fontPath - The path to the font directory. + * @param {string} name - The name of the font. + * @param {string} range - The range (e.g., '0-255') of the font to load. + * @param {object} [fallbacks] - Optional fallback font list. + * @returns {Promise} A promise that resolves with the font data Buffer or rejects with an error. + */ +async function getFontPbf(allowedFonts, fontPath, name, range, fallbacks) { + if (!allowedFonts || (allowedFonts[name] && fallbacks)) { + const fontMatch = name?.match(/^[\p{L}\p{N} \-\.~!*'()@&=+,#$\[\]]+$/u); + const sanitizedName = fontMatch?.[0] || 'invalid'; + if (!name || typeof name !== 'string' || name.trim() === '' || !fontMatch) { + console.error( + 'ERROR: Invalid font name: %s', + sanitizedName.replace(/\n|\r/g, ''), + ); + throw new Error('Invalid font name'); + } + + const rangeMatch = range?.match(/^[\d-]+$/); + const sanitizedRange = rangeMatch?.[0] || 'invalid'; + if (!/^\d+-\d+$/.test(range)) { + console.error( + 'ERROR: Invalid range: %s', + sanitizedRange.replace(/\n|\r/g, ''), + ); + throw new Error('Invalid range'); + } + const filename = path.join( + fontPath, + sanitizedName, + `${sanitizedRange}.pbf`, + ); + + if (!fallbacks) { + fallbacks = clone(allowedFonts || {}); + } + delete fallbacks[name]; + + try { + const data = await readFile(filename); + return data; + } catch (err) { + console.error( + 'ERROR: Font not found: %s, Error: %s', + filename.replace(/\n|\r/g, ''), + String(err), + ); + if (fallbacks && Object.keys(fallbacks).length) { + let fallbackName; + + let fontStyle = name.split(' ').pop(); + if (['Regular', 'Bold', 'Italic'].indexOf(fontStyle) < 0) { + fontStyle = 'Regular'; + } + fallbackName = `Noto Sans ${fontStyle}`; + if (!fallbacks[fallbackName]) { + fallbackName = `Open Sans ${fontStyle}`; + if (!fallbacks[fallbackName]) { + fallbackName = Object.keys(fallbacks)[0]; } - } else { - resolve(data); } - }); - } else { - reject(`Font not allowed: ${name}`); + console.error( + `ERROR: Trying to use %s as a fallback for: %s`, + fallbackName, + sanitizedName, + ); + delete fallbacks[fallbackName]; + return getFontPbf(null, fontPath, fallbackName, range, fallbacks); + } else { + throw new Error('Font load error'); + } } - }); - -export const getFontsPbf = async ( + } else { + throw new Error('Font not allowed'); + } +} +/** + * Combines multiple font pbf buffers into one. + * @param {object} allowedFonts - An object of allowed fonts. + * @param {string} fontPath - The path to the font directory. + * @param {string} names - Comma-separated font names. + * @param {string} range - The range of the font (e.g., '0-255'). + * @param {object} [fallbacks] - Fallback font list. + * @returns {Promise} - A promise that resolves to the combined font data buffer. + */ +export async function getFontsPbf( allowedFonts, fontPath, names, range, fallbacks, -) => { +) { const fonts = names.split(','); const queue = []; for (const font of fonts) { @@ -228,9 +360,14 @@ export const getFontsPbf = async ( const combined = combine(await Promise.all(queue), names); return Buffer.from(combined.buffer, 0, combined.buffer.length); -}; +} -export const listFonts = async (fontPath) => { +/** + * Lists available fonts in a given font directory. + * @param {string} fontPath - The path to the font directory. + * @returns {Promise} - Promise that resolves with an object where keys are the font names. + */ +export async function listFonts(fontPath) { const existingFonts = {}; const files = await fsPromises.readdir(fontPath); @@ -245,9 +382,14 @@ export const listFonts = async (fontPath) => { } return existingFonts; -}; +} -export const isValidHttpUrl = (string) => { +/** + * Checks if a string is a valid HTTP or HTTPS URL. + * @param {string} string - The string to validate. + * @returns {boolean} True if the string is a valid HTTP/HTTPS URL, false otherwise. + */ +export function isValidHttpUrl(string) { let url; try { @@ -257,4 +399,32 @@ export const isValidHttpUrl = (string) => { } return url.protocol === 'http:' || url.protocol === 'https:'; -}; +} + +/** + * Fetches tile data from either PMTiles or MBTiles source. + * @param {object} source - The source object, which may contain a mbtiles object, or pmtiles object. + * @param {string} sourceType - The source type, which should be `pmtiles` or `mbtiles` + * @param {number} z - The zoom level. + * @param {number} x - The x coordinate of the tile. + * @param {number} y - The y coordinate of the tile. + * @returns {Promise} - A promise that resolves to an object with data and headers or null if no data is found. + */ +export async function fetchTileData(source, sourceType, z, x, y) { + if (sourceType === 'pmtiles') { + return await new Promise(async (resolve) => { + const tileinfo = await getPMtilesTile(source, z, x, y); + if (!tileinfo?.data) return resolve(null); + resolve({ data: tileinfo.data, headers: tileinfo.header }); + }); + } else if (sourceType === 'mbtiles') { + return await new Promise((resolve) => { + source.getTile(z, x, y, (err, tileData, tileHeader) => { + if (err) { + return resolve(null); + } + resolve({ data: tileData, headers: tileHeader }); + }); + }); + } +} diff --git a/test/setup.js b/test/setup.js index 34fba6707..1852a195c 100644 --- a/test/setup.js +++ b/test/setup.js @@ -7,10 +7,10 @@ import { server } from '../src/server.js'; global.expect = expect; global.supertest = supertest; -before(function () { +before(async function () { console.log('global setup'); process.chdir('test_data'); - const running = server({ + const running = await server({ configPath: 'config.json', port: 8888, publicUrl: '/test/', diff --git a/test/static.js b/test/static.js index 32bd80c77..dedc793ec 100644 --- a/test/static.js +++ b/test/static.js @@ -78,7 +78,7 @@ describe('Static endpoints', function () { testStatic(prefix, '0,0,0/256x256', 'png', 404, 1); testStatic(prefix, '0,0,-1/256x256', 'png', 404); - testStatic(prefix, '0,0,0/256.5x256.5', 'png', 404); + testStatic(prefix, '0,0,0/256.5x256.5', 'png', 400); testStatic(prefix, '0,0,0,/256x256', 'png', 404); testStatic(prefix, '0,0,0,0,/256x256', 'png', 404); @@ -135,7 +135,7 @@ describe('Static endpoints', function () { testStatic(prefix, '0,0,1,1/1x1', 'gif', 400); - testStatic(prefix, '-180,-80,180,80/0.5x2.6', 'png', 404); + testStatic(prefix, '-180,-80,180,80/0.5x2.6', 'png', 400); }); }); diff --git a/test/tiles_rendered.js b/test/tiles_rendered.js index 6f7f43809..61996d93c 100644 --- a/test/tiles_rendered.js +++ b/test/tiles_rendered.js @@ -60,16 +60,16 @@ describe('Raster tiles', function () { describe('invalid requests return 4xx', function () { testTile('non_existent', 256, 0, 0, 0, 'png', 404); - testTile(prefix, 256, -1, 0, 0, 'png', 404); - testTile(prefix, 256, 25, 0, 0, 'png', 404); - testTile(prefix, 256, 0, 1, 0, 'png', 404); - testTile(prefix, 256, 0, 0, 1, 'png', 404); + testTile(prefix, 256, -1, 0, 0, 'png', 400); + testTile(prefix, 256, 25, 0, 0, 'png', 400); + testTile(prefix, 256, 0, 1, 0, 'png', 400); + testTile(prefix, 256, 0, 0, 1, 'png', 400); testTile(prefix, 256, 0, 0, 0, 'gif', 400); testTile(prefix, 256, 0, 0, 0, 'pbf', 400); - testTile(prefix, 256, 0, 0, 0, 'png', 404, 1); - testTile(prefix, 256, 0, 0, 0, 'png', 404, 5); + testTile(prefix, 256, 0, 0, 0, 'png', 400, 1); + testTile(prefix, 256, 0, 0, 0, 'png', 400, 5); - testTile(prefix, 300, 0, 0, 0, 'png', 404); + testTile(prefix, 300, 0, 0, 0, 'png', 400); }); });