diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8691235..e35c0e4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,9 +14,41 @@ on: required: true jobs: + release-check: + name: Check if version is published + runs-on: ubuntu-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: 'package.json' + check-latest: true + cache: 'npm' + + - name: Check if version is published + id: check + run: | + currentVersion="$( node -e "console.log(require('./package.json').version)" )" + isPublished="$( npm view tileserver-gl versions --json | jq -c --arg cv "$currentVersion" 'any(. == $cv)' )" + echo "version=$currentVersion" >> "$GITHUB_OUTPUT" + echo "published=$isPublished" >> "$GITHUB_OUTPUT" + echo "currentVersion: $currentVersion" + echo "isPublished: $isPublished" + outputs: + published: ${{ steps.check.outputs.published }} + version: ${{ steps.check.outputs.version }} + release: + needs: release-check + if: ${{ needs.release-check.outputs.published == 'false' }} name: 'Build, Test, Publish' runs-on: ubuntu-22.04 + env: + PACKAGE_VERSION: ${{ needs.release-check.outputs.version }} steps: - name: Check out repository ✨ uses: actions/checkout@v4 @@ -54,17 +86,23 @@ jobs: - name: Remove Test Data run: rm -R test_data* - - name: Publish to Full Version NPM + - name: Get release type + id: prepare_release + run: | + RELEASE_TYPE="$(node -e "console.log(require('semver').prerelease('${{ needs.release-check.outputs.version }}') ? 'prerelease' : 'regular')")" + if [[ $RELEASE_TYPE == 'regular' ]]; then + echo "prerelease=false" >> "$GITHUB_OUTPUT" + else + echo "prerelease=true" >> "$GITHUB_OUTPUT" + fi + + - name: Publish to NPM run: | npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN} - npm publish --access public + npm publish --access public --tag ${{ steps.prepare_release.outputs.prerelease == 'true' && 'next' || 'latest' }} env: NPM_TOKEN: ${{ github.event.inputs.npm_token }} - - name: Get version - run: | - echo "PACKAGE_VERSION=$(grep '"version"' package.json | cut -d '"' -f 4 | head -n 1)" >> $GITHUB_ENV - - name: Set up QEMU uses: docker/setup-qemu-action@v3 with: @@ -84,24 +122,42 @@ jobs: with: context: . push: true - tags: maptiler/tileserver-gl:latest, maptiler/tileserver-gl:v${{ env.PACKAGE_VERSION }} + tags: | + maptiler/tileserver-gl:${{ steps.prepare_release.outputs.prerelease == 'true' && 'next' || 'latest' }}, + maptiler/tileserver-gl:v${{ env.PACKAGE_VERSION }} platforms: linux/arm64,linux/amd64 - # experimental: https://github.com/docker/build-push-action/blob/master/docs/advanced/cache.md#cache-backend-api cache-from: type=gha cache-to: type=gha,mode=max + - name: Extract changelog for version + run: | + awk '/^##/ { p = 0 }; p == 1 { print }; $0 == "## ${{ env.PACKAGE_VERSION }}" { p = 1 };' CHANGELOG.md > changelog_for_version.md + cat changelog_for_version.md + + - name: Publish to Github + uses: ncipollo/release-action@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag: v${{ env.PACKAGE_VERSION }} + name: v${{ env.PACKAGE_VERSION }} + bodyFile: changelog_for_version.md + allowUpdates: true + draft: false + prerelease: ${{ steps.prepare_release.outputs.prerelease }} + - name: Create Tileserver Light Directory run: node publish.js --no-publish - name: Install node dependencies - run: npm install + run: npm ci --prefer-offline --no-audit working-directory: ./light - name: Publish to Light Version NPM working-directory: ./light run: | npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN} - npm publish --access public + npm publish --access public --tag ${{ steps.prepare_release.outputs.prerelease == 'true' && 'next' || 'latest' }} env: NPM_TOKEN: ${{ github.event.inputs.npm_token }} @@ -111,8 +167,9 @@ jobs: context: ./light file: ./light/Dockerfile push: true - tags: maptiler/tileserver-gl-light:latest, maptiler/tileserver-gl-light:v${{ env.PACKAGE_VERSION }} + tags: | + maptiler/tileserver-gl-light:${{ steps.prepare_release.outputs.prerelease == 'true' && 'next' || 'latest' }}, + maptiler/tileserver-gl-light:v${{ env.PACKAGE_VERSION }} platforms: linux/arm64,linux/amd64 - # experimental: https://github.com/docker/build-push-action/blob/master/docs/advanced/cache.md#cache-backend-api cache-from: type=gha cache-to: type=gha,mode=max diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2a1e4e5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,22 @@ +# tileserver-gl changelog + +## 5.1.3 +* Fix SIGHUP (broken since 5.1.x) (https://github.com/maptiler/tileserver-gl/pull/1452) by @okimiko + +## 5.1.2 +* Fix broken light (invalid use of heavy dependencies) (https://github.com/maptiler/tileserver-gl/pull/1449) by @okimiko + +## 5.1.1 +* Fix wrong node version in Docker image (https://github.com/maptiler/tileserver-gl/pull/1442) by @acalcutt + +## 5.1.0 +* Update recommended node to v22 + Update docker images to use node 22 (https://github.com/maptiler/tileserver-gl/pull/1438) by @acalcutt +* 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. + * This the first release that uses OpenGL (ES) 3.0 on Windows and Linux + * Note: Windows users may need to update their [c++ redistributable ](https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist?view=msvc-170) for maplibre-native v6.0.0 diff --git a/Dockerfile b/Dockerfile index facc777..701377a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,7 +35,7 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"] RUN mkdir -p /etc/apt/keyrings; \ curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg; \ - echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list; \ + echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list; \ apt-get -qq update; \ apt-get install -y nodejs; \ npm i -g npm@latest; \ @@ -94,7 +94,7 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"] RUN mkdir -p /etc/apt/keyrings; \ curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg; \ - echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list; \ + echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list; \ apt-get -qq update; \ apt-get install -y nodejs; \ npm i -g npm@latest; \ diff --git a/Dockerfile_light b/Dockerfile_light index 6a6595b..f16f25d 100644 --- a/Dockerfile_light +++ b/Dockerfile_light @@ -16,7 +16,7 @@ RUN set -ex; \ gnupg; \ mkdir -p /etc/apt/keyrings; \ curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg; \ - echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list; \ + echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list; \ apt-get -qq update; \ apt-get install -y nodejs; \ npm i -g npm@latest; \ diff --git a/Dockerfile_test b/Dockerfile_test index db69272..be79e28 100644 --- a/Dockerfile_test +++ b/Dockerfile_test @@ -13,7 +13,8 @@ RUN set -ex; \ unzip \ build-essential \ ca-certificates \ - wget \ + curl \ + gnupg \ pkg-config \ xvfb \ libglfw3-dev \ @@ -25,16 +26,33 @@ RUN set -ex; \ libjpeg-dev \ libgif-dev \ librsvg2-dev \ + gir1.2-rsvg-2.0 \ + librsvg2-2 \ + librsvg2-common \ libcurl4-openssl-dev \ - libpixman-1-dev; \ - wget -qO- https://deb.nodesource.com/setup_18.x | bash; \ + libpixman-1-dev \ + libpixman-1-0; \ + apt-get -y --purge autoremove; \ + apt-get clean; \ + rm -rf /var/lib/apt/lists/*; + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +RUN mkdir -p /etc/apt/keyrings; \ + curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg; \ + echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list; \ + apt-get -qq update; \ apt-get install -y nodejs; \ - apt-get clean; + npm i -g npm@latest; \ + apt-get -y remove gnupg; \ + apt-get -y --purge autoremove; \ + apt-get clean; \ + rm -rf /var/lib/apt/lists/*; RUN mkdir -p /usr/src/app WORKDIR /usr/src/app -RUN wget -O test_data.zip https://github.com/maptiler/tileserver-gl/releases/download/v1.3.0/test_data.zip; \ +RUN curl -L -o test_data.zip https://github.com/maptiler/tileserver-gl/releases/download/v1.3.0/test_data.zip; \ unzip -q test_data.zip -d test_data COPY package.json . diff --git a/PUBLISHING.md b/PUBLISHING.md index 8b41b6b..fedaf39 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -1,13 +1,19 @@ # Publishing new version -- Update version in `package.json` -- `git tag vx.x.x` -- `git push --tags` -- `docker buildx build --platform linux/amd64 -t maptiler/tileserver-gl:latest -t maptiler/tileserver-gl:[version] .` -- `docker push maptiler/tileserver-gl --all-tags` -- `npm publish --access public` or `node publish.js` -- `node publish.js --no-publish` -- `cd light` -- `docker buildx build --platform linux/amd64 -t maptiler/tileserver-gl-light:latest -t maptiler/tileserver-gl-light:[version] .` -- `docker push maptiler/tileserver-gl-light --all-tags` -- `npm publish --access public` +1.) Change the version number in package.json. Run the following command in the package root directory, replacing with one of the semantic versioning release types (prerelease, prepatch, preminor, premajor, patch, minor, major): +npm version --preid pre --no-git-tag-version + +--preid specifies which suffix to use in the release such as pre, next, beta, rc, etc. + +prepatch, preminor, and premajor start a new series of pre-releases while bumping the patch, minor, or major version. E.g. premajor with --preid pre would do a prerelease for a new major using the -pre suffix (i.e. it would be a new major with -pre.0) + +You can use prerelease to bump the version for a new pre-release version. E.g. you could run npm version prerelease --preid pre --no-git-tag-version to go from -pre.0 to -pre.1. + +For regular versions, you can use patch, minor, or major. E.g. npm version major --no-git-tag-version. + +2.) Update the changelog, which can be found in CHANGELOG.md. The heading must match ## exactly, or it will not be picked up. For example, for version 5.0.0: +```## 5.0.0``` + +3.) Commit and push the changes. + +4.) Run the 'Build, Test, Release' github workflow. The workflow will create a NPM, Docker, and Github release and Tag. diff --git a/README.md b/README.md index cf310ba..55c2bda 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Vector and raster maps with GL styles. Server-side rendering by MapLibre GL Nati Download vector tiles from [OpenMapTiles](https://data.maptiler.com/downloads/planet/). ## Getting Started with Node -Make sure you have Node.js version **18.17.0** or above installed. Node 20 is recommended. (running `node -v` it should output something like `v20.x.x`). Running without docker requires [Native dependencies](https://maptiler-tileserver.readthedocs.io/en/latest/installation.html#npm) to be installed first. +Make sure you have Node.js version **18.17.0** or above installed. Node 22 is recommended. (running `node -v` it should output something like `v22.x.x`). Running without docker requires [Native dependencies](https://maptiler-tileserver.readthedocs.io/en/latest/installation.html#npm) to be installed first. Install `tileserver-gl` with server-side raster rendering of vector tiles with npm. @@ -43,7 +43,7 @@ An alternative to npm to start the packed software easier is to install [Docker] Example using a mbtiles file ```bash wget https://github.com/maptiler/tileserver-gl/releases/download/v1.3.0/zurich_switzerland.mbtiles -docker run --rm -it -v $(pwd):/data -p 8080:8080 maptiler/tileserver-gl --file zurich_switzerland.mbtiles +docker run --rm -it -v $(pwd):/data -p 8080:8080 maptiler/tileserver-gl:latest --file zurich_switzerland.mbtiles [in your browser, visit http://[server ip]:8080] ``` @@ -51,18 +51,18 @@ Example using a config.json + style + mbtiles file ```bash wget https://github.com/maptiler/tileserver-gl/releases/download/v1.3.0/test_data.zip unzip test_data.zip -docker run --rm -it -v $(pwd):/data -p 8080:8080 maptiler/tileserver-gl +docker run --rm -it -v $(pwd):/data -p 8080:8080 maptiler/tileserver-gl:latest [in your browser, visit http://[server ip]:8080] ``` Example using a different path ```bash -docker run --rm -it -v /your/local/config/path:/data -p 8080:8080 maptiler/tileserver-gl +docker run --rm -it -v /your/local/config/path:/data -p 8080:8080 maptiler/tileserver-gl:latest ``` replace '/your/local/config/path' with the path to your config file -Alternatively, you can use the `maptiler/tileserver-gl-light` docker image instead, which is pure javascript, does not have any native dependencies, and can run anywhere, but does not contain rasterization on the server side made with Maplibre GL Native. +Alternatively, you can use the `maptiler/tileserver-gl-light:latest` docker image instead, which is pure javascript, does not have any native dependencies, and can run anywhere, but does not contain rasterization on the server side made with Maplibre GL Native. ## Getting Started with Linux cli diff --git a/docs/endpoints.rst b/docs/endpoints.rst index 12f1acf..435144a 100644 --- a/docs/endpoints.rst +++ b/docs/endpoints.rst @@ -108,6 +108,8 @@ Source data * the result will be a json object like ``{"z":7,"x":68,"y":45,"red":134,"green":66,"blue":0,"latitude":11.84069,"longitude":46.04798,"elevation":1602}`` + * The elevation api is not available in the ``tileserver-gl-light`` version. + Static files =========== * Static files are served at ``/files/{filename}`` diff --git a/package-lock.json b/package-lock.json index 699bb8e..8738ec4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tileserver-gl", - "version": "5.0.0", + "version": "5.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tileserver-gl", - "version": "5.0.0", + "version": "5.1.3", "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", "maplibre-contour": "^0.1.0", @@ -34,6 +34,7 @@ "pmtiles": "3.0.7", "proj4": "2.12.1", "sanitize-filename": "1.6.3", + "semver": "^7.6.3", "sharp": "0.33.5", "tileserver-gl-styles": "2.0.0" }, @@ -60,7 +61,7 @@ "yaml-lint": "^1.7.0" }, "engines": { - "node": ">=18.17.0 <21" + "node": ">=18.17.0 <23" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -1722,17 +1723,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": "~2.1.34", - "negotiator": "0.6.3" + "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-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 +1953,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 +2040,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 +2088,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 +2176,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 +2254,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 +2306,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 +2602,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 +2675,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 +2850,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 +3006,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 +3034,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 +3123,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,60 +3612,75 @@ "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==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.0.1.tgz", + "integrity": "sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ==", "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.2", - "content-disposition": "0.5.4", + "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==", + "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/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": { - "ms": "2.0.0" + "mime-db": "^1.53.0" + }, + "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/extend-shallow": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", @@ -3600,9 +3782,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 +3806,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 +3926,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 +3968,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 +4077,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 +4159,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 +4357,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 +4409,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 +4519,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 +4529,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 +4875,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", @@ -5311,11 +5558,11 @@ "integrity": "sha512-H8muT7JWYE4oLbFv7L2RSbIM1NOu5JxjA9P/TQqhODDnRChE8ENoDkQIWOKgfcKNU77ypLk2ggGoh4/pt4UPLA==" }, "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": { @@ -5388,9 +5635,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", @@ -5433,17 +5686,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", @@ -5628,6 +5870,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", @@ -5817,10 +6064,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", @@ -5914,6 +6161,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" } @@ -5928,6 +6176,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", @@ -6324,9 +6583,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" } @@ -6549,9 +6812,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", @@ -6622,6 +6888,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", @@ -6708,6 +7048,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", @@ -6718,11 +7067,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" @@ -6782,19 +7132,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", @@ -7092,6 +7480,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", @@ -7192,41 +7597,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", @@ -7242,17 +7641,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": { @@ -7260,6 +7659,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", @@ -7412,13 +7828,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" @@ -7985,6 +8406,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", @@ -8091,6 +8543,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", @@ -8116,12 +8579,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 d7070cc..09cdc07 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tileserver-gl", - "version": "5.0.0", + "version": "5.1.3", "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", "maplibre-contour": "^0.1.0", @@ -43,6 +43,7 @@ "pmtiles": "3.0.7", "proj4": "2.12.1", "sanitize-filename": "1.6.3", + "semver": "^7.6.3", "sharp": "0.33.5", "tileserver-gl-styles": "2.0.0" }, @@ -73,7 +74,7 @@ ], "license": "BSD-2-Clause", "engines": { - "node": ">=18.17.0 <21" + "node": ">=18.17.0 <23" }, "repository": { "url": "git+https://github.com/maptiler/tileserver-gl.git", diff --git a/public/resources/contour-control.js b/public/resources/contour-control.js new file mode 100644 index 0000000..2549d49 --- /dev/null +++ b/public/resources/contour-control.js @@ -0,0 +1,65 @@ +class MaplibreContourControl { + constructor(options) { + this.source = options["source"]; + this.confLayers = options["layers"]; + this.visibility = options["visibility"]; + } + + getDefaultPosition() { + const defaultPosition = "top-right"; + return defaultPosition; + } + + onAdd(map) { + this.map = map; + this.controlContainer = document.createElement("div"); + this.controlContainer.classList.add("maplibregl-ctrl"); + this.controlContainer.classList.add("maplibregl-ctrl-group"); + this.contourButton = document.createElement("button"); + this.contourButton.type = "button"; + this.contourButton.textContent = "C"; + + this.map.on("style.load", () => { + this.confLayers.forEach(layer => { + this.map.setLayoutProperty(layer, "visibility", this.visibility ? "visible" : "none"); + if (this.visibility) { + this.controlContainer.classList.add("maplibre-ctrl-contour-active"); + this.contourButton.title = "Disable Contours"; + } else { + this.contourButton.title = "Enable Contours"; + } + }); + }); + + this.contourButton.addEventListener("click", () => { + this.confLayers.forEach(layer => { + var visibility = this.map.getLayoutProperty(layer, "visibility"); + if (visibility === "visible") { + this.map.setLayoutProperty(layer, "visibility", "none"); + this.controlContainer.classList.remove("maplibre-ctrl-contour-active"); + this.contourButton.title = "Disable Contours"; + } else { + this.controlContainer.classList.add("maplibre-ctrl-contour-active"); + this.map.setLayoutProperty(layer, "visibility", "visible"); + this.contourButton.title = "Enable Contours"; + } + }); + }); + this.controlContainer.appendChild(this.contourButton); + return this.controlContainer; + } + + onRemove() { + if ( + !this.controlContainer || + !this.controlContainer.parentNode || + !this.map || + !this.contourButton + ) { + return; + } + this.contourButton.removeEventListener("click"); + this.controlContainer.parentNode.removeChild(this.controlContainer); + this.map = undefined; + } +}; diff --git a/public/resources/elevation-control.js b/public/resources/elevation-control.js new file mode 100644 index 0000000..a1fda61 --- /dev/null +++ b/public/resources/elevation-control.js @@ -0,0 +1,51 @@ +class ElevationInfoControl { + constructor(options) { + this.url = options["url"]; + } + + getDefaultPosition() { + const defaultPosition = "bottom-left"; + return defaultPosition; + } + + onAdd(map) { + this.map = map; + this.controlContainer = document.createElement("div"); + this.controlContainer.classList.add("maplibregl-ctrl"); + this.controlContainer.classList.add("maplibregl-ctrl-group"); + this.controlContainer.classList.add("maplibre-ctrl-elevation"); + this.controlContainer.textContent = "Elevation: Click on Map"; + + map.on('click', (e) => { + var url = this.url; + var coord = {"z": Math.floor(map.getZoom()), "x": e.lngLat["lng"], "y": e.lngLat["lat"]}; + for(var key in coord) { + url = url.replace(new RegExp('{'+ key +'}','g'), coord[key]); + } + + let request = new XMLHttpRequest(); + request.open("GET", url, true); + request.onload = () => { + if (request.status !== 200) { + this.controlContainer.textContent = "Elevation: No value"; + } else { + this.controlContainer.textContent = `Elevation: ${JSON.parse(request.responseText).elevation} (${JSON.stringify(coord)})`; + } + } + request.send(); + }); + return this.controlContainer; + } + + onRemove() { + if ( + !this.controlContainer || + !this.controlContainer.parentNode || + !this.map + ) { + return; + } + this.controlContainer.parentNode.removeChild(this.controlContainer); + this.map = undefined; + } + }; diff --git a/public/resources/fonts/OpenSans-Bold.ttf b/public/resources/fonts/OpenSans-Bold.ttf index fd79d43..b7fadfa 100644 Binary files a/public/resources/fonts/OpenSans-Bold.ttf and b/public/resources/fonts/OpenSans-Bold.ttf differ diff --git a/public/resources/fonts/OpenSans-Italic.ttf b/public/resources/fonts/OpenSans-Italic.ttf index c90da48..e99cb92 100644 Binary files a/public/resources/fonts/OpenSans-Italic.ttf and b/public/resources/fonts/OpenSans-Italic.ttf differ diff --git a/public/resources/fonts/OpenSans-Regular.ttf b/public/resources/fonts/OpenSans-Regular.ttf index db43334..8529c43 100644 Binary files a/public/resources/fonts/OpenSans-Regular.ttf and b/public/resources/fonts/OpenSans-Regular.ttf differ diff --git a/public/templates/data.tmpl b/public/templates/data.tmpl index 032c310..9ecdc37 100644 --- a/public/templates/data.tmpl +++ b/public/templates/data.tmpl @@ -9,17 +9,25 @@ + + {{^is_light}} + + {{/is_light}} {{/use_maplibre}} {{^use_maplibre}} @@ -69,10 +77,9 @@ }; {{/is_terrain}} {{#is_terrain}} - let baseUrl = window.location.origin; - console.log(baseUrl); - baseUrl = baseUrl + "/data/{{id}}/contour/{z}/{x}/{y}" - console.log(baseUrl); + + let baseUrl = window.location.origin; + var style = { version: 8, sources: { @@ -88,42 +95,64 @@ }, "contour": { "type": "vector", - "tiles": [ baseUrl ], + "tiles": [ baseUrl + "/data/{{id}}/contour/{z}/{x}/{y}" ], } }, + "glyphs": "/fonts/{fontstack}/{range}.pbf", "terrain": { "source": "terrain" }, - "layers": [ - { - "id": "background", - "paint": { - "background-color": "hsl(190, 99%, 63%)" - }, - "type": "background" - }, - { - "id": "hillshade", - "source": "hillshade", - "type": "hillshade", - "paint": { - "hillshade-shadow-color": "hsl(39, 21%, 33%)", - "hillshade-illumination-direction": 315, - "hillshade-exaggeration": 0.8 - } - }, - { - "id": "contours", - "type": "line", - "source": "contour", - "source-layer": "contours", - "paint": { - "line-opacity": 0.5, - "line-width": ["match", ["get", "level"], 1, 1, 0.5] - } - } - ] - }; + "layers": [ + { + "id": "background", + "paint": { + {{#if is_terrainrgb}} + "background-color": "hsl(190, 99%, 63%)" + {{else}} + "background-color": "hsl(0, 100%, 25%)" + {{/if}} + }, + "type": "background" + }, + { + "id": "hillshade", + "source": "hillshade", + "type": "hillshade", + "paint": { + "hillshade-shadow-color": "hsl(39, 21%, 33%)", + "hillshade-illumination-direction": 315, + "hillshade-exaggeration": 0.8 + } + }, + { + "id": "contours", + "type": "line", + "source": "contour", + "source-layer": "contours", + "paint": { + "line-opacity": 1, + "line-width": ["match", ["get", "level"], 1, 1, 0.5] + } + }, + { + "id": "contour-label", + "type": "symbol", + "source": "contour", + "source-layer": "contours", + "filter": [">", ["get", "ele"], 0 ], + "paint": { + "text-halo-color": "white", + "text-halo-width": 1 + }, + "layout": { + "symbol-placement": "line", + "text-size": 10, + "text-field": "{ele}", + "text-font": ["Noto Sans Bold"] + } + } + ] + }; {{/is_terrain}} var map = new maplibregl.Map({ @@ -132,24 +161,44 @@ maxPitch: 85, style: style }); + map.addControl(new maplibregl.NavigationControl({ visualizePitch: true, showZoom: true, showCompass: true })); {{#is_terrain}} + map.addControl( new maplibregl.TerrainControl({ source: "terrain", }) ); + + map.addControl( + new MaplibreContourControl({ + source: "contour", + visibility: false, + layers: [ "contours", "contour-label" ] + }) + ); + + {{^is_light}} + map.addControl( + new ElevationInfoControl({ + url: "{{public_url}}data/{{id}}/elevation/{z}/{x}/{y}" + }) + ); + {{/is_light}} {{/is_terrain}} {{^is_terrain}} + var inspect = new MaplibreInspect({ showInspectMap: true, showInspectButton: false }); map.addControl(inspect); + map.on('styledata', function() { var layerList = document.getElementById('layerList'); layerList.innerHTML = ''; diff --git a/public/templates/index.tmpl b/public/templates/index.tmpl index 0edd19a..1d3a514 100644 --- a/public/templates/index.tmpl +++ b/public/templates/index.tmpl @@ -124,9 +124,9 @@ {{/is_vector}} {{^is_vector}} View - {{#elevation_link}} - Preview - {{/elevation_link}} + {{#is_terrain}} + Preview Terrain + {{/is_terrain}} {{/is_vector}} diff --git a/src/contour.js b/src/contour.js index 2dcbb6f..fa770dd 100644 --- a/src/contour.js +++ b/src/contour.js @@ -107,7 +107,7 @@ export class LocalDemManager { * @throws If an error occurs fetching or processing the tile. */ async GetTile(url, abortController) { - console.log(url); + //console.log(url); const $zxy = this.extractZXYFromUrlTrim(url); if (!$zxy) { throw new Error(`Could not extract zxy from $url`); @@ -171,7 +171,7 @@ export class LocalDemManager { }; } catch (error) { if (error.name === 'AbortError') { - console.log('fetch cancelled'); + console.log('fetch canceled'); return null; } throw error; diff --git a/src/main.js b/src/main.js index 7523aa9..b1f14a2 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 d13ac0d..19eb6fe 100644 --- a/src/serve_data.js +++ b/src/serve_data.js @@ -8,438 +8,403 @@ import express from 'express'; import Pbf from 'pbf'; import { VectorTile } from '@mapbox/vector-tile'; import SphericalMercator from '@mapbox/sphericalmercator'; -import { Image, createCanvas } from 'canvas'; -import sharp from 'sharp'; import { LocalDemManager } from './contour.js'; -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'; +import fs from 'node:fs'; +import { fileURLToPath } from 'url'; +const packageJson = JSON.parse( + fs.readFileSync( + path.dirname(fileURLToPath(import.meta.url)) + '/../package.json', + 'utf8', + ), +); + +const isLight = packageJson.name.slice(-6) === '-light'; +const serve_rendered = ( + await import(`${!isLight ? `./serve_rendered.js` : `./serve_light.js`}`) +).serve_rendered; + 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); - } - } - }); - } - }, - ); - - app.get( - '^/:id/contour/: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.'); - } - - 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 maxzoom = tileJSON?.maxzoom; - if (maxzoom == null) { - return res.status(400).send('Missing tileJSON.maxzoom'); - } - - const z = parseInt(req.params.z, 10); - const x = parseFloat(req.params.x); - const y = parseFloat(req.params.y); - - const demManagerInit = new LocalDemManager( - encoding, - maxzoom, - source, - sourceType, - ); - const demManager = await demManagerInit.getManager(); - - let levels; - if (z <= 8) { - levels = 1000; - } else if (z <= 10) { - levels = 500; - } else if (z <= 11) { - levels = 250; - } else if (z <= 12) { - levels = 100; - } else if (z <= 13) { - levels = 50; - } else if (z <= 14) { - levels = 25; - } else if (z <= 15) { - levels = 20; - } else if (z <= 17) { - levels = 10; - } else if (z >= 18) { - levels = 5; - } - - const { arrayBuffer } = await demManager.fetchContourTile( + data = options.dataDecoratorFunc( + req.params.id, + 'data', + data, z, x, y, - { levels: [levels] }, - new AbortController(), ); - - // Set the Content-Type header here - res.setHeader('Content-Type', 'application/x-protobuf'); - res.setHeader('Content-Encoding', 'gzip'); - let data = Buffer.from(arrayBuffer); - data = await gzipP(data); - res.send(data); - } catch (err) { - return res - .status(500) - .header('Content-Type', 'text/plain') - .send(err.message); } - }, - ); + } - 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.'); + 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); } - - 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 = 256; - let tileCenter; - let xy; - - if (Number.isInteger(x) && Number.isInteger(y)) { - const intX = parseInt(req.params.x, 10); - const intY = parseInt(req.params.y, 10); - - if ( - z < tileJSON.minzoom || - z > tileJSON.maxzoom || - intX < 0 || - intY < 0 || - intX >= Math.pow(2, z) || - intY >= Math.pow(2, z) - ) { - return res.status(404).send('Out of bounds'); - } - xy = [intX, intY]; - tileCenter = new SphericalMercator().bbox(intX, intY, z); - } else { - if ( - z < tileJSON.minzoom || - z > tileJSON.maxzoom || - x < -180 || - y < -90 || - x > 180 || - y > 90 - ) { - return res.status(404).send('Out of bounds'); - } - - tileCenter = [y, x, y + 0.1, x + 0.1]; - const { minX, minY } = new SphericalMercator().xyz(tileCenter, z); - xy = [minX, minY]; - } - - let data; - if (sourceType === 'pmtiles') { - const tileinfo = await getPMtilesTile(source, z, x, y); - if (!tileinfo?.data) return res.status(204).send(); - data = tileinfo.data; - } else { - data = await new Promise((resolve, reject) => { - source.getTile(z, xy[0], xy[1], (err, tileData) => { - if (err) { - return /does not exist/.test(err.message) - ? resolve(null) - : reject(err); - } - resolve(tileData); - }); - }); - } - 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 imgdata = context.getImageData(0, 0, TILE_SIZE, TILE_SIZE); - - const arrayWidth = imgdata.width; - const arrayHeight = imgdata.height; - const bytesPerPixel = 4; - - const xPixel = Math.floor(xy[0]); - const yPixel = Math.floor(xy[1]); - - if ( - xPixel < 0 || - yPixel < 0 || - xPixel >= arrayWidth || - yPixel >= arrayHeight - ) { - return reject('Out of bounds Pixel'); - } - - const index = (yPixel * arrayWidth + xPixel) * bytesPerPixel; - - const red = imgdata.data[index]; - const green = imgdata.data[index + 1]; - const blue = imgdata.data[index + 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, - x: xy[0], - y: xy[1], - red, - green, - blue, - latitude: tileCenter[0], - longitude: tileCenter[1], - 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); } - }, - ); + data = JSON.stringify(geojson); + } + if (headers) { + delete headers['ETag']; + } + headers['Content-Encoding'] = 'gzip'; + res.set(headers); - app.get('/:id.json', (req, res, next) => { + if (!isGzipped) { + data = await gzipP(data); + } + + return res.status(200).send(data); + }); + + /** + * Handles requests for contour data. + * @param {object} req - Express request object. + * @param {object} res - Express response object. + * @param {string} req.params.id - ID of the contour 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/contour/:z/:x/:y', async (req, res, next) => { + try { + if (verbose) { + console.log( + `Handling contour request for: /data/%s/contour/%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 maxzoom = tileJSON?.maxzoom; + if (maxzoom == null) { + return res.status(400).send('Missing tileJSON.maxzoom'); + } + + const z = parseInt(req.params.z, 10); + const x = parseFloat(req.params.x); + const y = parseFloat(req.params.y); + + const demManagerInit = new LocalDemManager( + encoding, + maxzoom, + source, + sourceType, + ); + const demManager = await demManagerInit.getManager(); + + let levels; + if (z <= 8) { + levels = 1000; + } else if (z <= 10) { + levels = 500; + } else if (z <= 11) { + levels = 250; + } else if (z <= 12) { + levels = 100; + } else if (z <= 13) { + levels = 50; + } else if (z <= 14) { + levels = 25; + } else if (z <= 15) { + levels = 20; + } else if (z <= 17) { + levels = 10; + } else if (z >= 18) { + levels = 5; + } + + const { arrayBuffer } = await demManager.fetchContourTile( + z, + x, + y, + { levels: [levels] }, + new AbortController(), + ); + // Set the Content-Type header here + res.setHeader('Content-Type', 'application/x-protobuf'); + res.setHeader('Content-Encoding', 'gzip'); + let data = Buffer.from(arrayBuffer); + data = await gzipP(data); + res.send(data); + } catch (err) { + return res + .status(500) + .header('Content-Type', 'text/plain') + .send(err.message); + } + }); + + /** + * 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 fetchTile = await fetchTileData( + source, + sourceType, + zoom, + xy[0], + xy[1], + ); + if (fetchTile == null) return res.status(204).send(); + + let data = fetchTile.data; + var param = { + long: bbox[0], + lat: bbox[1], + encoding, + format, + tile_size: TILE_SIZE, + z: zoom, + x: xy[0], + y: xy[1], + }; + + res + .status(200) + .send(await serve_rendered.getTerrainElevation(data, param)); + } catch (err) { + return res + .status(500) + .header('Content-Type', 'text/plain') + .send(err.message); + } + }); + + /** + * 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); @@ -462,7 +427,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) { @@ -503,6 +481,7 @@ export const serve_data = { const metadata = await getPMtilesInfo(source); tileJSON['encoding'] = params['encoding']; + tileJSON['tileSize'] = params['tileSize']; tileJSON['name'] = id; tileJSON['format'] = 'pbf'; Object.assign(tileJSON, metadata); @@ -524,6 +503,7 @@ export const serve_data = { const info = await mbw.getInfo(); source = mbw.getMbTiles(); tileJSON['encoding'] = params['encoding']; + tileJSON['tileSize'] = params['tileSize']; tileJSON['name'] = id; tileJSON['format'] = 'pbf'; diff --git a/src/serve_font.js b/src/serve_font.js index 02f46dc..a42246b 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; + /** + * 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, + '', + ); - try { - const concatenated = await getFontsPbf( - options.serveAllFonts ? null : allowedFonts, - fontPath, - fontstack, - range, - existingFonts, - ); + if (verbose) { + console.log( + `Handling font request for: /fonts/%s/%s.pbf`, + sFontStack, + sRange, + ); + } - 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); + 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); } - }, - ); + } - app.get('/fonts.json', (req, res, next) => { + 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'); + } + }); + + /** + * 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 474a781..13aa84c 100644 --- a/src/serve_light.js +++ b/src/serve_light.js @@ -3,7 +3,11 @@ '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) => {}, + getTerrainElevation: (data, param) => { + param['elevation'] = 'not supported in light'; + return param; + }, }; diff --git a/src/serve_rendered.js b/src/serve_rendered.js index 3e5c94e..3dc3cd5 100644 --- a/src/serve_rendered.js +++ b/src/serve_rendered.js @@ -7,13 +7,12 @@ // This happens on ARM: // > terminate called after throwing an instance of 'std::runtime_error' // > what(): Cannot read GLX extensions. -import 'canvas'; +import { Image, createCanvas } from 'canvas'; 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 = ( }); }); }); -}; +} +/** + * 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); + } + + 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); + } + } + const z = parseFloat(zParam) | 0; + const x = parseFloat(xParam) | 0; + const y = parseFloat(yParam) | 0; + const scale = allowedScales(scaleParam, maxScaleFactor); + + let parsedTileSize = parseInt(defailtTileSize, 10); + if (tileSize) { + parsedTileSize = parseInt(allowedTileSizes(tileSize), 10); + + if (parsedTileSize == null) { + return res.status(400).send('Invalid Tile Size'); + } + } + + 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, + ); + + // prettier-ignore + return await respondImage( + options, item, z, tileCenter[0], tileCenter[1], 0, 0, parsedTileSize, parsedTileSize, scale, format, res, + ); +} + +/** + * 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 scale = allowedScales(scaleParam, maxScaleFactor); + let isRaw = raw === 'raw'; + + const staticTypeMatch = staticType.match(staticTypeRegex); + if (!item || !format || !scale || !staticTypeMatch?.groups) { + return res.sendStatus(404); + } + + 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'); + } + + const transformer = isRaw + ? mercator.inverse.bind(mercator) + : item.dataProjWGStoInternalWGS; + + if (transformer) { + const ll = transformer([x, y]); + x = ll[0]; + y = ll[1]; + } + + 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); + } + + 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, + ); + + // 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); + } + + // 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, parsedWidth, parsedHeight, req.query); + if (maxZoom > 0) { + z = Math.min(z, maxZoom); + } + + const x = center[0]; + const y = center[1]; + + // 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 { + return res.sendStatus(404); + } +} const existingFonts = {}; let maxScaleFactor = 2; export const serve_rendered = { - init: async (options, repo) => { + /** + * 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); - let scalePattern = ''; - for (let i = 2; i <= maxScaleFactor; i++) { - scalePattern += i.toFixed(); - } - scalePattern = `@[${scalePattern}]x`; - const app = express().disable('x-powered-by'); + /** + * 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/(: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); - } - - 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); + `/: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, ''), + ); } + + if (requestType === 'static') { + // Route to static if p2 is static + if (options.serveStaticMaps !== false) { + return handleStaticRequest( + options, + repo, + req, + res, + next, + maxScaleFactor, + ); + } + return res.sendStatus(404); + } + + return handleTileRequest( + options, + repo, + req, + res, + next, + maxScaleFactor, + defailtTileSize, + ); + } catch (e) { + console.log(e); + return next(e); } - - 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'); - } - - 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, - ); }, ); - if (options.serveStaticMaps !== false) { - const staticPattern = `/:id/static/:raw(raw)?/%s/:width(\\d+)x:height(\\d+):scale(${scalePattern})?.:format([\\w]+)`; - - const centerPattern = util.format( - ':x(%s),:y(%s),:z(%s)(@:bearing(%s)(,:pitch(%s))?)?', - FLOAT_PATTERN, - FLOAT_PATTERN, - FLOAT_PATTERN, - FLOAT_PATTERN, - FLOAT_PATTERN, - ); - - 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 transformer = raw - ? mercator.inverse.bind(mercator) - : item.dataProjWGStoInternalWGS; - - if (transformer) { - const ll = transformer([x, y]); - x = ll[0]; - y = ll[1]; - } - - 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, - ); - - // prettier-ignore - return respondImage( - options, item, z, x, y, bearing, pitch, w, h, scale, format, res, overlay, 'static', - ); - } catch (e) { - next(e); - } - }, - ); - - 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; - - const z = calcZForBBox(bbox, w, h, 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, 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); - } - }; - - const boundsPattern = util.format( - ':minx(%s),:miny(%s),:maxx(%s),:maxy(%s)', - FLOAT_PATTERN, - FLOAT_PATTERN, - FLOAT_PATTERN, - FLOAT_PATTERN, - ); - - app.get(util.format(staticPattern, boundsPattern), serveBounds); - - 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}`; - } - - return serveBounds(req, res, next); - }); - - const autoPattern = 'auto'; - - 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, - ); - - // 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); - } - - 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); - } - }, - ); - } - - app.get('/(:tileSize(256|512)/)?:id.json', (req, res, next) => { + /** + * 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) => { @@ -1303,4 +1458,76 @@ export const serve_rendered = { } delete repo[id]; }, + + /** + * Get the elevation of terrain tile data by rendering it to a canvas image + * @param {object} data The background color (or empty string for transparent). + * @param {object} param Required parameters (coordinates e.g.) + * @returns {object} + */ + getTerrainElevation: async function (data, param) { + return await new Promise(async (resolve, reject) => { + const image = new Image(); + image.onload = async () => { + const canvas = createCanvas(param['tile_size'], param['tile_size']); + const context = canvas.getContext('2d'); + context.drawImage(image, 0, 0); + + // calculate pixel coordinate of tile, + // see https://developers.google.com/maps/documentation/javascript/examples/map-coordinates + let siny = Math.sin((param['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 = param['tile_size'] * (0.5 + param['long'] / 360); + const yWorld = + param['tile_size'] * + (0.5 - Math.log((1 + siny) / (1 - siny)) / (4 * Math.PI)); + + const scale = 1 << param['z']; + + const xTile = Math.floor((xWorld * scale) / param['tile_size']); + const yTile = Math.floor((yWorld * scale) / param['tile_size']); + + const xPixel = Math.floor(xWorld * scale) - xTile * param['tile_size']; + const yPixel = Math.floor(yWorld * scale) - yTile * param['tile_size']; + if ( + xPixel < 0 || + yPixel < 0 || + xPixel >= param['tile_size'] || + yPixel >= param['tile_size'] + ) { + return reject('Out of bounds Pixel'); + } + 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 (param['encoding'] === 'mapbox') { + elevation = -10000 + (red * 256 * 256 + green * 256 + blue) * 0.1; + } else if (param['encoding'] === 'terrarium') { + elevation = red * 256 + green + blue / 256 - 32768; + } else { + elevation = 'invalid encoding'; + } + param['elevation'] = elevation; + param['red'] = red; + param['green'] = green; + param['blue'] = blue; + resolve(param); + }; + image.onerror = (err) => reject(err); + if (param['format'] === 'webp') { + try { + const img = await sharp(data).toFormat('png').toBuffer(); + image.src = img; + } catch (err) { + reject(err); + } + } else { + image.src = data; + } + }); + }, }; diff --git a/src/serve_style.js b/src/serve_style.js index 5d3b469..51b03d3 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_.glyphs) { - styleJSON_.glyphs = fixUrl(req, styleJSON_.glyphs, item.publicUrl); - } - return res.send(styleJSON_); - }); - - 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, - ); - 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); - } + if (styleJSON_.sprite) { + if (Array.isArray(styleJSON_.sprite)) { + styleJSON_.sprite.forEach((spriteItem) => { + spriteItem.url = fixUrl(req, spriteItem.url, item.publicUrl); }); } else { - return res.status(400).send('Bad Sprite ID or Scale'); + styleJSON_.sprite = fixUrl(req, styleJSON_.sprite, item.publicUrl); } - } else { - return res.status(400).send('Bad Sprite Format'); + } + if (styleJSON_.glyphs) { + styleJSON_.glyphs = fixUrl(req, styleJSON_.glyphs, item.publicUrl); + } + return res.send(styleJSON_); + } catch (e) { + next(e); + } + }); + + /** + * 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}{.: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, + ); + } + 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); + } + } + + 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 39808e3..9a3f8a8 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,25 +16,30 @@ 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); -const __dirname = path.dirname(__filename); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); const packageJson = JSON.parse( fs.readFileSync(__dirname + '/../package.json', 'utf8'), ); - const isLight = packageJson.name.slice(-6) === '-light'; + const serve_rendered = ( await import(`${!isLight ? `./serve_rendered.js` : `./serve_light.js`}`) ).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 +75,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 +118,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 +139,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 +162,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 +188,7 @@ function start(opts) { serving.styles, item, id, - opts.publicUrl, + opts, (styleSourceId, protocol) => { let dataItemId; for (const id of Object.keys(data)) { @@ -235,7 +245,7 @@ function start(opts) { serving.rendered, item, id, - opts.publicUrl, + opts, function dataResolver(styleSourceId) { let fileType; let inputFile; @@ -261,7 +271,7 @@ function start(opts) { item.serve_rendered = false; } } - }; + } for (const id of Object.keys(config.styles || {})) { const item = config.styles[id]; @@ -272,13 +282,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 +296,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 +337,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 +364,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 +395,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 +443,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 +463,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 +486,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 +518,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 +542,6 @@ function start(opts) { styles[id] = style; } - let datas = {}; for (const id of Object.keys(serving.data || {})) { let data = Object.assign({}, serving.data[id]); @@ -517,15 +574,20 @@ function start(opts) { tileJSON.encoding === 'terrarium' || tileJSON.encoding === 'mapbox' ) { - data.elevation_link = getTileUrls( - req, - tileJSON.tiles, - `data/${id}/elevation`, - )[0]; + if (!isLight) { + data.elevation_link = getTileUrls( + req, + tileJSON.tiles, + `data/${id}/elevation`, + )[0]; + } + data.is_terrain = true; } 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 +604,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 +635,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 +673,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 +691,8 @@ function start(opts) { const is_terrain = (data.tileJSON.encoding === 'terrarium' || data.tileJSON.encoding === 'mapbox') && - preview === 'preview'; + view === 'preview'; + return { ...data, id, @@ -624,6 +700,7 @@ function start(opts) { is_terrain: is_terrain, is_terrainrgb: data.tileJSON.encoding === 'mapbox', terrain_encoding: data.tileJSON.encoding, + is_light: isLight, }; }); @@ -633,7 +710,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 +745,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 +756,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); @@ -691,12 +775,11 @@ export function server(opts) { console.log(`Caught signal ${signal}, refreshing`); console.log('Stopping server and reloading config'); - running.server.shutdown(() => { - const restarted = start(opts); + running.server.shutdown(async () => { + const restarted = await start(opts); running.server = restarted.server; running.app = restarted.app; }); }); - return running; } diff --git a/src/utils.js b/src/utils.js index 4f976f5..5dab80a 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,32 +88,54 @@ 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; + // support overriding port by sending X-Forwarded-Port http header + const xForwardedPort = req.get('X-Forwarded-Port'); + if (xForwardedPort) { + urlObject.port = xForwardedPort; + } + // support add url prefix by sending X-Forwarded-Path http header const xForwardedPath = req.get('X-Forwarded-Path'); if (xForwardedPath) { 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, @@ -73,7 +143,7 @@ export const getTileUrls = ( format, publicUrl, aliases, -) => { +) { const urlObject = getUrlObject(req); if (domains) { if (domains.constructor === String && domains.length > 0) { @@ -138,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; @@ -153,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}`); - } - } else { - resolve(data); - } - }); - } else { - reject(`Font not allowed: ${name}`); - } + }); }); +} -export const getFontsPbf = async ( +/** + * 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]; + } + } + 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'); + } + } + } 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) { @@ -222,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); @@ -239,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 { @@ -251,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 34fba67..1852a19 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 32bd80c..dedc793 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 6f7f438..61996d9 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); }); });