diff --git a/.dev.vars.example b/.dev.vars.example new file mode 100644 index 0000000..7b0ec70 --- /dev/null +++ b/.dev.vars.example @@ -0,0 +1,3 @@ +# Local development only (wrangler dev). Copy to .dev.vars, which git ignores. +ALLOWED_ORIGINS=https://weather.mukoko.com,http://localhost:3000 +PUBLIC_BASE_URL=http://localhost:8787 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f4c7245 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,40 @@ +# EditorConfig — https://editorconfig.org +# Keeps whitespace consistent across editors and IDEs before +# Prettier / rustfmt / ruff get a chance to run. + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +# Markdown: trailing spaces are significant (hard line-break). +# Let Prettier handle wrapping; don't trim here. +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[*.{json,jsonc}] +indent_size = 2 + +[*.toml] +indent_size = 4 + +[Makefile] +indent_style = tab + +[*.rs] +# rustfmt handles Rust formatting; EditorConfig just sets the baseline. +indent_size = 4 + +[*.py] +indent_size = 4 + +[*.{cpp,cc,c,h,hpp}] +indent_size = 4 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..37e53c0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,70 @@ +# Tests, typecheck and a bundle check for the Worker. The org lint gate +# (actionlint, JSON, prettier, markdownlint, yamllint) is lint.yml. +# +# Deploys are not done here: nothing is deployed from this repo until the +# owner provisions the bucket, the DNS record and the route. + +name: CI + +on: + pull_request: + push: + branches: [main] + merge_group: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +env: + # wrangler needs Node 22 or later. + NODE_VERSION: "24" + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + - run: npm ci + # The Workers Vitest suite, against stubbed R2 and rate limiter. + - run: npm test + + typecheck: + name: Typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + - run: npm ci + - run: npm run typecheck + + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + - run: npm ci + # Bundles the Worker exactly as a deploy would, without deploying. + - run: npm run build diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..afd670e --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,145 @@ +# Deploys the Worker to Cloudflare from GitHub Actions: on every push to +# `main` (the release merge from `staging`) and by hand. Never on pull +# requests, so no PR can reach the deploy credentials. +# +# The same test, typecheck and bundle checks as ci.yml run first; the deploy +# job only starts when they pass. It runs in the `production` GitHub +# Environment, which holds CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID. +# Neither value is ever printed. +# +# Until the owner has created the R2 bucket (and put its real name in +# wrangler.jsonc) a deploy fails at the bucket binding: that is intended. +# The maps.nyuchi.com route is NOT enabled in wrangler.jsonc and workers_dev +# is off, so a deployed Worker has no public URL until the route is turned +# on; the smoke test then needs SMOKE_TEST_URL and is skipped without it. + +name: Deploy + +on: + push: + branches: [main] + workflow_dispatch: + +# Never cancel a deploy that is already running. +concurrency: + group: deploy-production + cancel-in-progress: false + +permissions: + contents: read + +env: + # wrangler needs Node 22 or later. + NODE_VERSION: "24" + +jobs: + verify: + name: Verify + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + - run: npm ci + - run: npm test + - run: npm run typecheck + # Bundles the Worker exactly as a deploy would, without deploying. + - run: npm run build + + deploy: + name: Deploy + needs: verify + # A manual run from another branch only verifies; it never deploys. + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + environment: + name: production + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + - run: npm ci + + - name: Check Cloudflare credentials are set + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID || vars.CLOUDFLARE_ACCOUNT_ID }} + run: | + # Reports only whether each value exists, never the value. + missing=0 + if [ -z "$CLOUDFLARE_API_TOKEN" ]; then + echo "::error::CLOUDFLARE_API_TOKEN is not set in the production environment" + missing=1 + fi + if [ -z "$CLOUDFLARE_ACCOUNT_ID" ]; then + echo "::error::CLOUDFLARE_ACCOUNT_ID is not set (secret or variable) in the production environment" + missing=1 + fi + exit "$missing" + + - name: Deploy to Cloudflare + id: deploy + uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID || vars.CLOUDFLARE_ACCOUNT_ID }} + command: deploy + quiet: "true" + + - name: Smoke test + env: + # SMOKE_TEST_URL (a repository or environment variable), for example + # https://maps.nyuchi.com once the route is live, overrides the URL + # wrangler reports. + BASE_URL: ${{ vars.SMOKE_TEST_URL || steps.deploy.outputs.deployment-url }} + run: | + set -euo pipefail + if [ -z "$BASE_URL" ]; then + echo "::warning::No public URL to smoke test (route not enabled, workers_dev off). Set SMOKE_TEST_URL once the route is live." + exit 0 + fi + BASE_URL="${BASE_URL%/}" + echo "Smoke testing ${BASE_URL}/health" + + # A fresh deploy can take a moment to be served everywhere. + status=000 + for attempt in 1 2 3 4 5 6; do + status=$(curl --silent --show-error --max-time 15 \ + --dump-header headers.txt --output body.json \ + --write-out '%{http_code}' "${BASE_URL}/health" || echo 000) + [ "$status" = "200" ] && break + echo "Attempt ${attempt}: HTTP ${status}, retrying in 10s" + sleep 10 + done + + fail=0 + check() { + echo "::error::Smoke test failed: $1" + fail=1 + } + + [ "$status" = "200" ] || check "GET /health returned HTTP ${status}, expected 200" + + if ! grep -qiE '^content-type:[[:space:]]*application/json' headers.txt; then + check "content-type is not application/json" + fi + if ! grep -qiE '^cache-control:[[:space:]]*no-store' headers.txt; then + check "cache-control is not no-store" + fi + + # Exact body, so any unexpected extra field fails. + expected='{"status":"ok","service":"nyuchi-maps"}' + actual=$(tr -d '[:space:]' < body.json) + if [ "$actual" != "$expected" ]; then + check "body is not the expected health document" + fi + + exit "$fail" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..38ca0ae --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,36 @@ +# The org-wide lint gate: the identical caller from nyuchi/.github. Change +# nothing here. It publishes the five contexts the org ruleset requires: +# +# lint / actionlint +# lint / JSON validity +# lint / prettier +# lint / markdownlint +# lint / yamllint +# +# They come from the job being called `lint` and calling a reusable +# workflow (" / "). Do NOT convert this to a matrix. +# +# The reusable workflow is referenced by `@main` exactly as the org's +# canonical caller does (it is org-owned, not a third-party action); every +# third-party action in this repo is pinned to a commit SHA. + +name: Lint + +on: + pull_request: + push: + branches: [main, master, scaffold] + # Required for repos whose ruleset uses a merge queue. + merge_group: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + lint: + uses: nyuchi/.github/.github/workflows/reusable-lint.yml@main diff --git a/.github/workflows/staging-version.yml b/.github/workflows/staging-version.yml new file mode 100644 index 0000000..57c244e --- /dev/null +++ b/.github/workflows/staging-version.yml @@ -0,0 +1,27 @@ +# Versioning policy (nyuchi/.github#80): every merge into `staging` is +# released as the next PATCH, tagged on the merged commit. Inert until a +# `staging` branch exists. +name: Staging version + +on: + push: + branches: [staging] + workflow_dispatch: + inputs: + bump: + description: Override the bump (major is only ever manual). + type: choice + options: [patch, minor, major] + default: patch + +permissions: + contents: read + +jobs: + version: + if: github.ref == 'refs/heads/staging' + uses: nyuchi/.github/.github/workflows/reusable-staging-release.yml@924e5b4d0983d31379aedbec02b990793f29c792 # main, 2026-10-04 + with: + bump: ${{ inputs.bump || '' }} + permissions: + contents: write diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..569bb42 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +node_modules/ +.wrangler/ +.dev.vars* +!.dev.vars.example +dist/ diff --git a/.markdownlint.jsonc b/.markdownlint.jsonc new file mode 100644 index 0000000..1a3974c --- /dev/null +++ b/.markdownlint.jsonc @@ -0,0 +1,39 @@ +// markdownlint-cli2 config for nyuchi/.github. +// +// Relaxed defaults that accommodate: +// - Long prose lines in docs (we format for readability, not width) +// - Inline HTML in the org profile (
) +// - Files that start with an HTML comment or a
instead of an H1 +// - Fenced code blocks without a language tag +// +// See https://github.com/DavidAnson/markdownlint/blob/main/doc/Rules.md +{ + "default": true, + + // Line length — we wrap for readability, not at a fixed column. + "MD013": false, + + // Inline HTML — profile/README.md uses
for the + // landing-page layout and that's intentional. + "MD033": false, + + // First line must be a top-level heading — profile/README.md opens + // with an HTML comment, which is correct. + "MD041": false, + + // Fenced code blocks without a language — acceptable for output + // blocks and plain-text fences. + "MD040": false, + + // Duplicate headings are fine as long as they aren't siblings + // (e.g. "## Summary" appears in multiple docs; that's expected). + "MD024": { "siblings_only": true }, + + // Unordered list indentation — 2 spaces, matching our house style. + "MD007": { "indent": 2 }, + + // Table-column-style — re-enabled now that Prettier is in CI and + // auto-formats tables. Prettier's table output should satisfy + // MD060's "aligned" style. + "MD060": true, +} diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..876f80b --- /dev/null +++ b/.npmrc @@ -0,0 +1,7 @@ +# Needed: without it npm's resolver crashes ("Cannot read properties of null +# (reading 'edgesOut')") on the sharp/undici overrides in package.json, because +# @cloudflare/vitest-pool-workers declares peers it cannot satisfy exactly. +# Remove once that package's peer ranges and the overrides line up. +legacy-peer-deps=true +# Wait a week before resolving newly published versions (npm 11.10+). +min-release-age=7 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..b8d69cf --- /dev/null +++ b/.prettierignore @@ -0,0 +1,30 @@ +# Prettier ignore for nyuchi/.github. + +# Standard ignores +node_modules/ +.git/ + +# Files with structural meaning that Prettier would harm +LICENSE +CODEOWNERS +CODEOWNERS.example +.github/CODEOWNERS + +# YAML is handled by yamllint and actionlint, not Prettier — they have +# stronger guarantees about GitHub Actions semantics that Prettier does +# not understand (e.g. the `on:` truthy quirk). +*.yml +*.yaml + +# Governance documents are authored by the Founder as legal / formal +# documents. Prettier's markdown formatter reflows numbered paragraphs +# ("2.2 The company pursues...") after a list as loose list-item +# continuations, which corrupts the document's structure. Hand-format +# these files. +profile/governance/ + +# Lockfiles, generated output +*-lock.json +*-lock.yaml +package-lock.json +pnpm-lock.yaml diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..333987c --- /dev/null +++ b/.prettierrc @@ -0,0 +1,16 @@ +{ + "printWidth": 80, + "proseWrap": "preserve", + "tabWidth": 2, + "useTabs": false, + "endOfLine": "lf", + "trailingComma": "all", + "overrides": [ + { + "files": ["*.md", "*.mdx"], + "options": { + "embeddedLanguageFormatting": "off" + } + } + ] +} diff --git a/.yamllint.yaml b/.yamllint.yaml new file mode 100644 index 0000000..61cc7b6 --- /dev/null +++ b/.yamllint.yaml @@ -0,0 +1,82 @@ +# yamllint config for nyuchi/.github. +# +# Relaxed defaults that accommodate: +# - GitHub Actions workflows (which use `on:` — a YAML 1.1 boolean) +# - Long lines in shell heredocs and comment blocks inside workflows +# - Files without `---` document-start markers +# +# See https://yamllint.readthedocs.io/en/stable/configuration.html + +extends: default + +ignore: | + node_modules/ + # Lockfiles are generated, not authored. .prettierignore already + # exempts them under "Lockfiles, generated output"; yamllint not doing + # the same was an oversight that only stayed hidden because this repo + # has no lockfile. pnpm writes registry deprecation notices verbatim + # into pnpm-lock.yaml, which routinely exceeds the 140-char limit and + # cannot be rewrapped without invalidating the lockfile. + pnpm-lock.yaml + package-lock.json + yarn.lock + **/pnpm-lock.yaml + **/package-lock.json + **/yarn.lock + + # Rails fixtures are ERB TEMPLATES, not YAML documents. campfire's + # test/fixtures/users.yml opens with + # <% password_digest = BCrypt::Password.create("secret123456") %> + # and only becomes valid YAML after Rails renders it, so yamllint + # reports a hard syntax error and always will. The ActionText fixtures + # alongside them are HTML blobs that tests assert on byte-for-byte, so + # rewrapping their long lines would be a test change, not a formatting + # change. Seven files in that repo are ERB-templated YAML; the others + # only parse because their ERB happens to sit inside quoted scalars, + # which is luck rather than correctness. + test/fixtures/ + **/test/fixtures/ + +rules: + # 140-char limit. Workflow files have legitimately long shell URLs + # (the actionlint download script, action references) and issue- + # template `about:` strings render as a single line in the picker, + # so wrapping them would change user-visible behaviour. 140 is + # tight enough to flag truly excessive lines without forcing + # awkward breaks on URLs. Strict CI fails on any violation. + line-length: + max: 140 + level: error + allow-non-breakable-words: true + allow-non-breakable-inline-mappings: true + + # GitHub Actions uses `on:` as a key, which YAML 1.1 treats as a + # boolean. Disable the truthy check on keys to stop yamllint from + # rejecting every workflow. + truthy: + check-keys: false + level: error + + # We don't prefix YAML files with `---`. + document-start: disable + + # Be lenient about how many spaces follow `#` in comments. + comments: + min-spaces-from-content: 1 + + # Comments-indentation flags standalone comments that don't match the + # indent of surrounding content. Real-world configs use comments to + # delimit sections (long `# ----` separator banners) and to mark + # commented-out blocks at the level of the code they replace, neither + # of which fits the rule cleanly. Disabled. + comments-indentation: disable + + # GitHub issue forms have deeply nested lists and mappings. + indentation: + spaces: 2 + indent-sequences: consistent + + # Stops a warning on the trailing-empty-line check for files that + # end cleanly with a single newline. + empty-lines: + max-end: 1 diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..2e3831d --- /dev/null +++ b/NOTICE @@ -0,0 +1,65 @@ +Nyuchi Maps +Copyright 2026 Nyuchi Africa (PVT) Ltd + +This product is licensed under the Apache License, Version 2.0 (see LICENSE). +It includes or depends on the third-party material below. Each keeps its own +licence. Entries marked CONFIRM must be checked against the upstream licence +file at the pinned version before the first public release. + +------------------------------------------------------------------------ +Map data +------------------------------------------------------------------------ + +OpenStreetMap data + (c) OpenStreetMap contributors + Licence: Open Database Licence (ODbL) 1.0 + https://www.openstreetmap.org/copyright + Tile archives and maps built from OpenStreetMap are treated as a Produced + Work under the ODbL. Keep the credit "(c) OpenStreetMap contributors" + visible wherever a map is shown. Confirm with counsel before publishing a + tile file for download. + +Protomaps basemap builds (the PMTiles planet and regional extracts) + https://docs.protomaps.com/basemaps/downloads + OpenStreetMap-derived data: ODbL 1.0, as above. + Protomaps' download page names only OpenStreetMap attribution (an ODbL + Produced Work), so no Protomaps credit is listed. Re-check before release; + if the terms change add it to src/credits.ts (see the TODO there). + +------------------------------------------------------------------------ +Runtime dependencies (bundled into the Worker) +------------------------------------------------------------------------ + +pmtiles 4.5.0 (PMTiles v3 reader) + Copyright Protomaps LLC. Licence: BSD-3-Clause. + https://github.com/protomaps/PMTiles + +@protomaps/basemaps 5.7.2 (basemap style layers and flavors) + Copyright Protomaps LLC. Licence: BSD-3-Clause. + https://github.com/protomaps/basemaps + Our light and dark styles are generated from its layers and flavors. + +hono 4.x (HTTP router) + Copyright Yusuke Wada. Licence: MIT. + https://github.com/honojs/hono + +yaml 2.x (serves the OpenAPI document as JSON) + Copyright Eemeli Aro. Licence: ISC. + https://github.com/eemeli/yaml + +------------------------------------------------------------------------ +Assets stored in the R2 bucket (not in this repository) +------------------------------------------------------------------------ + +Glyph PBFs (fonts/) built from Noto Sans (Regular, Medium, Italic, +Devanagari Regular) + Copyright The Noto Project Authors. Licence: SIL Open Font License 1.1. + The full licence text is licenses/OFL-1.1-Noto-Sans.txt; upload a copy to + the bucket as fonts/OFL.txt so it travels with the PBFs (OFL condition 2). + Source of the prebuilt PBFs: github.com/protomaps/basemaps-assets. + CONFIRM that repository's own licence for the glyph build and verify each + family's copyright line (the Devanagari family has its own) when the assets + are imported. + +Sprites (sprites/) from protomaps/basemaps-assets + CONFIRM licence when the assets are imported. diff --git a/README.md b/README.md index 09b7867..c04fafc 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,148 @@ -# maps -Nyuchi Maps is Nyuchi infrastructure: it owns the base map (tiles, styles, fonts, icons and credits) and serves every Mukoko app and Nyuchi product, +# Nyuchi Maps + +Nyuchi Maps is Nyuchi infrastructure: the base map for every Mukoko app and +every Nyuchi product. It owns the tiles, styles, fonts, sprites and the credits +that go with them. It is not a weather feature; weather overlays (for example +the Tomorrow.io tiles served by `mukoko-weather-tiles`) are a separate product +and are out of scope here. + +It is a small TypeScript Cloudflare Worker over one R2 bucket. A single +[PMTiles](https://docs.protomaps.com/pmtiles/) archive (v3) holds the whole map; +the Worker answers tile requests with HTTP range reads against it. There is no +tile server to run. + +Host: `maps.nyuchi.com` (owner-confirmed). It is configuration, not code: +`PUBLIC_BASE_URL` in `wrangler.jsonc`. The route is commented out until the +owner provisions the DNS record. + +## Routes + +| Route | What it serves | +| ---------------------------------------------------------------- | --------------------------------------------------------------------- | +| `GET /health` | Liveness, answered at the edge | +| `GET /openapi.json` | The OpenAPI 3.1 contract ([`openapi/`](openapi/)) | +| `GET /v1/tiles.json` | TileJSON 3.0.0 for the archive, with the credits | +| `GET /v1/tiles/{z}/{x}/{y}.mvt` | One tile (the extension follows the archive's tile type) | +| `GET /v1/sources/{id}/tiles.json`, `.../tiles/{z}/{x}/{y}.{ext}` | Any further registered tile source (none ships yet; see architecture) | +| `GET /v1/styles/{light,dark}.json` | MapLibre styles that reference only our tiles, fonts, icons | +| `GET /v1/fonts/{stack}/{range}.pbf` | Glyph PBFs from the bucket | +| `GET /v1/sprites/{name}[@2x].{json,png}` | Sprite index and sheets from the bucket | + +Behaviour worth knowing: + +- Tiles are passed through still compressed (`Content-Encoding: gzip`, with + `encodeBody: "manual"` so the runtime does not gzip them again), with a long + edge cache (`s-maxage` 30 days), an `ETag` and `304` on `If-None-Match`. + `/v1/tiles.json` is TileJSON 3.0.0 including `vector_layers` from the archive. +- A tile the archive does not hold, inside its zoom range, is `204`. Outside the + zoom range, an unknown style, font, sprite or object is `404`. Bad + coordinates or names are `400` and never reach R2. +- Every path piece is validated against a strict pattern before it can form an + R2 key, so there is no path traversal. +- Per-IP rate limit on `/v1` through the `RATE_LIMITER` binding (1800 a minute; + it fails open). Tiles count only when they miss the edge cache, and a request + with no `CF-Connecting-IP` is not limited. `429` carries `Retry-After`. +- CORS is an allow-list from `ALLOWED_ORIGINS`; first-party apps use an + allow-listed origin. Every response carries `Vary: Origin`. `?lang=sn` on a style picks the label language. + +Use it from MapLibre by style URL: + +```ts +new maplibregl.Map({ + container: "map", + style: "https://maps.nyuchi.com/v1/styles/light.json", +}); +``` + +The credits come from the TileJSON that the style points at, so the attribution +control shows them once. Never write them by hand: they live in +[`src/credits.ts`](src/credits.ts) only. + +Keys for outside developers and enterprise customers will follow the Nyuchi +model (`nyk_` / `nys_`, verified through `api.nyuchi.com`). That is not built in +this first slice; see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). + +## Configuration + +| Name | Kind | What it is | +| ----------------- | ------- | ------------------------------------------------------------------------ | +| `MAPS_BUCKET` | binding | The R2 bucket (placeholder name in `wrangler.jsonc`, not created) | +| `RATE_LIMITER` | binding | Workers rate limiting, 600 requests a minute per client IP | +| `PUBLIC_BASE_URL` | var | Public origin for URLs in TileJSON and styles; `https://maps.nyuchi.com` | +| `ALLOWED_ORIGINS` | var | Comma-separated CORS origins; `https://*.example.com` matches subdomains | +| `TILES_VERSION` | var | Part of the edge cache key; bump and deploy when the archive is replaced | +| `TILES_KEY` | var | Object key of the archive, `tiles/nyuchi-maps.pmtiles` | + +There are no secrets. `workers_dev` and preview URLs are off, no route is +enabled and observability is on. `ALLOWED_ORIGINS` in `wrangler.jsonc` is the +production list only; for `wrangler dev` copy `.dev.vars.example` to `.dev.vars` +to add `http://localhost:3000`. Nothing in this repo creates a bucket, a DNS record or a deployment. + +Bucket layout: + +```text +tiles/nyuchi-maps.pmtiles +fonts/OFL.txt copy of licenses/OFL-1.1-Noto-Sans.txt +fonts/{font name}/{range}.pbf the stacks the styles use: + Noto Sans Regular, Noto Sans Medium, Noto Sans Italic, + Noto Sans Devanagari Regular v1 +sprites/{light,dark}[@2x].{json,png} +``` + +To produce a small regional archive (Zimbabwe plus a few cities) see +[`scripts/build-extract.md`](scripts/build-extract.md). + +## Development + +```sh +npm ci +npm test # Workers Vitest suite, stubbed R2 and rate limiter +npm run typecheck +npm run build # wrangler deploy --dry-run: bundles, deploys nothing +npm run dev # wrangler dev (needs a local R2 archive) +``` + +Tests include `test/encoding.test.ts`, which runs the real Worker entry in +workerd. To check double compression by hand against a local archive (put one in +the simulated bucket with +`npx wrangler r2 object put nyuchi-maps-placeholder/tiles/nyuchi-maps.pmtiles --file x.pmtiles --local`): + +```sh +npx wrangler dev +curl -s -H 'Accept-Encoding: gzip' --raw http://localhost:8787/v1/tiles/0/0/0.mvt -o t.gz +gzip -t t.gz # exits 0: gzip exactly once +gzip -dc t.gz | gzip -t # must FAIL ("not in gzip format"): the tile is not gzipped twice +``` + +Tests run inside workerd with +[`@cloudflare/vitest-pool-workers`](https://developers.cloudflare.com/workers/testing/vitest-integration/). +They build small PMTiles v3 archives in memory, so they cover the real +directory format without any map data. `.npmrc` sets `legacy-peer-deps`. + +## CI and releases + +- `ci.yml`: tests, typecheck and a build dry run. Actions are pinned to commit + SHAs. +- `lint.yml`: the org lint gate (actionlint, JSON validity, prettier, + markdownlint, yamllint), the identical caller from `nyuchi/.github`. +- `staging-version.yml`: tags each merge to `staging` as the next patch. Inert + until a `staging` branch exists. + +`main` requires a pull request (squash or rebase, linear history, resolved +threads) and the five lint checks. Releases follow the same convention as +`mukoko-api`: `staging` into `main` by squash-merge. + +## Licences + +- **Code** (the Worker, tests, scripts): Apache-2.0, see [LICENSE](LICENSE). +- **Map data**: the underlying data is OpenStreetMap, under the Open Database + Licence (ODbL) 1.0. We treat the tile files and the maps rendered from them as + a Produced Work: credit **"© OpenStreetMap contributors"** wherever a map is + shown (the TileJSON carries it). Protomaps' own downloads name only that + attribution. Confirm the classification of the tile archive itself with + counsel before publishing it for download. +- **Third-party styles, fonts and sprites** keep their upstream licences, listed + in [NOTICE](NOTICE). + +This is not legal advice. Confirm anything contractual with the Bundu +Foundation's counsel. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..3d0c5b2 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,134 @@ +# Architecture + +Nyuchi Maps is platform infrastructure. One Worker, one R2 bucket, no tile +server and no database. + +```text +browser / app Cloudflare edge Worker (Hono) R2 (one bucket) +MapLibre ──GET──► CORS (allow-list, Vary: Origin) + ├─ /v1 non-tile routes: rate limit, validate, serve ──► fonts/… sprites/… + └─ /v1 tiles: validate ─► Cache API (key: path + TILES_VERSION) + hit ─► respond (no limiter, no R2) + miss ─► rate limit ─► pmtiles reader ──range──► tiles/nyuchi-maps.pmtiles +``` + +## Request path + +1. **CORS** (`src/cors.ts`): `ALLOWED_ORIGINS` is an exact or `https://*.host` + allow-list. No `Origin` header (servers) passes; an unlisted browser origin + gets no `Access-Control-Allow-Origin`, and its preflight is `403`. +2. **Rate limit** (`src/ratelimit.ts`), keyed by `CF-Connecting-IP`, 1800 a + minute. It fails open so a limiter fault cannot take the map down. Non-tile + `/v1` routes are limited up front; tile requests only on an edge-cache miss, + so cache hits (and many users behind one carrier-grade NAT address) do not + spend the budget. A request with no `CF-Connecting-IP` (never behind + Cloudflare) is not limited, rather than sharing one bucket. The namespace id + is 7402; 1001 is the Mukoko API's IP limiter and ids are unique per account. +3. **Validation** (`src/validate.ts`): every path piece is matched against a + strict pattern first (digits without leading zeros, `[a-z][a-z0-9-]*` names, + a bounded font alphabet). Only validated values ever form an R2 key, so there + is no path traversal. +4. **Tiles** (`src/archive.ts`): the `pmtiles` library reads the v3 header and + directories through an R2 `Source`; every read is an R2 range read, so the + archive is never downloaded whole. The header and directories are cached per + isolate in a `ResolvedValueCache` (keyed by the archive ETag): it stores only + values that resolved, so a missing archive or a transient R2 error is never + cached (the next request retries; `SharedPromiseCache` would keep the + rejection and poison the isolate, and cannot share promises across Workers + requests anyway). A replaced archive surfaces as an ETag mismatch, which the + library retries. A matching `If-None-Match` is answered from the cached + header, before the directory or the tile is read. +5. **Response**: tile bytes are passed through still compressed with + `Content-Encoding: gzip` **and `encodeBody: "manual"`** (`responseInit` in + `src/http.ts`): the runtime's default is to compress such a body again, which + clients cannot parse. Every place that builds or rebuilds a tile response + uses it: the origin response, the CORS rebuild, the cache-hit path, and the + copy written to the Cache API (a `res.clone()` would lose the flag). + `test/encoding.test.ts` runs the real Worker entry to prove a single gzip + layer. A strong `ETag` (`"://"`), + `Cache-Control: public, max-age=86400, s-maxage=2592000, +stale-while-revalidate=86400`, and `304` on `If-None-Match`. Successful + tiles are also stored in the Cache API under a key of origin + path + + `TILES_VERSION` only (a query string cannot bypass it; bumping the var + retires every tile of a replaced archive). CORS headers are applied after the + cache so one cached object serves every allowed origin. + +Status codes: a tile absent from the archive but inside its zoom range is `204` +(cached for an hour); outside the zoom range, or a wrong extension, `404` +(cached for minutes; error responses keep a Cache-Control the handler set and +default to `no-store`); bad coordinates `400`; archive missing from the bucket +`503 archive_unavailable` (`no-store`). + +## Tile sources + +A source is one PMTiles archive in the bucket, registered in `src/sources.ts` +(`SOURCES`). Today there is one, `basemap`, at the short URLs +(`/v1/tiles.json`, `/v1/tiles/{z}/{x}/{y}.mvt`) and also at +`/v1/sources/basemap/...`. Any further source is served at +`/v1/sources/{id}/tiles.json` and `/v1/sources/{id}/tiles/{z}/{x}/{y}.{ext}` +by the same handlers: same validation, caching, ETag, CORS and rate limit. +Each source has its own object key, its own TileJSON (with its own credit ids, +so an overlay shows only the credits that apply to it), and its own header and +directory cache entries. + +This is how a Kweli places overlay would arrive later: a second archive +(`tiles/kweli-places.pmtiles`), one entry in `SOURCES`, one var for its key, and +a style layer that adds it as a second vector source. That overlay is not built +here, and the registry ships with the base map only. `createApp(sources)` takes +the registry as a parameter, which is how the tests exercise a second source +without registering one. + +## Styles, fonts, sprites and credits + +- **Styles** (`src/styles.ts`) are generated from `@protomaps/basemaps` layers, + flavors `light` and `dark`. The vector source carries a `url` to our TileJSON + and nothing else; `glyphs` and `sprite` point at our own `/v1/fonts` and + `/v1/sprites`. No third-party host appears in a style (a test enforces it). + Each style is built once per (name, base URL, language) and memoised. + Restyling with Mzizi tokens means replacing the flavor objects, not the + plumbing. +- **Fonts and sprites** are stored objects in the same bucket, served as is. A + font stack lists fallbacks; the first one the bucket holds wins. +- **Credits** live in `src/credits.ts` and nowhere else. They reach a map through + the TileJSON `attribution` (MapLibre shows a source's attribution once); + `vector_layers` come from the archive's own metadata block; styles + deliberately add none, so a credit cannot appear twice. A test checks that no + credit is listed or rendered twice and that styles carry none. + +## Configuration, not constants + +The host is `PUBLIC_BASE_URL` (`https://maps.nyuchi.com`, owner-confirmed). +TileJSON and style URLs are built from it; if it is unset or invalid the +Worker falls back to the request origin. The route is commented out in +`wrangler.jsonc`, so nothing is served until the owner provisions the DNS record +and enables it. + +## Access model + +- **First-party apps** (Mukoko apps, Nyuchi products): an allow-listed origin + (CORS) and the per-IP limit. This is what is built. +- **Outside developers and enterprise**: keys in the Nyuchi model (`nyk_` / + `nys_`), verified through `api.nyuchi.com`, with per-key limits. Not built in + this slice; it would add a key check in front of `/v1/*` (header or query key) + and move the rate-limit key from IP to key id. + +## Data and updates + +The archive is a Protomaps build (OpenStreetMap, ODbL). The first slice is a +regional extract (see `scripts/build-extract.md`); planet versus regional is the +owner's decision. Updates are manual for now: rebuild, upload over the same +key, bump `TILES_VERSION` and deploy (see `scripts/build-extract.md`). A +scheduled refresh (a cron Worker) is a later step. + +## Out of scope + +The Tomorrow.io overlay Worker `mukoko-weather-tiles` is a weather product and +stays where it is. Satellite basemaps are not provided here. + +## Not verified yet + +This slice is tested against small in-memory PMTiles v3 archives and a stubbed +R2 binding. It has not been run against real Protomaps data, a real bucket or +the Workers runtime in production: real-archive directory depth, R2 range-read +latency and the rendered styles in MapLibre still need checking once the owner +creates the bucket. diff --git a/licenses/OFL-1.1-Noto-Sans.txt b/licenses/OFL-1.1-Noto-Sans.txt new file mode 100644 index 0000000..db175dc --- /dev/null +++ b/licenses/OFL-1.1-Noto-Sans.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml new file mode 100644 index 0000000..70916ae --- /dev/null +++ b/openapi/openapi.yaml @@ -0,0 +1,344 @@ +openapi: 3.1.0 +info: + title: Nyuchi Maps + version: 0.0.1 + summary: The Nyuchi platform base map. + description: | + Vector tiles, styles, glyphs and sprites for every Mukoko app and Nyuchi + product. Tiles are read from a single PMTiles archive (PMTiles v3) in R2 + with HTTP range reads. Map data is derived from OpenStreetMap and is + available under the ODbL as a Produced Work; credits are carried by the + TileJSON and must be displayed by every map. Every `/v1` response carries + `Vary: Origin`; CORS is an allow-list of first-party origins. + license: + name: Apache-2.0 + identifier: Apache-2.0 +servers: + - url: https://maps.nyuchi.com + description: Production host (not live until the owner enables the route). +tags: + - name: Tiles + - name: Styles + - name: Assets + - name: Operations +paths: + /health: + get: + tags: [Operations] + operationId: getHealth + summary: Liveness check, answered at the edge. + responses: + "200": + description: The Worker is running. + content: + application/json: + schema: + type: object + required: [status, service] + properties: + status: {type: string, const: ok} + service: {type: string, const: nyuchi-maps} + /openapi.json: + get: + tags: [Operations] + operationId: getOpenApi + summary: This document, as JSON. + responses: + "200": + description: The OpenAPI 3.1 document. + content: + application/json: + schema: {type: object} + /v1/tiles.json: + get: + tags: [Tiles] + operationId: getTileJson + summary: TileJSON 3.0.0 for the archive, including the credits. + responses: + "200": + description: TileJSON. + headers: + ETag: {$ref: "#/components/headers/ETag"} + Cache-Control: {$ref: "#/components/headers/CacheControl"} + content: + application/json: + schema: {$ref: "#/components/schemas/TileJSON"} + "304": {$ref: "#/components/responses/NotModified"} + "429": {$ref: "#/components/responses/RateLimited"} + "503": {$ref: "#/components/responses/ArchiveUnavailable"} + /v1/tiles/{z}/{x}/{y}.{ext}: + get: + tags: [Tiles] + operationId: getTile + summary: One tile from the archive. + description: | + `ext` must match the archive's tile type (`mvt` for the Protomaps + basemap). The body is passed through still compressed and carries + `Content-Encoding: gzip` when the archive stores gzip tiles. + A tile the archive does not hold, inside its zoom range, is `204`. + parameters: + - {name: z, in: path, required: true, schema: {type: integer, minimum: 0, maximum: 22}} + - {name: x, in: path, required: true, schema: {type: integer, minimum: 0}} + - {name: y, in: path, required: true, schema: {type: integer, minimum: 0}} + - name: ext + in: path + required: true + schema: {type: string, enum: [mvt, png, jpg, webp, avif, mlt]} + responses: + "200": + description: The tile. + headers: + ETag: {$ref: "#/components/headers/ETag"} + Cache-Control: {$ref: "#/components/headers/CacheControl"} + Content-Encoding: {schema: {type: string, enum: [gzip]}} + content: + application/vnd.mapbox-vector-tile: + schema: {type: string, format: binary} + application/vnd.maplibre-vector-tile: + schema: {type: string, format: binary} + image/png: + schema: {type: string, format: binary} + image/jpeg: + schema: {type: string, format: binary} + image/webp: + schema: {type: string, format: binary} + image/avif: + schema: {type: string, format: binary} + "204": + description: No tile here (inside the archive's zoom range). + "304": {$ref: "#/components/responses/NotModified"} + "400": {$ref: "#/components/responses/BadRequest"} + "404": {$ref: "#/components/responses/NotFound"} + "429": {$ref: "#/components/responses/RateLimited"} + "503": {$ref: "#/components/responses/ArchiveUnavailable"} + /v1/sources/{source}/tiles.json: + get: + tags: [Tiles] + operationId: getSourceTileJson + summary: TileJSON for a registered tile source other than the base map. + description: | + The Worker serves several PMTiles archives, each a registered source. + The base map is the default source and also answers at `/v1/tiles.json`. + parameters: + - $ref: "#/components/parameters/Source" + responses: + "200": + description: TileJSON. + content: + application/json: + schema: {$ref: "#/components/schemas/TileJSON"} + "304": {$ref: "#/components/responses/NotModified"} + "404": {$ref: "#/components/responses/NotFound"} + "429": {$ref: "#/components/responses/RateLimited"} + "503": {$ref: "#/components/responses/ArchiveUnavailable"} + /v1/sources/{source}/tiles/{z}/{x}/{y}.{ext}: + get: + tags: [Tiles] + operationId: getSourceTile + summary: One tile from a registered tile source. + parameters: + - $ref: "#/components/parameters/Source" + - {name: z, in: path, required: true, schema: {type: integer, minimum: 0, maximum: 22}} + - {name: x, in: path, required: true, schema: {type: integer, minimum: 0}} + - {name: y, in: path, required: true, schema: {type: integer, minimum: 0}} + - name: ext + in: path + required: true + schema: {type: string, enum: [mvt, png, jpg, webp, avif, mlt]} + responses: + "200": + description: The tile (same headers and encoding as the base map tiles). + content: + application/vnd.mapbox-vector-tile: + schema: {type: string, format: binary} + application/vnd.maplibre-vector-tile: + schema: {type: string, format: binary} + image/png: + schema: {type: string, format: binary} + image/jpeg: + schema: {type: string, format: binary} + image/webp: + schema: {type: string, format: binary} + image/avif: + schema: {type: string, format: binary} + "204": + description: No tile here (inside the archive's zoom range). + "304": {$ref: "#/components/responses/NotModified"} + "400": {$ref: "#/components/responses/BadRequest"} + "404": {$ref: "#/components/responses/NotFound"} + "429": {$ref: "#/components/responses/RateLimited"} + "503": {$ref: "#/components/responses/ArchiveUnavailable"} + /v1/styles/{name}.json: + get: + tags: [Styles] + operationId: getStyle + summary: A MapLibre style that references only this service. + parameters: + - name: name + in: path + required: true + schema: {type: string, enum: [light, dark]} + - name: lang + in: query + required: false + description: Label language, an ISO 639 code. Default `en`. + schema: {type: string, pattern: "^[a-z]{2,3}$", default: en} + responses: + "200": + description: Style JSON (MapLibre / Mapbox style spec, version 8). + headers: + ETag: {$ref: "#/components/headers/ETag"} + content: + application/json: + schema: {type: object} + "304": {$ref: "#/components/responses/NotModified"} + "400": {$ref: "#/components/responses/BadRequest"} + "404": {$ref: "#/components/responses/NotFound"} + "429": {$ref: "#/components/responses/RateLimited"} + /v1/fonts/{stack}/{range}.pbf: + get: + tags: [Assets] + operationId: getGlyphs + summary: Glyph PBF for a font stack and Unicode range. + description: | + `stack` is one to four comma-separated font names; the first one the + bucket holds is served. + parameters: + - name: stack + in: path + required: true + schema: {type: string, examples: ["Noto Sans Regular"]} + - name: range + in: path + required: true + schema: {type: string, pattern: "^\\d{1,5}-\\d{1,5}$", examples: ["0-255"]} + responses: + "200": + description: Glyph data. + content: + application/x-protobuf: + schema: {type: string, format: binary} + "304": {$ref: "#/components/responses/NotModified"} + "400": {$ref: "#/components/responses/BadRequest"} + "404": {$ref: "#/components/responses/NotFound"} + "429": {$ref: "#/components/responses/RateLimited"} + /v1/sprites/{name}.{ext}: + get: + tags: [Assets] + operationId: getSprite + summary: Sprite sheet or index. Use `{name}@2x.{ext}` for high-DPI. + parameters: + - name: name + in: path + required: true + description: Sprite name, for example `light` or `dark`; append `@2x` for the high-DPI sheet. + schema: {type: string, pattern: "^[a-z][a-z0-9-]{0,31}(@2x)?$"} + - name: ext + in: path + required: true + schema: {type: string, enum: [json, png]} + responses: + "200": + description: The sprite index (json) or image (png). + content: + application/json: + schema: {type: object} + image/png: + schema: {type: string, format: binary} + "304": {$ref: "#/components/responses/NotModified"} + "400": {$ref: "#/components/responses/BadRequest"} + "404": {$ref: "#/components/responses/NotFound"} + "429": {$ref: "#/components/responses/RateLimited"} +components: + parameters: + Source: + name: source + in: path + required: true + description: A registered tile source id (see src/sources.ts). + schema: {type: string, pattern: "^[a-z][a-z0-9-]{0,31}$", examples: [basemap]} + headers: + ETag: + description: Strong validator; send it back as `If-None-Match`. + schema: {type: string} + CacheControl: + description: Edge and browser caching policy. + schema: {type: string} + schemas: + Error: + type: object + required: [error] + properties: + error: {type: string} + TileJSON: + description: TileJSON 3.0.0. + type: object + required: [tilejson, tiles, attribution] + properties: + tilejson: {type: string, const: 3.0.0} + name: {type: string} + scheme: {type: string, const: xyz} + tiles: + type: array + items: {type: string, format: uri-template} + attribution: + type: string + description: HTML credits. Maps must display this text. + vector_layers: + type: array + description: From the archive's metadata (empty when it has none). + items: {type: object} + minzoom: {type: integer} + maxzoom: {type: integer} + bounds: + type: array + items: {type: number} + minItems: 4 + maxItems: 4 + center: + type: array + items: {type: number} + minItems: 3 + maxItems: 3 + example: + tilejson: 3.0.0 + name: Nyuchi Maps + scheme: xyz + tiles: ["https://maps.nyuchi.com/v1/tiles/{z}/{x}/{y}.mvt"] + attribution: >- + Nyuchi Maps | + © OpenStreetMap contributors + minzoom: 0 + maxzoom: 14 + bounds: [25.2, -22.45, 33.1, -15.55] + center: [31.05, -17.83, 8] + responses: + NotModified: + description: The cached copy is current. + BadRequest: + description: A path or query value failed validation. + content: + application/json: + schema: {$ref: "#/components/schemas/Error"} + NotFound: + description: | + Unknown path, style, source, font or sprite; a tile outside the + archive's zoom range or with an extension that does not match the + archive's tile type; or an object that is not in the bucket. + content: + application/json: + schema: {$ref: "#/components/schemas/Error"} + RateLimited: + description: | + Too many requests from this IP. Retry after `Retry-After` seconds. + Tiles are counted only when they miss the edge cache. + headers: + Retry-After: {schema: {type: integer}} + content: + application/json: + schema: {$ref: "#/components/schemas/Error"} + ArchiveUnavailable: + description: The PMTiles archive is not in the bucket. + content: + application/json: + schema: {$ref: "#/components/schemas/Error"} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1eafc04 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3484 @@ +{ + "name": "nyuchi-maps", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nyuchi-maps", + "version": "0.0.1", + "license": "Apache-2.0", + "dependencies": { + "@protomaps/basemaps": "5.7.2", + "hono": "^4.13.12", + "pmtiles": "4.5.0", + "yaml": "^2.9.1" + }, + "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.22.0", + "@cloudflare/workers-types": "^5.20261002.1", + "prettier": "^3.9.9", + "typescript": "^5.9.3", + "vitest": "~4.1.11", + "wrangler": "^4.147.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.2.tgz", + "integrity": "sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/vitest-pool-workers": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@cloudflare/vitest-pool-workers/-/vitest-pool-workers-0.22.0.tgz", + "integrity": "sha512-OJv/qikkOgxnKxJ5xrLS7zuOLZhc/6iziU+llqZm4tiQf2CJUYwlMuXN68VaWIQebORd1AUx4w6A0oy8XRbuaQ==", + "deprecated": "@cloudflare/vitest-pool-workers has been renamed to @cloudflare/vitest-plugin. This package will not receive future updates.", + "dev": true, + "license": "MIT", + "dependencies": { + "cjs-module-lexer": "1.2.3", + "esbuild": "0.28.1", + "miniflare": "5.20260815.0-alpha", + "wrangler": "4.124.0", + "zod": "4.4.3" + }, + "peerDependencies": { + "@vitest/runner": "^4.1.0", + "@vitest/snapshot": "^4.1.0", + "vitest": "^4.1.0" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/wrangler": { + "version": "4.124.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.124.0.tgz", + "integrity": "sha512-75euoZKjVTJYFy+Xhctt/5JlZL4M6A4xmovZsUlep+6GHcCm14n9VtdGzNIybOW2t8wuNxRj5iMUwjT5E7Ctog==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "5.20260815.0-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260815.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260815.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260815.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260815.1.tgz", + "integrity": "sha512-7PsLdcz6pT9EMd1EJGZEgMyYRfs0CHxGs62PS2L1w3s6+xGmQcRXKm/zoMftmqZF45JBa4MzFeownRKbRt/x5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260815.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260815.1.tgz", + "integrity": "sha512-60wtg8ng7FVWeOg/UMbZ9Ye0sslpRRAKoftPbdtuH2volq676quxVr6Zm2EjVULH/JFZeCn72dbLlrnbh0Mpcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260815.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260815.1.tgz", + "integrity": "sha512-MuqKIHPo0Qyo8MZMmy0lP2B5PeAL7f4T9Fu4Usk3QdbV4JIrKG/OoybN3Ign7m/Dff+L1Oo/ZHydB+hEg1ueFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260815.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260815.1.tgz", + "integrity": "sha512-XNFtJ5rIqJxnY6ISjkfbhT/ODiWJ6LcBvNbntuPD6I/F2k7aZeKgPaXrvWvKde66LXyzFKzc8Hn+Ydx4shevQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260815.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260815.1.tgz", + "integrity": "sha512-PiIUWrhbMg3quolwjgMvPOd75vKESjT4aDm7nL6mSjL5IOgmpO/zKstXnYfnEH3pq7sC0UCvKlF8ZPcfsh8NMw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "5.20261011.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20261011.1.tgz", + "integrity": "sha512-6dgFMnwq6Wn238QWGHHb93gqIXjbSQMALcurXmI+9SHkM0QeEdHDhbyxwnE8v1m41MUzCtNO/gnTRwI4FsS9fQ==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@protomaps/basemaps": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/@protomaps/basemaps/-/basemaps-5.7.2.tgz", + "integrity": "sha512-K1Yk6bWdULulYg+R2QRVXx4NzJZan5YQhpejEG0c1/sXruJrfPIPZuakpf3jwAgVmjIRVQwAv+yRafDeN0aaUQ==", + "license": "BSD-3-Clause", + "bin": { + "generate_style": "src/cli.ts" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", + "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/hono": { + "version": "4.13.13", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz", + "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/miniflare": { + "version": "5.20260815.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260815.0-alpha.tgz", + "integrity": "sha512-YAaGj4Sh5f4fqHKiMQ8zRHDOOM5IGUVtMhnLIeyjuQfU+9P6hcOTrHUVtbfj/ZPay9Kzik4pWELB39pGgefjiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.2", + "undici": "7.29.0", + "workerd": "1.20260815.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pmtiles": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-4.5.0.tgz", + "integrity": "sha512-CBeD4SoUluFziGdy/8k7FOjQxQy486n+929W/tWophauvMICpkZ26vGBWhDt/6b1FZQWNaf4jFdT/5+q0Wii5w==", + "license": "BSD-3-Clause", + "dependencies": { + "fflate": "^0.8.2" + } + }, + "node_modules/postcss": { + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.19", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", + "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/rolldown": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.153.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/workerd": { + "version": "1.20260815.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260815.1.tgz", + "integrity": "sha512-8bArFkHmlp7qFEKVPyNzDzHzS35gc2fg0PYBcDtaNLF7UCDryCX2BQnpkUkTHYIy824IRrHOTwOEoTj0sUO2Fg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260815.1", + "@cloudflare/workerd-darwin-arm64": "1.20260815.1", + "@cloudflare/workerd-linux-64": "1.20260815.1", + "@cloudflare/workerd-linux-arm64": "1.20260815.1", + "@cloudflare/workerd-windows-64": "1.20260815.1" + } + }, + "node_modules/wrangler": { + "version": "4.149.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.149.0.tgz", + "integrity": "sha512-OzK7xmB5r5iLKb3cIT3783g13fe6T7xyeKu9LEdUyl+DKAM2KP/jkjnE0OUhyw+mB/XJM9ycjgF3MWm8PR9amg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.2", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.2", + "miniflare": "5.20261006.1-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20261006.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20261006.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20261006.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20261006.1.tgz", + "integrity": "sha512-lCX6avJO2It9AyDEYitjmOtJRVp/xmaW0TziyoLAnfxlzozyyRbSFfIIMMCiDx/sTP1++E4VLbV+7BHSD33f0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20261006.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20261006.1.tgz", + "integrity": "sha512-PyFGjOaldtppnLuKsdkoYWXfLVpDjuf8u+uoykX+1Xvxo2EPmCVViq0iDKm+U7c7PRno85ehS3/MPVXyk2HwHw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20261006.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20261006.1.tgz", + "integrity": "sha512-Ntf9S8eesmVufdjaT2kzWlz6Ng9XGYTc6cHQVRKAg59lFufwsIabGeMT6U4KkrpEeSIxgHq8aPtuLbZXE19tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20261006.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20261006.1.tgz", + "integrity": "sha512-JyeDBymVWw5tVRebSsVE8ojDYxd6UlZrAseHU8G+NpIyd5wF61tXlF/axLeCUqu0pOhBFHb8I5QKi0jwrCK41Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20261006.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20261006.1.tgz", + "integrity": "sha512-nWP0URNqOBN9fa/gZ7eh/w051DwjOXN8j4lDZDYVXyQPsux6sV3DfyYZT+CjR/rt9nKBSmJT9QE9/bWT18+84w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/wrangler/node_modules/miniflare": { + "version": "5.20261006.1-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20261006.1-alpha.tgz", + "integrity": "sha512-6ylv8tamkrspDI2R/cAYfv+YWmfGNdXe76Pi9TauEUWsFVXn3LNF9Yk8oduzf1a+TYsSRYTqlOE7+nGGRsMHdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.5", + "undici": "7.29.1", + "workerd": "1.20261006.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/wrangler/node_modules/undici": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/wrangler/node_modules/workerd": { + "version": "1.20261006.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20261006.1.tgz", + "integrity": "sha512-8MadBuffZ5NdpLwZjUmsqT5QlaKXq4gjgT6wzcKeynZTICNXyePhc+5QVoKiK7+MMhFTLNrRO2KXLlpk8Q6BkA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20261006.1", + "@cloudflare/workerd-darwin-arm64": "1.20261006.1", + "@cloudflare/workerd-linux-64": "1.20261006.1", + "@cloudflare/workerd-linux-arm64": "1.20261006.1", + "@cloudflare/workerd-windows-64": "1.20261006.1" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..dfd359b --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "nyuchi-maps", + "version": "0.0.1", + "private": true, + "description": "Nyuchi Maps: the Nyuchi platform base-map service, a Cloudflare Worker serving PMTiles from R2", + "license": "Apache-2.0", + "type": "module", + "scripts": { + "dev": "wrangler dev", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "build": "wrangler deploy --dry-run --outdir dist", + "format": "prettier --write .", + "format:check": "prettier --check ." + }, + "dependencies": { + "@protomaps/basemaps": "5.7.2", + "hono": "^4.13.12", + "pmtiles": "4.5.0", + "yaml": "^2.9.1" + }, + "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.22.0", + "@cloudflare/workers-types": "^5.20261002.1", + "prettier": "^3.9.9", + "typescript": "^5.9.3", + "vitest": "~4.1.11", + "wrangler": "^4.147.0" + }, + "overrides": { + "sharp@<0.35.4": "^0.35.4", + "undici@<7.29.1": "^7.29.1", + "sharp": "^0.35.5" + } +} diff --git a/scripts/build-extract.md b/scripts/build-extract.md new file mode 100644 index 0000000..0d77641 --- /dev/null +++ b/scripts/build-extract.md @@ -0,0 +1,99 @@ +# Building a regional PMTiles extract + +This documents how to produce the small archive for the first slice: Zimbabwe +plus a few cities. **It is documentation only. Nothing here is run by CI, and +nothing in this repo runs it.** An extract reads only the byte ranges it needs +from the remote Protomaps build, so it downloads a few tens of MB, not the +planet. Do not download a full planet file for this. + +Run it on a machine with disk and network of your choosing, not in CI. + +## 1. Get the `pmtiles` CLI + +Download a release binary from +(Apache-2.0 / BSD; check its licence file) and put it on your `PATH`. +`pmtiles version` should answer. + +## 2. Pick a build + +Protomaps publishes daily planet builds. Look at the current list at + and note the file name, for example +`20261001.pmtiles`. The source URL has the form +`https://build.protomaps.com/.pmtiles`. Confirm the URL on that page +before you rely on it. + +## 3. Extract the regions + +[`extract-regions.geojson`](extract-regions.geojson) holds four boxes: Zimbabwe +(`25.2,-22.45,33.1,-15.55`) and small boxes around Lusaka, Nairobi and +Singapore. Edit it to change the set. + +```sh +BUILD=20261001 # a date from the builds page + +pmtiles extract "https://build.protomaps.com/${BUILD}.pmtiles" nyuchi-maps.pmtiles \ + --region=scripts/extract-regions.geojson \ + --maxzoom=14 +``` + +- `--maxzoom=14` keeps the archive small. Dropping it keeps the source's + maximum zoom (15) and grows the file. +- Add `--dry-run` first: it prints the number of tiles and the bytes it would + download, and downloads nothing. +- For a single box, `--bbox=25.2,-22.45,33.1,-15.55` replaces `--region`. + +## 4. Check it + +```sh +pmtiles show nyuchi-maps.pmtiles # header: type mvt, tile compression gzip, zooms, bounds +pmtiles verify nyuchi-maps.pmtiles +``` + +The Worker expects a PMTiles v3 archive of MVT tiles with gzip tile compression +(what Protomaps builds use). It passes the tile bytes through compressed, so +brotli or zstd archives are refused with a 500. + +## 5. Fonts and sprites + +The styles use the `Noto Sans Regular`, `Noto Sans Medium`, +`Noto Sans Italic` and `Noto Sans Devanagari Regular v1` font stacks and the +`light` and `dark` sprites. Take the +prebuilt glyph PBFs and sprites from + (check their licence, see +`NOTICE`) and lay them out as: + +```text +fonts/OFL.txt (copy of licenses/OFL-1.1-Noto-Sans.txt) +fonts/Noto Sans Regular/0-255.pbf ... +fonts/Noto Sans Medium/... fonts/Noto Sans Italic/... +fonts/Noto Sans Devanagari Regular v1/... +sprites/light.json sprites/light.png sprites/light@2x.json sprites/light@2x.png +sprites/dark.json sprites/dark.png sprites/dark@2x.json sprites/dark@2x.png +``` + +## 6. Upload (owner only) + +The bucket does not exist yet; creating it, and the cost, need the owner's +approval. Once it exists, with its real name in place of ``: + +```sh +npx wrangler r2 object put "/tiles/nyuchi-maps.pmtiles" \ + --file nyuchi-maps.pmtiles --content-type application/octet-stream --remote +``` + +Do the same for each font and sprite object. Large files (over about 300 MB) +need an S3-compatible client such as `rclone` instead of `wrangler`. + +## Refreshing + +Edge-cached tiles live for up to 30 days, so replacing the object alone leaves +stale tiles in the cache. A refresh has three mandatory steps: + +1. Rebuild from a newer `BUILD` date and upload over the same key. +2. Bump `TILES_VERSION` in `wrangler.jsonc` (for example `"2"`) and deploy. It + is part of the edge cache key, so every tile cached from the old archive is + retired at once. +3. Check `GET /v1/tiles.json` and a few tiles. + +The Worker also notices the replaced object by ETag on its next range read, but +that does not clear the Cache API; `TILES_VERSION` does. diff --git a/scripts/extract-regions.geojson b/scripts/extract-regions.geojson new file mode 100644 index 0000000..dbd3eb8 --- /dev/null +++ b/scripts/extract-regions.geojson @@ -0,0 +1,70 @@ +{ + "type": "FeatureCollection", + "name": "nyuchi-maps-first-slice", + "features": [ + { + "type": "Feature", + "properties": { "name": "Zimbabwe" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [25.2, -22.45], + [33.1, -22.45], + [33.1, -15.55], + [25.2, -15.55], + [25.2, -22.45] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Lusaka" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [28.1, -15.6], + [28.5, -15.6], + [28.5, -15.3], + [28.1, -15.3], + [28.1, -15.6] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Nairobi" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [36.6, -1.45], + [37.1, -1.45], + [37.1, -1.15], + [36.6, -1.15], + [36.6, -1.45] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Singapore" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [103.6, 1.15], + [104.1, 1.15], + [104.1, 1.5], + [103.6, 1.5], + [103.6, 1.15] + ] + ] + } + } + ] +} diff --git a/src/archive.ts b/src/archive.ts new file mode 100644 index 0000000..0a8c08f --- /dev/null +++ b/src/archive.ts @@ -0,0 +1,193 @@ +import { + Compression, + EtagMismatch, + PMTiles, + ResolvedValueCache, + TileType, + type Header, + type RangeResponse, + type Source, +} from "pmtiles"; +import type { Env } from "./types"; + +export const DEFAULT_TILES_KEY = "tiles/nyuchi-maps.pmtiles"; + +/** The archive object is absent from the bucket. */ +export class ArchiveMissingError extends Error { + constructor(key: string) { + super(`archive not found: ${key}`); + } +} + +/** The archive uses a tile compression the edge cannot pass through. */ +export class UnsupportedCompressionError extends Error {} + +/** PMTiles Source over an R2 binding: every read is an R2 range read. */ +class R2Source implements Source { + constructor( + private readonly bucket: R2Bucket, + private readonly key: string, + ) {} + + getKey(): string { + return this.key; + } + + async getBytes( + offset: number, + length: number, + _signal?: AbortSignal, + etag?: string, + ): Promise { + const object = await this.bucket.get(this.key, { + range: { offset, length }, + onlyIf: etag ? { etagMatches: etag } : undefined, + }); + if (!object) throw new ArchiveMissingError(this.key); + // R2 answers a failed precondition with metadata and no body: the + // archive was replaced between two reads. + if (!("body" in object) || !object.body) throw new EtagMismatch(); + return { data: await object.arrayBuffer(), etag: object.etag }; + } +} + +/** + * Directory and header caches live per bucket binding, per isolate. + * + * ResolvedValueCache, not SharedPromiseCache, on purpose: it stores only + * values that resolved. SharedPromiseCache stores the promise before it + * settles and never drops a rejection, so one missing archive or one + * transient R2 error would poison every later request in the isolate; it + * also shares promises between requests, which Workers cannot do (I/O + * belongs to the request that started it). Here a failure caches nothing and + * the next request simply tries again. The key includes the archive ETag, so + * a replaced archive is picked up on the next ETag mismatch. + */ +const archiveCaches = new WeakMap(); + +/** Tile bytes are passed through still compressed (see tileEncoding). */ +const passThrough = async (buf: ArrayBuffer): Promise => buf; + +function cacheFor(env: Env): ResolvedValueCache { + let cache = archiveCaches.get(env.MAPS_BUCKET); + if (!cache) { + cache = new ResolvedValueCache(200); + archiveCaches.set(env.MAPS_BUCKET, cache); + } + return cache; +} + +export function openArchive(env: Env, key: string): PMTiles { + const source = new R2Source(env.MAPS_BUCKET, key); + // The cache decompresses directories itself; this decompress only touches + // tile bytes, which the client can inflate. + return new PMTiles(source, cacheFor(env), passThrough); +} + +async function gunzip(bytes: ArrayBuffer): Promise { + const stream = new Blob([bytes]) + .stream() + .pipeThrough(new DecompressionStream("gzip")); + return new Response(stream).arrayBuffer(); +} + +/** + * Parse the archive's JSON metadata block. Only none (1) and gzip (2) are + * handled; anything else yields null rather than an error, because metadata + * only enriches the TileJSON. + */ +export async function decodeMetadata( + bytes: ArrayBuffer, + compression: Compression, +): Promise | null> { + try { + let raw = bytes; + if (compression === Compression.Gzip) raw = await gunzip(bytes); + else if ( + compression !== Compression.None && + compression !== Compression.Unknown + ) { + return null; + } + const parsed: unknown = JSON.parse(new TextDecoder().decode(raw)); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } +} + +const metadataCache = new WeakMap< + R2Bucket, + Map | null> +>(); + +/** `vector_layers` from the archive metadata, cached per archive ETag. */ +export async function vectorLayers( + env: Env, + key: string, + header: Header, +): Promise { + let perBucket = metadataCache.get(env.MAPS_BUCKET); + if (!perBucket) { + perBucket = new Map(); + metadataCache.set(env.MAPS_BUCKET, perBucket); + } + const cacheKey = `${key}|${header.etag ?? ""}`; + let meta = perBucket.get(cacheKey); + if (meta === undefined) { + const source = new R2Source(env.MAPS_BUCKET, key); + const { data } = await source.getBytes( + header.jsonMetadataOffset, + header.jsonMetadataLength, + undefined, + header.etag, + ); + meta = await decodeMetadata(data, header.internalCompression); + if (perBucket.size > 16) perBucket.clear(); + perBucket.set(cacheKey, meta); + } + const layers = meta?.vector_layers; + return Array.isArray(layers) ? layers : null; +} + +const EXT: Partial> = { + [TileType.Mvt]: "mvt", + [TileType.Png]: "png", + [TileType.Jpeg]: "jpg", + [TileType.Webp]: "webp", + [TileType.Avif]: "avif", + [TileType.Mlt]: "mlt", +}; + +const CONTENT_TYPE: Record = { + mvt: "application/vnd.mapbox-vector-tile", + mlt: "application/vnd.maplibre-vector-tile", + png: "image/png", + jpg: "image/jpeg", + webp: "image/webp", + avif: "image/avif", +}; + +export function tileExt(header: Header): string | undefined { + return EXT[header.tileType]; +} + +export function tileContentType(ext: string): string { + return CONTENT_TYPE[ext] ?? "application/octet-stream"; +} + +/** Content-Encoding for the stored tile bytes; throws when unsupported. */ +export function tileEncoding(header: Header): "gzip" | null { + if (header.tileCompression === Compression.Gzip) return "gzip"; + if ( + header.tileCompression === Compression.None || + header.tileCompression === Compression.Unknown + ) { + return null; + } + throw new UnsupportedCompressionError( + `tile compression ${header.tileCompression} is not supported`, + ); +} diff --git a/src/cors.ts b/src/cors.ts new file mode 100644 index 0000000..1dd10fd --- /dev/null +++ b/src/cors.ts @@ -0,0 +1,78 @@ +import type { MiddlewareHandler } from "hono"; +import { jsonError, rebuild } from "./http"; +import type { AppEnv } from "./types"; + +/** Split the ALLOWED_ORIGINS var into trimmed, non-empty entries. */ +export function parseAllowedOrigins(value: string | undefined): string[] { + return (value ?? "") + .split(",") + .map((s) => s.trim().replace(/\/+$/, "")) + .filter(Boolean); +} + +/** + * Exact match, or "https://*.example.com" to match any one-or-more-label + * subdomain (never the bare apex, never a different suffix). + */ +export function isOriginAllowed(origin: string, allowed: string[]): boolean { + for (const entry of allowed) { + if (entry === origin) return true; + const star = entry.indexOf("*."); + if (star > 0 && entry.slice(0, star).endsWith("//")) { + const scheme = entry.slice(0, star); + const suffix = entry.slice(star + 1); // ".example.com" + if ( + origin.startsWith(scheme) && + origin.endsWith(suffix) && + origin.length > scheme.length + suffix.length && + !origin.slice(scheme.length, -suffix.length).includes("/") + ) { + return true; + } + } + } + return false; +} + +const ALLOW_METHODS = "GET, HEAD, OPTIONS"; +const EXPOSE = "ETag, Content-Length, Content-Encoding"; + +/** + * CORS allow-list. Requests without an Origin header (servers, curl) are + * not browsers and pass untouched. A browser origin outside the list gets + * no Access-Control-Allow-Origin, so the browser refuses the response; its + * preflight is answered 403. + */ +export const cors: MiddlewareHandler = async (c, next) => { + const origin = c.req.header("Origin"); + const allowed = parseAllowedOrigins(c.env.ALLOWED_ORIGINS); + const ok = origin !== undefined && isOriginAllowed(origin, allowed); + + if (c.req.method === "OPTIONS") { + if (!ok) return jsonError(403, "origin_not_allowed", { Vary: "Origin" }); + return new Response(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": ALLOW_METHODS, + "Access-Control-Allow-Headers": + c.req.header("Access-Control-Request-Headers") ?? "", + "Access-Control-Max-Age": "86400", + Vary: "Origin", + }, + }); + } + + await next(); + + // Rebuild the response: ones from the Cache API have immutable headers. + // rebuild() keeps encodeBody "manual" for already-compressed tiles. + const res = rebuild(c.res); + // Always vary on Origin: the same URL answers differently per origin. + res.headers.append("Vary", "Origin"); + if (origin !== undefined && ok) { + res.headers.set("Access-Control-Allow-Origin", origin); + res.headers.set("Access-Control-Expose-Headers", EXPOSE); + } + c.res = res; +}; diff --git a/src/credits.ts b/src/credits.ts new file mode 100644 index 0000000..4c2eb34 --- /dev/null +++ b/src/credits.ts @@ -0,0 +1,64 @@ +/** + * The ONE place attribution strings live. + * + * The TileJSON (`/v1/tiles.json`) is the only thing that carries them to a + * map: the styles reference the tiles by `url` and add no attribution of + * their own, so MapLibre shows each credit exactly once. Never write a + * credit string anywhere else; add it here and `test/credits.test.ts` + * checks it appears once. + */ + +export interface Credit { + /** Stable id, unique in the list. */ + readonly id: string; + /** The text shown to people, unique in the list. */ + readonly text: string; + /** Where the credit links to. */ + readonly url: string; +} + +export const CREDITS: readonly Credit[] = [ + { + id: "nyuchi-maps", + text: "Nyuchi Maps", + url: "https://maps.nyuchi.com", + }, + { + // Required by the ODbL for anything derived from OpenStreetMap. + id: "osm", + text: "© OpenStreetMap contributors", + url: "https://www.openstreetmap.org/copyright", + }, + // TODO(protomaps): add a Protomaps credit here ONLY if its terms ask for + // one. As recorded when this was written, the Protomaps basemap downloads + // name only OpenStreetMap attribution (the tiles are an ODbL Produced + // Work), so no Protomaps credit is listed. Re-check + // https://docs.protomaps.com/basemaps/downloads before the first public + // release and whenever the build source changes. +]; + +function escapeHtml(text: string): string { + return text + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function select(ids?: readonly string[]): readonly Credit[] { + return ids ? CREDITS.filter((c) => ids.includes(c.id)) : CREDITS; +} + +/** HTML for TileJSON / MapLibre attribution controls (all credits by default). */ +export function attributionHtml(ids?: readonly string[]): string { + return select(ids) + .map((c) => `${escapeHtml(c.text)}`) + .join(" | "); +} + +/** Plain text, for logs and non-HTML consumers. */ +export function attributionText(ids?: readonly string[]): string { + return select(ids) + .map((c) => c.text) + .join(" | "); +} diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..70659d5 --- /dev/null +++ b/src/http.ts @@ -0,0 +1,114 @@ +import type { Context } from "hono"; +import type { AppEnv, Env } from "./types"; + +/** Tiles are immutable between archive refreshes: cache for a long time. */ +export const CACHE_TILE = + "public, max-age=86400, s-maxage=2592000, stale-while-revalidate=86400"; +/** A tile the archive does not hold; short so a refresh shows up soon. */ +export const CACHE_EMPTY = "public, max-age=3600, s-maxage=86400"; +/** Out of range or unknown: cache briefly, never forever. */ +export const CACHE_MISS = "public, max-age=300, s-maxage=3600"; +/** Styles, TileJSON, sprites, fonts: change on our releases only. */ +export const CACHE_ASSET = + "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400"; +export const NO_STORE = "no-store"; + +export function baseUrl(env: Env, requestUrl: string): string { + const configured = env.PUBLIC_BASE_URL?.trim().replace(/\/+$/, ""); + if (configured) { + try { + const u = new URL(configured); + const local = u.hostname === "localhost" || u.hostname === "127.0.0.1"; + if ((u.protocol === "https:" || local) && u.pathname === "/") { + return u.origin; + } + } catch { + // fall through to the request origin + } + } + return new URL(requestUrl).origin; +} + +/** + * Response init that tells the Workers runtime NOT to compress a body that + * already carries a Content-Encoding. The default (encodeBody "automatic") + * would gzip a second time and the client could not parse the result. + * Every place that builds or rebuilds a response around stored-compressed + * bytes must go through this. + */ +export function responseInit( + status: number, + headers: Headers, + statusText?: string, +): ResponseInit { + const init: ResponseInit = { status, headers }; + if (statusText) init.statusText = statusText; + if (headers.has("Content-Encoding")) init.encodeBody = "manual"; + return init; +} + +/** Copy a response (for example from the Cache API) with mutable headers. */ +export function rebuild(res: Response): Response { + const headers = new Headers(res.headers); + return new Response( + res.body, + responseInit(res.status, headers, res.statusText), + ); +} + +/** JSON error. Cache-Control defaults to no-store unless the caller sets one. */ +export function jsonError( + status: number, + error: string, + extra?: HeadersInit, +): Response { + const headers = new Headers(extra); + headers.set("Content-Type", "application/json; charset=utf-8"); + if (!headers.has("Cache-Control")) headers.set("Cache-Control", NO_STORE); + return new Response(JSON.stringify({ error }), { status, headers }); +} + +/** True when an If-None-Match header lists (or wildcards) this ETag. */ +export function etagMatches( + header: string | null | undefined, + etag: string, +): boolean { + if (!header) return false; + if (header.trim() === "*") return true; + const bare = etag.replace(/^W\//, ""); + return header + .split(",") + .map((t) => t.trim().replace(/^W\//, "")) + .includes(bare); +} + +async function sha256Hex(text: string): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(text), + ); + return [...new Uint8Array(digest)] + .slice(0, 12) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +/** JSON response with a content-hash ETag and 304 handling. */ +export async function jsonResponse( + c: Context, + body: unknown, + cacheControl: string, + contentType = "application/json; charset=utf-8", +): Promise { + const text = JSON.stringify(body); + const etag = `"${await sha256Hex(text)}"`; + const headers = new Headers({ + "Content-Type": contentType, + "Cache-Control": cacheControl, + ETag: etag, + }); + if (etagMatches(c.req.header("If-None-Match"), etag)) { + return new Response(null, { status: 304, headers }); + } + return new Response(text, { status: 200, headers }); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..c7e34dc --- /dev/null +++ b/src/index.ts @@ -0,0 +1,303 @@ +import { Hono, type Context } from "hono"; +import { parse as parseYaml } from "yaml"; +import openapiYaml from "../openapi/openapi.yaml"; +import { + ArchiveMissingError, + UnsupportedCompressionError, + openArchive, + vectorLayers, + tileContentType, + tileEncoding, + tileExt, +} from "./archive"; +import { attributionHtml } from "./credits"; +import { cors } from "./cors"; +import { + CACHE_ASSET, + CACHE_EMPTY, + CACHE_MISS, + CACHE_TILE, + NO_STORE, + baseUrl, + etagMatches, + jsonError, + jsonResponse, + rebuild, + responseInit, +} from "./http"; +import { checkRateLimit, rateLimit } from "./ratelimit"; +import { + DEFAULT_SOURCE_ID, + SOURCES, + sourceKey, + type SourceDef, +} from "./sources"; +import { buildStyle } from "./styles"; +import type { AppEnv } from "./types"; +import { + isName, + parseFontRange, + parseFontStack, + parseLang, + parseSpriteFile, + parseStyleFile, + parseTile, +} from "./validate"; + +export function createApp( + sources: Readonly> = SOURCES, +) { + const app = new Hono(); + + app.onError((err, c) => { + if (err instanceof ArchiveMissingError) { + console.error(err.message); + return jsonError(503, "archive_unavailable"); + } + if (err instanceof UnsupportedCompressionError) { + console.error(err.message); + return jsonError(500, "unsupported_tile_compression"); + } + console.error("unhandled error", c.req.path, err); + return jsonError(500, "internal_error"); + }); + + app.notFound(() => jsonError(404, "not_found")); + + app.use("*", cors); + + app.get("/health", () => + Response.json( + { status: "ok", service: "nyuchi-maps" }, + { headers: { "Cache-Control": NO_STORE } }, + ), + ); + + let openapiJson: unknown; + app.get("/openapi.json", (c) => { + openapiJson ??= parseYaml(openapiYaml as string); + return jsonResponse(c, openapiJson, CACHE_ASSET); + }); + + app.use("/v1/*", rateLimit); + + type Ctx = Context; + + /** TileJSON 3.0.0 for one source, carrying its credits. */ + async function tileJson(c: Ctx, def: SourceDef): Promise { + const key = sourceKey(c.env, def); + const header = await openArchive(c.env, key).getHeader(); + const ext = tileExt(header); + if (!ext) return jsonError(500, "unsupported_tile_type"); + // Required by TileJSON 3.0.0 for vector tiles; read from the archive's + // own metadata block. + const layers = + ext === "mvt" ? await vectorLayers(c.env, key, header) : null; + const base = baseUrl(c.env, c.req.url); + const prefix = + def.id === DEFAULT_SOURCE_ID + ? `${base}/v1/tiles` + : `${base}/v1/sources/${def.id}/tiles`; + return jsonResponse( + c, + { + tilejson: "3.0.0", + name: + def.id === DEFAULT_SOURCE_ID + ? "Nyuchi Maps" + : `Nyuchi Maps ${def.id}`, + scheme: "xyz", + tiles: [`${prefix}/{z}/{x}/{y}.${ext}`], + ...(ext === "mvt" ? { vector_layers: layers ?? [] } : {}), + attribution: attributionHtml(def.creditIds), + minzoom: header.minZoom, + maxzoom: header.maxZoom, + bounds: [header.minLon, header.minLat, header.maxLon, header.maxLat], + center: [header.centerLon, header.centerLat, header.centerZoom], + }, + CACHE_ASSET, + ); + } + + async function tile(c: Ctx, def: SourceDef): Promise { + const req = parseTile( + c.req.param("z") ?? "", + c.req.param("x") ?? "", + c.req.param("file") ?? "", + ); + if (!req) return jsonError(400, "invalid_tile_coordinates"); + + const edge = c.env.EDGE_CACHE === "off" ? null : caches.default; + // Key on origin + path + the archive version only: a query string must + // not bypass the cache, and bumping TILES_VERSION retires every cached + // tile of a replaced archive. + const url = new URL(c.req.url); + const cacheKey = new Request( + `${url.origin}${url.pathname}?v=${encodeURIComponent(c.env.TILES_VERSION ?? "")}`, + ); + if (edge) { + const hit = await edge.match(cacheKey); + if (hit) { + const etag = hit.headers.get("ETag"); + if (etag && etagMatches(c.req.header("If-None-Match"), etag)) { + return new Response(null, { status: 304, headers: hit.headers }); + } + return rebuild(hit); + } + } + + // Only a miss costs the origin anything, so only a miss is limited. + const limited = await checkRateLimit(c); + if (limited) return limited; + + const archive = openArchive(c.env, sourceKey(c.env, def)); + const header = await archive.getHeader(); + const ext = tileExt(header); + if (ext !== req.ext) { + return jsonError(404, "format_mismatch", { "Cache-Control": CACHE_MISS }); + } + if (req.z < header.minZoom || req.z > header.maxZoom) { + return jsonError(404, "zoom_out_of_range", { + "Cache-Control": CACHE_MISS, + }); + } + + const encoding = tileEncoding(header); + const headers = new Headers({ + "Content-Type": tileContentType(ext), + "Cache-Control": CACHE_TILE, + }); + if (encoding) headers.set("Content-Encoding", encoding); + if (header.etag) { + headers.set("ETag", `"${header.etag}:${req.z}/${req.x}/${req.y}"`); + } + // The ETag is derived from the cached header, so a matching validator is + // answered before the directory or the tile is read from R2. + const etag = headers.get("ETag"); + if (etag && etagMatches(c.req.header("If-None-Match"), etag)) { + return new Response(null, { status: 304, headers }); + } + + const found = await archive.getZxy(req.z, req.x, req.y, c.req.raw.signal); + if (!found) { + // Inside the archive's zoom range but no data here (ocean, empty cell). + return new Response(null, { + status: 204, + headers: { "Cache-Control": CACHE_EMPTY }, + }); + } + + const res = new Response(found.data, responseInit(200, headers)); + if (edge) { + try { + // Not res.clone(): a clone does not keep encodeBody "manual", so the + // Cache API would gzip the already-gzipped bytes a second time. + const copy = new Response( + found.data, + responseInit(200, new Headers(headers)), + ); + c.executionCtx.waitUntil(edge.put(cacheKey, copy)); + } catch { + // No execution context (tests): skip the write. + } + } + return res; + } + + function lookup( + sources: Readonly>, + id: string, + ): SourceDef | null { + return isName(id) && Object.hasOwn(sources, id) ? sources[id]! : null; + } + + // Base map: the default source, at the short URLs. + app.get("/v1/tiles.json", (c) => tileJson(c, sources[DEFAULT_SOURCE_ID]!)); + app.get("/v1/tiles/:z/:x/:file", (c) => tile(c, sources[DEFAULT_SOURCE_ID]!)); + + // Any registered source (a places overlay later), under its id. + app.get("/v1/sources/:id/tiles.json", (c) => { + const def = lookup(sources, c.req.param("id")); + return def ? tileJson(c, def) : jsonError(404, "source_not_found"); + }); + app.get("/v1/sources/:id/tiles/:z/:x/:file", (c) => { + const def = lookup(sources, c.req.param("id")); + return def ? tile(c, def) : jsonError(404, "source_not_found"); + }); + + // Styles are pure functions of (name, base URL, lang): build each once. + const styleMemo = new Map>(); + app.get("/v1/styles/:file", async (c) => { + const name = parseStyleFile(c.req.param("file")); + const lang = parseLang(c.req.query("lang")); + if (!lang) return jsonError(400, "invalid_lang"); + const base = baseUrl(c.env, c.req.url); + const memoKey = `${name}|${base}|${lang}`; + let style = name ? styleMemo.get(memoKey) : undefined; + if (!style && name) { + style = buildStyle(name, base, lang) ?? undefined; + if (style) { + if (styleMemo.size >= 64) styleMemo.clear(); + styleMemo.set(memoKey, style); + } + } + if (!style) return jsonError(404, "style_not_found"); + return jsonResponse(c, style, CACHE_ASSET); + }); + + /** + * Stream an R2 object. R2 evaluates If-None-Match itself (onlyIf), so a + * matching validator never transfers the body. + */ + async function serveObject( + c: Ctx, + key: string, + contentType: string, + ): Promise { + const object = await c.env.MAPS_BUCKET.get(key, { + onlyIf: c.req.raw.headers, + }); + if (!object) { + return jsonError(404, "not_found", { "Cache-Control": CACHE_MISS }); + } + const headers = new Headers({ + "Content-Type": contentType, + "Cache-Control": CACHE_ASSET, + ETag: object.httpEtag, + }); + if (!("body" in object) || !object.body) { + return new Response(null, { status: 304, headers }); + } + return new Response(object.body, { status: 200, headers }); + } + + app.get("/v1/fonts/:stack/:file", async (c) => { + const names = parseFontStack(c.req.param("stack")); + const range = parseFontRange(c.req.param("file")); + if (!names || !range) return jsonError(400, "invalid_font_request"); + // A stack lists fallbacks: the first font we hold wins. + for (const name of names) { + const res = await serveObject( + c, + `fonts/${name}/${range}.pbf`, + "application/x-protobuf", + ); + if (res.status !== 404) return res; + } + return jsonError(404, "not_found", { "Cache-Control": CACHE_MISS }); + }); + + app.get("/v1/sprites/:file", async (c) => { + const sprite = parseSpriteFile(c.req.param("file")); + if (!sprite) return jsonError(400, "invalid_sprite_request"); + return serveObject( + c, + `sprites/${sprite.name}${sprite.retina ? "@2x" : ""}.${sprite.ext}`, + sprite.ext === "json" ? "application/json" : "image/png", + ); + }); + + return app; +} + +export default createApp(); diff --git a/src/ratelimit.ts b/src/ratelimit.ts new file mode 100644 index 0000000..ed55a08 --- /dev/null +++ b/src/ratelimit.ts @@ -0,0 +1,48 @@ +import type { Context, MiddlewareHandler } from "hono"; +import { jsonError } from "./http"; +import type { AppEnv } from "./types"; + +/** + * Per-IP limit through the Workers rate limiting binding (RATE_LIMITER). + * + * - It fails open: a missing binding or a limiter error must never take the + * map down. + * - A request with no CF-Connecting-IP (never the case behind Cloudflare; + * local dev and tests only) has nothing to key on, so it is not limited + * rather than sharing one global "unknown" bucket that could throttle + * everyone at once. + * - Tile requests are limited only when they miss the edge cache (see + * tilesAreLimitedOnMiss below): cache hits cost the origin nothing, and + * many users behind one carrier-grade NAT address must not exhaust the + * limit just by loading cached tiles. + */ +export async function checkRateLimit( + c: Context, +): Promise { + const limiter = c.env.RATE_LIMITER; + const ip = c.req.header("CF-Connecting-IP"); + if (!limiter || !ip) return null; + try { + const { success } = await limiter.limit({ key: ip }); + if (!success) + return jsonError(429, "rate_limited", { "Retry-After": "60" }); + } catch (err) { + console.error("rate limiter failed, allowing request", err); + } + return null; +} + +/** Tile routes do their own check, after the edge cache lookup. */ +const TILE_PATH = /^\/v1\/(sources\/[^/]+\/)?tiles\/[^/]+\/[^/]+\/[^/]+$/; + +export const tilesAreLimitedOnMiss = (path: string): boolean => + TILE_PATH.test(path); + +/** Limit every /v1 route except tiles, which limit themselves on a miss. */ +export const rateLimit: MiddlewareHandler = async (c, next) => { + if (!tilesAreLimitedOnMiss(c.req.path)) { + const limited = await checkRateLimit(c); + if (limited) return limited; + } + await next(); +}; diff --git a/src/sources.ts b/src/sources.ts new file mode 100644 index 0000000..734c9d7 --- /dev/null +++ b/src/sources.ts @@ -0,0 +1,33 @@ +import { DEFAULT_TILES_KEY } from "./archive"; +import type { Env } from "./types"; + +/** + * A tile source is one PMTiles archive in the bucket. The Worker is built + * for several: the base map is the only one today; a places overlay (for + * example Kweli) would be a second archive, a second entry here and a second + * var, with no change to the request path. + */ +export interface SourceDef { + /** URL-safe id, used in `/v1/sources/{id}/...`. */ + readonly id: string; + /** Name of the var holding this archive's object key (optional override). */ + readonly keyVar?: "TILES_KEY"; + /** Key used when there is no var, or it is unset. */ + readonly defaultKey: string; + /** Ids from src/credits.ts shown for this source; all credits when unset. */ + readonly creditIds?: readonly string[]; +} + +export const DEFAULT_SOURCE_ID = "basemap"; + +export const SOURCES: Readonly> = { + [DEFAULT_SOURCE_ID]: { + id: DEFAULT_SOURCE_ID, + keyVar: "TILES_KEY", + defaultKey: DEFAULT_TILES_KEY, + }, +}; + +export function sourceKey(env: Env, def: SourceDef): string { + return (def.keyVar && env[def.keyVar]) || def.defaultKey; +} diff --git a/src/styles.ts b/src/styles.ts new file mode 100644 index 0000000..9950c19 --- /dev/null +++ b/src/styles.ts @@ -0,0 +1,35 @@ +import { layers, namedFlavor } from "@protomaps/basemaps"; + +/** Style names this service publishes, and the Protomaps flavor behind each. */ +export const STYLES: Readonly> = { + light: "light", + dark: "dark", +}; + +export const STYLE_NAMES = Object.keys(STYLES); + +/** + * Build a MapLibre style that references OUR tiles, glyphs and sprite only. + * + * The vector source carries `url` and nothing else: attribution reaches the + * map from the TileJSON, so the credits (src/credits.ts) show exactly once. + * No third-party host appears anywhere in the output. + */ +export function buildStyle( + name: string, + base: string, + lang: string, +): Record | null { + const flavor = STYLES[name]; + if (!flavor) return null; + return { + version: 8, + name: `Nyuchi basemap ${name}`, + sources: { + protomaps: { type: "vector", url: `${base}/v1/tiles.json` }, + }, + glyphs: `${base}/v1/fonts/{fontstack}/{range}.pbf`, + sprite: `${base}/v1/sprites/${name}`, + layers: layers("protomaps", namedFlavor(flavor), { lang }), + }; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..4cc176d --- /dev/null +++ b/src/types.ts @@ -0,0 +1,18 @@ +export interface Env { + /** R2 bucket holding the PMTiles archive, glyphs and sprites. */ + MAPS_BUCKET: R2Bucket; + /** Workers rate limiting binding, keyed by client IP. */ + RATE_LIMITER?: RateLimit; + /** Public origin for URLs in TileJSON and styles (no trailing slash). */ + PUBLIC_BASE_URL?: string; + /** Comma-separated first-party origins allowed by CORS. */ + ALLOWED_ORIGINS?: string; + /** Object key of the PMTiles archive in the bucket. */ + TILES_KEY?: string; + /** Bump when the archive is replaced: part of the edge cache key. */ + TILES_VERSION?: string; + /** Set to "off" to skip the Cache API (tests). */ + EDGE_CACHE?: string; +} + +export type AppEnv = { Bindings: Env }; diff --git a/src/validate.ts b/src/validate.ts new file mode 100644 index 0000000..80e3003 --- /dev/null +++ b/src/validate.ts @@ -0,0 +1,80 @@ +/** + * Input validation. Every path piece that reaches R2 or the PMTiles reader + * is matched against a strict pattern first, so nothing user-supplied can + * form a different object key (no "..", no "/", no encoded separators). + */ + +export const MAX_ZOOM = 22; + +const INT = /^(0|[1-9]\d{0,7})$/; +const TILE_FILE = /^(0|[1-9]\d{0,7})\.(mvt|png|jpg|webp|avif|mlt)$/; +const NAME = /^[a-z][a-z0-9-]{0,31}$/; +const SPRITE_FILE = /^([a-z][a-z0-9-]{0,31})(@2x)?\.(json|png)$/; +const STYLE_FILE = /^([a-z][a-z0-9-]{0,31})\.json$/; +const FONT_NAME = /^[A-Za-z0-9][A-Za-z0-9 _-]{0,63}$/; +const FONT_RANGE = /^(\d{1,5})-(\d{1,5})\.pbf$/; +const LANG = /^[a-z]{2,3}$/; + +export interface TileRequest { + z: number; + x: number; + y: number; + ext: string; +} + +/** Parse z / x / "y.ext". Returns null when anything is out of range. */ +export function parseTile( + z: string, + x: string, + file: string, +): TileRequest | null { + const f = TILE_FILE.exec(file); + if (!f || !INT.test(z) || !INT.test(x)) return null; + const zn = Number(z); + const xn = Number(x); + const yn = Number(f[1]); + if (zn > MAX_ZOOM) return null; + const side = 2 ** zn; + if (xn >= side || yn >= side) return null; + return { z: zn, x: xn, y: yn, ext: f[2]! }; +} + +export function parseStyleFile(file: string): string | null { + const m = STYLE_FILE.exec(file); + return m ? m[1]! : null; +} + +export function isName(name: string): boolean { + return NAME.test(name); +} + +export interface SpriteRequest { + name: string; + retina: boolean; + ext: "json" | "png"; +} + +export function parseSpriteFile(file: string): SpriteRequest | null { + const m = SPRITE_FILE.exec(file); + if (!m) return null; + return { name: m[1]!, retina: m[2] === "@2x", ext: m[3] as "json" | "png" }; +} + +/** A font stack is 1 to 4 comma-separated font names. */ +export function parseFontStack(stack: string): string[] | null { + const names = stack.split(","); + if (names.length < 1 || names.length > 4) return null; + return names.every((n) => FONT_NAME.test(n)) ? names : null; +} + +/** "0-255.pbf" -> "0-255". Start must not exceed end. */ +export function parseFontRange(file: string): string | null { + const m = FONT_RANGE.exec(file); + if (!m) return null; + return Number(m[1]) <= Number(m[2]) ? `${m[1]}-${m[2]}` : null; +} + +export function parseLang(value: string | undefined): string | null { + if (value === undefined || value === "") return "en"; + return LANG.test(value) ? value : null; +} diff --git a/src/yaml.d.ts b/src/yaml.d.ts new file mode 100644 index 0000000..4bc1894 --- /dev/null +++ b/src/yaml.d.ts @@ -0,0 +1,4 @@ +declare module "*.yaml" { + const content: string; + export default content; +} diff --git a/test/assets.test.ts b/test/assets.test.ts new file mode 100644 index 0000000..074529c --- /dev/null +++ b/test/assets.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { StubBucket, call, makeEnv } from "./helpers"; + +const GLYPHS = new Uint8Array([10, 20, 30]); +const PNG = new Uint8Array([137, 80, 78, 71]); + +function bucket() { + return new StubBucket() + .put("fonts/Noto Sans Regular/0-255.pbf", GLYPHS, "font1") + .put("fonts/Noto Sans Medium/0-255.pbf", new Uint8Array([9]), "font2") + .put("sprites/light.json", '{"icon":{}}', "spr1") + .put("sprites/light@2x.png", PNG, "spr2"); +} + +describe("GET /v1/fonts/{stack}/{range}.pbf", () => { + it("serves glyphs with a long cache and an ETag", async () => { + const res = await call( + "/v1/fonts/Noto%20Sans%20Regular/0-255.pbf", + makeEnv(bucket()), + ); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("application/x-protobuf"); + expect(res.headers.get("Cache-Control")).toContain("s-maxage=86400"); + expect(res.headers.get("ETag")).toBe('"font1"'); + expect(new Uint8Array(await res.arrayBuffer())).toEqual(GLYPHS); + }); + + it("falls back along the stack to the first font it holds", async () => { + const res = await call( + "/v1/fonts/Missing%20Font,Noto%20Sans%20Medium/0-255.pbf", + makeEnv(bucket()), + ); + expect(res.status).toBe(200); + expect(res.headers.get("ETag")).toBe('"font2"'); + }); + + it("answers 304 for a matching ETag", async () => { + const res = await call( + "/v1/fonts/Noto%20Sans%20Regular/0-255.pbf", + makeEnv(bucket()), + { headers: { "If-None-Match": '"font1"' } }, + ); + expect(res.status).toBe(304); + expect(res.headers.get("ETag")).toBe('"font1"'); + expect(await res.text()).toBe(""); + }); + + it("404s a font or range it does not hold", async () => { + const env = makeEnv(bucket()); + const nope = await call("/v1/fonts/Nope/0-255.pbf", env); + expect(nope.status).toBe(404); + expect(nope.headers.get("Cache-Control")).toBe( + "public, max-age=300, s-maxage=3600", + ); + expect( + (await call("/v1/fonts/Noto%20Sans%20Regular/256-511.pbf", env)).status, + ).toBe(404); + }); + + it.each([ + "/v1/fonts/..%2F..%2Fsprites/0-255.pbf", + "/v1/fonts/Noto%20Sans%20Regular/255-0.pbf", + "/v1/fonts/Noto%20Sans%20Regular/0-255.json", + "/v1/fonts/a,b,c,d,e/0-255.pbf", + "/v1/fonts/Noto%2FSans/0-255.pbf", + ])("rejects %s and never reads R2", async (path) => { + const b = bucket(); + const res = await call(path, makeEnv(b)); + expect(res.status).toBe(400); + expect(b.gets).toHaveLength(0); + }); +}); + +describe("path traversal", () => { + it("never reads R2 for dot-segment paths (the URL is normalised away)", async () => { + const b = bucket(); + const res = await call("/v1/fonts/%2e%2e/0-255.pbf", makeEnv(b)); + expect(res.status).toBe(404); // normalised to /v1/fonts/0-255.pbf + expect(b.gets).toHaveLength(0); + }); +}); + +describe("GET /v1/sprites/{name}[@2x].(json|png)", () => { + it("serves the index and the retina sheet", async () => { + const env = makeEnv(bucket()); + const json = await call("/v1/sprites/light.json", env); + expect(json.status).toBe(200); + expect(json.headers.get("Content-Type")).toBe("application/json"); + expect(await json.json()).toEqual({ icon: {} }); + + const png = await call("/v1/sprites/light@2x.png", env); + expect(png.status).toBe(200); + expect(png.headers.get("Content-Type")).toBe("image/png"); + expect(new Uint8Array(await png.arrayBuffer())).toEqual(PNG); + }); + + it("answers 304 for a matching ETag", async () => { + const res = await call("/v1/sprites/light.json", makeEnv(bucket()), { + headers: { "If-None-Match": '"spr1"' }, + }); + expect(res.status).toBe(304); + }); + + it("404s a sprite it does not hold", async () => { + const res = await call("/v1/sprites/dark.json", makeEnv(bucket())); + expect(res.status).toBe(404); + expect(res.headers.get("Cache-Control")).toBe( + "public, max-age=300, s-maxage=3600", + ); + }); + + it.each([ + "/v1/sprites/Light.json", + "/v1/sprites/..%2Flight.json", + "/v1/sprites/light@3x.png", + "/v1/sprites/light.svg", + ])("rejects %s with 400 and never reads R2", async (path) => { + const b = bucket(); + const res = await call(path, makeEnv(b)); + expect(res.status).toBe(400); + expect(b.gets).toHaveLength(0); + }); +}); diff --git a/test/cors-ratelimit.test.ts b/test/cors-ratelimit.test.ts new file mode 100644 index 0000000..30f8223 --- /dev/null +++ b/test/cors-ratelimit.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from "vitest"; +import { isOriginAllowed, parseAllowedOrigins } from "../src/cors"; +import { StubBucket, buildPmtiles, call, makeCtx, makeEnv } from "./helpers"; + +const bucket = () => + new StubBucket().put( + "tiles/test.pmtiles", + buildPmtiles([{ z: 0, x: 0, y: 0, data: new Uint8Array([1]) }]), + ); + +describe("isOriginAllowed", () => { + const allowed = parseAllowedOrigins( + " https://weather.mukoko.com/ , http://localhost:3000,https://*.nyuchi.com ,", + ); + + it("parses the list", () => { + expect(allowed).toEqual([ + "https://weather.mukoko.com", + "http://localhost:3000", + "https://*.nyuchi.com", + ]); + }); + + it("matches exactly and by subdomain wildcard", () => { + expect(isOriginAllowed("https://weather.mukoko.com", allowed)).toBe(true); + expect(isOriginAllowed("https://console.nyuchi.com", allowed)).toBe(true); + expect(isOriginAllowed("https://a.b.nyuchi.com", allowed)).toBe(true); + }); + + it.each([ + "https://nyuchi.com", // apex is not a subdomain + "https://evilnyuchi.com", + "https://nyuchi.com.evil.test", + "http://console.nyuchi.com", // wrong scheme + "https://weather.mukoko.com.evil.test", + "http://localhost:3001", + "null", + ])("rejects %s", (origin) => { + expect(isOriginAllowed(origin, allowed)).toBe(false); + }); + + it("allows nothing when the var is empty", () => { + expect(isOriginAllowed("https://weather.mukoko.com", [])).toBe(false); + }); +}); + +describe("CORS headers", () => { + it("echoes an allowed origin", async () => { + const res = await call("/health", makeEnv(bucket()), { + headers: { Origin: "https://weather.mukoko.com" }, + }); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe( + "https://weather.mukoko.com", + ); + expect(res.headers.get("Vary")).toContain("Origin"); + }); + + it("omits the header for other origins and for no origin", async () => { + const other = await call("/health", makeEnv(bucket()), { + headers: { Origin: "https://evil.test" }, + }); + expect(other.status).toBe(200); + expect(other.headers.get("Access-Control-Allow-Origin")).toBeNull(); + const none = await call("/health", makeEnv(bucket())); + expect(none.headers.get("Access-Control-Allow-Origin")).toBeNull(); + // The URL answers per origin, so Vary: Origin is there even with none. + expect(none.headers.get("Vary")).toContain("Origin"); + const tile = await call("/v1/tiles/0/0/0.mvt", makeEnv(bucket())); + expect(tile.headers.get("Vary")).toContain("Origin"); + }); + + it("answers preflight 204 for allowed and 403 for others", async () => { + const ok = await call("/v1/tiles/0/0/0.mvt", makeEnv(bucket()), { + method: "OPTIONS", + headers: { + Origin: "http://localhost:3000", + "Access-Control-Request-Method": "GET", + }, + }); + expect(ok.status).toBe(204); + expect(ok.headers.get("Access-Control-Allow-Methods")).toContain("GET"); + const bad = await call("/v1/tiles/0/0/0.mvt", makeEnv(bucket()), { + method: "OPTIONS", + headers: { Origin: "https://evil.test" }, + }); + expect(bad.status).toBe(403); + }); + + it("also covers error responses", async () => { + const res = await call("/v1/tiles/99/0/0.mvt", makeEnv(bucket()), { + headers: { Origin: "https://weather.mukoko.com" }, + }); + expect(res.status).toBe(400); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe( + "https://weather.mukoko.com", + ); + }); +}); + +describe("rate limiting", () => { + const IP = { "CF-Connecting-IP": "203.0.113.7" }; + const refuse = { limit: async () => ({ success: false }) }; + + it("answers 429 with Retry-After when the limiter refuses", async () => { + const env = makeEnv(bucket(), { RATE_LIMITER: refuse }); + const res = await call("/v1/tiles/0/0/0.mvt", env, { headers: IP }); + expect(res.status).toBe(429); + expect(res.headers.get("Retry-After")).toBe("60"); + expect(await res.json()).toEqual({ error: "rate_limited" }); + const style = await call("/v1/styles/light.json", env, { headers: IP }); + expect(style.status).toBe(429); + }); + + it("keys the limit by client IP", async () => { + const keys: string[] = []; + const env = makeEnv(bucket(), { + RATE_LIMITER: { + limit: async ({ key }) => (keys.push(key), { success: true }), + }, + }); + await call("/v1/tiles/0/0/0.mvt", env, { headers: IP }); + expect(keys).toEqual(["203.0.113.7"]); + }); + + it("does not limit a request with no CF-Connecting-IP (no shared bucket)", async () => { + let calls = 0; + const env = makeEnv(bucket(), { + RATE_LIMITER: { limit: async () => (calls++, { success: false }) }, + }); + expect((await call("/v1/tiles/0/0/0.mvt", env)).status).toBe(200); + expect(calls).toBe(0); + }); + + it("limits tiles only on an edge-cache miss", async () => { + let calls = 0; + const env = makeEnv(bucket(), { + EDGE_CACHE: "on", + TILES_VERSION: "v-limit-1", + RATE_LIMITER: { limit: async () => (calls++, { success: true }) }, + }); + const { ctx, settle } = makeCtx(); + const init = { headers: IP }; + const first = await call("/v1/tiles/0/0/0.mvt", env, init, ctx); + await first.arrayBuffer(); + await settle(); + expect(calls).toBe(1); + // A cache hit costs the origin nothing, so it does not touch the limiter, + // even from an IP the limiter would now refuse. + const refused = makeEnv(bucket(), { + EDGE_CACHE: "on", + TILES_VERSION: "v-limit-1", + RATE_LIMITER: refuse, + }); + const hit = await call("/v1/tiles/0/0/0.mvt", refused, init, ctx); + expect(hit.status).toBe(200); + }); + + it("does not limit invalid tile requests (rejected before any work)", async () => { + let calls = 0; + const env = makeEnv(bucket(), { + RATE_LIMITER: { limit: async () => (calls++, { success: true }) }, + }); + expect( + (await call("/v1/tiles/99/0/0.mvt", env, { headers: IP })).status, + ).toBe(400); + expect(calls).toBe(0); + }); + + it("never limits /health", async () => { + const env = makeEnv(bucket(), { RATE_LIMITER: refuse }); + expect((await call("/health", env, { headers: IP })).status).toBe(200); + }); + + it("fails open when the limiter errors or is absent", async () => { + const broken = makeEnv(bucket(), { + RATE_LIMITER: { + limit: async () => { + throw new Error("boom"); + }, + }, + }); + expect( + (await call("/v1/tiles/0/0/0.mvt", broken, { headers: IP })).status, + ).toBe(200); + const absent = makeEnv(bucket(), { RATE_LIMITER: undefined }); + expect( + (await call("/v1/tiles/0/0/0.mvt", absent, { headers: IP })).status, + ).toBe(200); + }); +}); diff --git a/test/credits.test.ts b/test/credits.test.ts new file mode 100644 index 0000000..7b27c94 --- /dev/null +++ b/test/credits.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { CREDITS, attributionHtml, attributionText } from "../src/credits"; + +describe("credits", () => { + it("lists Nyuchi Maps and OpenStreetMap contributors", () => { + const texts = CREDITS.map((c) => c.text); + expect(texts).toContain("Nyuchi Maps"); + expect(texts).toContain("© OpenStreetMap contributors"); + }); + + it("has no credit twice (by id, text or url)", () => { + for (const key of ["id", "text", "url"] as const) { + const values = CREDITS.map((c) => c[key].toLowerCase()); + expect(new Set(values).size, `duplicate ${key}`).toBe(values.length); + } + }); + + it("renders each credit exactly once in the HTML and the text", () => { + const html = attributionHtml(); + const text = attributionText(); + for (const c of CREDITS) { + expect(html.split(c.text).length - 1, c.text).toBe(1); + expect(text.split(c.text).length - 1, c.text).toBe(1); + } + }); +}); diff --git a/test/encoding.test.ts b/test/encoding.test.ts new file mode 100644 index 0000000..97a73d2 --- /dev/null +++ b/test/encoding.test.ts @@ -0,0 +1,71 @@ +import { SELF, env } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; +import { responseInit } from "../src/http"; +import { buildPmtiles, gzip } from "./helpers"; + +// These run the real Worker entry (src/index.ts) in workerd with miniflare's +// simulated R2, so the runtime's own response pipeline is in play. The other +// suites call app.fetch directly, which never reaches it. + +const TILE = new TextEncoder().encode("plain-mvt-tile-bytes"); +const KEY = "tiles/nyuchi-maps.pmtiles"; +const ORIGIN = "https://weather.mukoko.com"; + +describe("responseInit", () => { + it("sets encodeBody manual only when a Content-Encoding is present", () => { + const gz = responseInit(200, new Headers({ "Content-Encoding": "gzip" })); + expect(gz.encodeBody).toBe("manual"); + const plain = responseInit(200, new Headers({ "Content-Type": "x/y" })); + expect(plain.encodeBody).toBeUndefined(); + }); +}); + +describe("gzip tiles through the real runtime", () => { + async function seed() { + const data = await gzip(TILE); + await env.MAPS_BUCKET.put( + KEY, + buildPmtiles([{ z: 0, x: 0, y: 0, data }], { tileCompression: 2 }), + ); + } + + async function fetchTile(path: string, headers: HeadersInit = {}) { + return SELF.fetch(`https://maps.nyuchi.com${path}`, { headers }); + } + + it("is gzipped exactly once: one decode yields the tile, not gzip again", async () => { + await seed(); + const res = await fetchTile("/v1/tiles/0/0/0.mvt", { + "Accept-Encoding": "gzip", + }); + expect(res.status).toBe(200); + const body = new Uint8Array(await res.arrayBuffer()); + expect(new TextDecoder().decode(body)).toBe("plain-mvt-tile-bytes"); + }); + + it("stays single-gzip with a CORS rebuild and on an edge-cache hit", async () => { + await seed(); + const path = "/v1/tiles/0/0/0.mvt"; + const headers = { Origin: ORIGIN, "Accept-Encoding": "gzip" }; + const miss = await fetchTile(path, headers); + expect(miss.headers.get("Access-Control-Allow-Origin")).toBe(ORIGIN); + expect(await miss.text()).toBe("plain-mvt-tile-bytes"); + + // Retire the object: only the edge cache can answer now. The Cache API + // write is asynchronous, so allow a few tries. + await env.MAPS_BUCKET.delete(KEY); + let hit: Response | undefined; + for (let i = 0; i < 20; i++) { + const res = await fetchTile(path, headers); + if (res.status === 200) { + hit = res; + break; + } + await res.arrayBuffer(); + await new Promise((r) => setTimeout(r, 50)); + } + expect(hit, "edge cache never answered").toBeDefined(); + expect(hit!.headers.get("Access-Control-Allow-Origin")).toBe(ORIGIN); + expect(await hit!.text()).toBe("plain-mvt-tile-bytes"); + }); +}); diff --git a/test/env.d.ts b/test/env.d.ts new file mode 100644 index 0000000..1485dfd --- /dev/null +++ b/test/env.d.ts @@ -0,0 +1,10 @@ +// The bindings from wrangler.jsonc, as the Worker sees them in `cloudflare:test`. +export {}; + +declare global { + namespace Cloudflare { + interface Env { + MAPS_BUCKET: R2Bucket; + } + } +} diff --git a/test/helpers.ts b/test/helpers.ts new file mode 100644 index 0000000..21f66cd --- /dev/null +++ b/test/helpers.ts @@ -0,0 +1,234 @@ +import { zxyToTileId } from "pmtiles"; +import app, { createApp } from "../src/index"; +import type { SourceDef } from "../src/sources"; +import type { Env } from "../src/types"; + +export const BASE = "https://maps.test"; + +// ---- R2 stub -------------------------------------------------------------- + +interface StoredObject { + data: Uint8Array; + etag: string; +} + +export interface GetCall { + key: string; + range?: { offset: number; length: number }; +} + +/** Just enough of the R2 binding for the Worker: get() with range + onlyIf. */ +export class StubBucket { + readonly objects = new Map(); + readonly gets: GetCall[] = []; + /** When set, the next get() rejects with it (a transient R2 fault). */ + failNext: Error | null = null; + + put(key: string, data: Uint8Array | string, etag = "etag1"): this { + const bytes = + typeof data === "string" ? new TextEncoder().encode(data) : data; + this.objects.set(key, { data: bytes, etag }); + return this; + } + + async get( + key: string, + options?: { + range?: { offset: number; length: number }; + onlyIf?: { etagMatches?: string } | Headers; + }, + ): Promise { + this.gets.push({ key, range: options?.range }); + if (this.failNext) { + const err = this.failNext; + this.failNext = null; + throw err; + } + const stored = this.objects.get(key); + if (!stored) return null; + const meta = { + key, + etag: stored.etag, + httpEtag: `"${stored.etag}"`, + size: stored.data.length, + }; + const cond = options?.onlyIf; + if (cond instanceof Headers) { + // R2 evaluates request conditionals: a matching If-None-Match -> no body. + const inm = cond.get("If-None-Match"); + if ( + inm && + inm + .split(",") + .map((t) => t.trim()) + .includes(meta.httpEtag) + ) { + return meta; + } + } else if (cond?.etagMatches && cond.etagMatches !== stored.etag) { + return meta; // precondition failed: no body + } + const slice = options?.range + ? stored.data.slice( + options.range.offset, + options.range.offset + options.range.length, + ) + : stored.data; + return { + ...meta, + body: new Response(slice).body, + arrayBuffer: async () => slice.slice().buffer, + }; + } +} + +export const ALLOWED = "https://weather.mukoko.com,http://localhost:3000"; + +export function makeEnv(bucket: StubBucket, overrides: Partial = {}): Env { + return { + MAPS_BUCKET: bucket as unknown as R2Bucket, + RATE_LIMITER: { limit: async () => ({ success: true }) }, + PUBLIC_BASE_URL: "https://maps.nyuchi.com", + ALLOWED_ORIGINS: ALLOWED, + TILES_KEY: "tiles/test.pmtiles", + EDGE_CACHE: "off", + ...overrides, + }; +} + +export function makeCtx() { + const pending: Promise[] = []; + return { + ctx: { + waitUntil: (p: Promise) => void pending.push(p), + passThroughOnException: () => {}, + } as unknown as ExecutionContext, + settle: () => Promise.all(pending), + }; +} + +export async function call( + path: string, + env: Env, + init?: RequestInit, + ctx: ExecutionContext = makeCtx().ctx, + worker: { fetch: typeof app.fetch } = app, +): Promise { + return worker.fetch(new Request(`${BASE}${path}`, init), env, ctx); +} + +export { createApp }; +export type { SourceDef }; + +// ---- PMTiles v3 writer (tests only) ----------------------------------------- + +export async function gzip(data: Uint8Array): Promise { + const stream = new Blob([data]) + .stream() + .pipeThrough(new CompressionStream("gzip")); + return new Uint8Array(await new Response(stream).arrayBuffer()); +} + +function varint(n: number, out: number[]): void { + let v = n; + while (v >= 0x80) { + out.push((v % 0x80) | 0x80); + v = Math.floor(v / 0x80); + } + out.push(v); +} + +export interface TestTile { + z: number; + x: number; + y: number; + data: Uint8Array; +} + +export interface ArchiveOptions { + tileType?: number; // 1 = MVT + tileCompression?: number; // 1 none, 2 gzip + minZoom?: number; + maxZoom?: number; + /** JSON metadata block (stored uncompressed). */ + metadata?: Record; +} + +/** A valid single-directory PMTiles v3 archive holding the given tiles. */ +export function buildPmtiles( + tiles: TestTile[], + options: ArchiveOptions = {}, +): Uint8Array { + const { + tileType = 1, + tileCompression = 1, + minZoom = 0, + maxZoom = 3, + metadata: metadataObject = {}, + } = options; + const sorted = tiles + .map((t) => ({ ...t, id: zxyToTileId(t.z, t.x, t.y) })) + .sort((a, b) => a.id - b.id); + + // Directory: count, id deltas, run lengths, lengths, offsets. The first + // offset is explicit (+1); the rest are contiguous, written as 0. + const dirBytes: number[] = []; + varint(sorted.length, dirBytes); + let last = 0; + for (const t of sorted) { + varint(t.id - last, dirBytes); + last = t.id; + } + for (let i = 0; i < sorted.length; i++) varint(1, dirBytes); + for (const t of sorted) varint(t.data.length, dirBytes); + sorted.forEach((_, i) => varint(i === 0 ? 1 : 0, dirBytes)); + + const metadata = new TextEncoder().encode(JSON.stringify(metadataObject)); + const rootOffset = 127; + const rootLength = dirBytes.length; + const metaOffset = rootOffset + rootLength; + const tileOffset = metaOffset + metadata.length; + const tileLength = sorted.reduce((n, t) => n + t.data.length, 0); + + const out = new Uint8Array(tileOffset + tileLength); + const dv = new DataView(out.buffer); + out.set(new TextEncoder().encode("PMTiles"), 0); + dv.setUint8(7, 3); + const u64 = (pos: number, value: number) => { + dv.setUint32(pos, value >>> 0, true); + dv.setUint32(pos + 4, Math.floor(value / 2 ** 32), true); + }; + u64(8, rootOffset); + u64(16, rootLength); + u64(24, metaOffset); + u64(32, metadata.length); + u64(40, tileOffset); // leaf directories: none + u64(48, 0); + u64(56, tileOffset); + u64(64, tileLength); + u64(72, sorted.length); + u64(80, sorted.length); + u64(88, sorted.length); + dv.setUint8(96, 1); // clustered + dv.setUint8(97, 1); // internal compression: none + dv.setUint8(98, tileCompression); + dv.setUint8(99, tileType); + dv.setUint8(100, minZoom); + dv.setUint8(101, maxZoom); + dv.setInt32(102, Math.round(25.2 * 1e7), true); // min lon + dv.setInt32(106, Math.round(-22.45 * 1e7), true); // min lat + dv.setInt32(110, Math.round(33.1 * 1e7), true); // max lon + dv.setInt32(114, Math.round(-15.55 * 1e7), true); // max lat + dv.setUint8(118, 5); // center zoom + dv.setInt32(119, Math.round(31.05 * 1e7), true); + dv.setInt32(123, Math.round(-17.83 * 1e7), true); + + out.set(dirBytes, rootOffset); + out.set(metadata, metaOffset); + let p = tileOffset; + for (const t of sorted) { + out.set(t.data, p); + p += t.data.length; + } + return out; +} diff --git a/test/metadata.test.ts b/test/metadata.test.ts new file mode 100644 index 0000000..51442c6 --- /dev/null +++ b/test/metadata.test.ts @@ -0,0 +1,29 @@ +import { Compression } from "pmtiles"; +import { describe, expect, it } from "vitest"; +import { decodeMetadata } from "../src/archive"; +import { gzip } from "./helpers"; + +const enc = (v: unknown) => new TextEncoder().encode(JSON.stringify(v)); +const buf = (u: Uint8Array) => u.slice().buffer as ArrayBuffer; + +describe("decodeMetadata", () => { + const meta = { vector_layers: [{ id: "water" }], name: "x" }; + + it("reads uncompressed and gzip metadata", async () => { + expect(await decodeMetadata(buf(enc(meta)), Compression.None)).toEqual( + meta, + ); + expect( + await decodeMetadata(buf(await gzip(enc(meta))), Compression.Gzip), + ).toEqual(meta); + }); + + it("returns null for unsupported compression, garbage and non-objects", async () => { + expect(await decodeMetadata(buf(enc(meta)), Compression.Zstd)).toBeNull(); + expect( + await decodeMetadata(buf(new Uint8Array([1, 2, 3])), Compression.None), + ).toBeNull(); + expect(await decodeMetadata(buf(enc([1, 2])), Compression.None)).toBeNull(); + expect(await decodeMetadata(buf(enc(meta)), Compression.Gzip)).toBeNull(); // not actually gzip + }); +}); diff --git a/test/openapi.test.ts b/test/openapi.test.ts new file mode 100644 index 0000000..b8a233d --- /dev/null +++ b/test/openapi.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { StubBucket, call, makeEnv } from "./helpers"; + +const EXPECTED_PATHS = [ + "/health", + "/openapi.json", + "/v1/fonts/{stack}/{range}.pbf", + "/v1/sources/{source}/tiles.json", + "/v1/sources/{source}/tiles/{z}/{x}/{y}.{ext}", + "/v1/sprites/{name}.{ext}", + "/v1/styles/{name}.json", + "/v1/tiles.json", + "/v1/tiles/{z}/{x}/{y}.{ext}", +]; + +describe("service documents", () => { + it("GET /health", async () => { + const res = await call("/health", makeEnv(new StubBucket())); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: "ok", service: "nyuchi-maps" }); + expect(res.headers.get("Cache-Control")).toBe("no-store"); + }); + + it("GET /openapi.json documents every route", async () => { + const res = await call("/openapi.json", makeEnv(new StubBucket())); + expect(res.status).toBe(200); + const spec = (await res.json()) as { + openapi: string; + paths: Record>; + }; + expect(spec.openapi).toBe("3.1.0"); + expect(Object.keys(spec.paths).sort()).toEqual(EXPECTED_PATHS); + const ids = Object.values(spec.paths).map((p) => p.get?.operationId); + expect(ids.every(Boolean)).toBe(true); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("answers unknown paths with a JSON 404", async () => { + const res = await call("/nope", makeEnv(new StubBucket())); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: "not_found" }); + }); +}); diff --git a/test/sources.test.ts b/test/sources.test.ts new file mode 100644 index 0000000..abe7a0f --- /dev/null +++ b/test/sources.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_SOURCE_ID, SOURCES } from "../src/sources"; +import { + StubBucket, + buildPmtiles, + call, + createApp, + makeEnv, + type SourceDef, +} from "./helpers"; + +const BASE_TILE = new Uint8Array([1, 1, 1]); +const OVERLAY_TILE = new Uint8Array([9, 9, 9, 9]); + +// A second source exists only inside this test: no overlay is built or +// registered in the shipped code. +const overlay: SourceDef = { + id: "overlay", + defaultKey: "tiles/overlay.pmtiles", + creditIds: ["osm"], +}; +const worker = createApp({ ...SOURCES, overlay }); + +function bucket() { + return new StubBucket() + .put( + "tiles/test.pmtiles", + buildPmtiles([{ z: 0, x: 0, y: 0, data: BASE_TILE }]), + "base1", + ) + .put( + "tiles/overlay.pmtiles", + buildPmtiles([{ z: 0, x: 0, y: 0, data: OVERLAY_TILE }], { maxZoom: 6 }), + "over1", + ); +} + +const get = (path: string, b = bucket()) => + call(path, makeEnv(b), undefined, undefined, worker); + +describe("tile sources", () => { + it("ships exactly one source: the base map", () => { + expect(Object.keys(SOURCES)).toEqual([DEFAULT_SOURCE_ID]); + }); + + it("serves each source from its own archive", async () => { + const b = bucket(); + const base = await get("/v1/tiles/0/0/0.mvt", b); + const over = await get("/v1/sources/overlay/tiles/0/0/0.mvt", b); + expect(new Uint8Array(await base.arrayBuffer())).toEqual(BASE_TILE); + expect(new Uint8Array(await over.arrayBuffer())).toEqual(OVERLAY_TILE); + expect(base.headers.get("ETag")).toContain("base1"); + expect(over.headers.get("ETag")).toContain("over1"); + }); + + it("gives each source its own TileJSON, URLs and credits", async () => { + const b = bucket(); + const base = (await (await get("/v1/tiles.json", b)).json()) as any; + const over = (await ( + await get("/v1/sources/overlay/tiles.json", b) + ).json()) as any; + expect(base.tiles[0]).toBe( + "https://maps.nyuchi.com/v1/tiles/{z}/{x}/{y}.mvt", + ); + expect(over.tiles[0]).toBe( + "https://maps.nyuchi.com/v1/sources/overlay/tiles/{z}/{x}/{y}.mvt", + ); + expect(over.maxzoom).toBe(6); + expect(base.attribution).toContain("Nyuchi Maps"); + expect(over.attribution).toContain("OpenStreetMap contributors"); + expect(over.attribution).not.toContain("Nyuchi Maps"); + }); + + it("also reaches the base map by id", async () => { + const res = await get("/v1/sources/basemap/tiles/0/0/0.mvt"); + expect(res.status).toBe(200); + }); + + it("404s unknown, malformed and inherited-property source ids", async () => { + for (const id of ["nope", "Overlay", "constructor", "__proto__"]) { + const a = await get(`/v1/sources/${id}/tiles.json`); + const t = await get(`/v1/sources/${id}/tiles/0/0/0.mvt`); + expect([a.status, t.status], id).toEqual([404, 404]); + } + }); + + it("validates tile coordinates for every source", async () => { + const b = bucket(); + const res = await get("/v1/sources/overlay/tiles/99/0/0.mvt", b); + expect(res.status).toBe(400); + expect(b.gets).toHaveLength(0); + }); +}); diff --git a/test/styles.test.ts b/test/styles.test.ts new file mode 100644 index 0000000..5fcdee8 --- /dev/null +++ b/test/styles.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { CREDITS } from "../src/credits"; +import { StubBucket, call, makeEnv } from "./helpers"; + +const env = () => makeEnv(new StubBucket()); +const BASE = "https://maps.nyuchi.com"; + +function urlsIn(value: unknown, out: string[] = []): string[] { + if (typeof value === "string") { + if (/^https?:\/\//.test(value)) out.push(value); + } else if (Array.isArray(value)) { + value.forEach((v) => urlsIn(v, out)); + } else if (value && typeof value === "object") { + Object.values(value).forEach((v) => urlsIn(v, out)); + } + return out; +} + +describe("GET /v1/styles/{name}.json", () => { + for (const name of ["light", "dark"]) { + describe(name, () => { + it("is a MapLibre style that points at our own tiles, glyphs and sprite", async () => { + const res = await call(`/v1/styles/${name}.json`, env()); + expect(res.status).toBe(200); + expect(res.headers.get("ETag")).toBeTruthy(); + const style = (await res.json()) as Record; + expect(style.version).toBe(8); + expect(style.sources.protomaps).toEqual({ + type: "vector", + url: `${BASE}/v1/tiles.json`, + }); + expect(style.glyphs).toBe(`${BASE}/v1/fonts/{fontstack}/{range}.pbf`); + expect(style.sprite).toBe(`${BASE}/v1/sprites/${name}`); + expect(style.layers.length).toBeGreaterThan(10); + }); + + it("references no host other than our own", async () => { + const style = await ( + await call(`/v1/styles/${name}.json`, env()) + ).json(); + const foreign = urlsIn(style).filter((u) => !u.startsWith(BASE)); + expect(foreign).toEqual([]); + }); + + it("carries no credit text: the TileJSON is the one source", async () => { + const text = await ( + await call(`/v1/styles/${name}.json`, env()) + ).text(); + for (const c of CREDITS) expect(text).not.toContain(c.text); + expect(text).not.toContain('"attribution"'); + }); + }); + } + + it("light and dark differ", async () => { + const a = await (await call("/v1/styles/light.json", env())).text(); + const b = await (await call("/v1/styles/dark.json", env())).text(); + expect(a).not.toBe(b); + }); + + it("honours ?lang", async () => { + const en = await (await call("/v1/styles/light.json", env())).text(); + const sn = await ( + await call("/v1/styles/light.json?lang=sn", env()) + ).text(); + expect(sn).not.toBe(en); + expect(sn).toContain("name:sn"); + }); + + it("answers 304 for a matching ETag", async () => { + const first = await call("/v1/styles/dark.json", env()); + const res = await call("/v1/styles/dark.json", env(), { + headers: { "If-None-Match": first.headers.get("ETag")! }, + }); + expect(res.status).toBe(304); + }); + + it("404s unknown styles and rejects bad input", async () => { + expect((await call("/v1/styles/sepia.json", env())).status).toBe(404); + expect((await call("/v1/styles/light", env())).status).toBe(404); + expect((await call("/v1/styles/..%2Fsecret.json", env())).status).toBe(404); + expect((await call("/v1/styles/light.json?lang=EN;x", env())).status).toBe( + 400, + ); + }); +}); diff --git a/test/tilejson.test.ts b/test/tilejson.test.ts new file mode 100644 index 0000000..56e92ad --- /dev/null +++ b/test/tilejson.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import { CREDITS } from "../src/credits"; +import { StubBucket, buildPmtiles, call, makeEnv } from "./helpers"; + +function bucketWithArchive() { + return new StubBucket().put( + "tiles/test.pmtiles", + buildPmtiles([{ z: 0, x: 0, y: 0, data: new Uint8Array([1, 2, 3]) }], { + maxZoom: 14, + metadata: { + vector_layers: [{ id: "roads", fields: { kind: "String" } }], + }, + }), + "arch1", + ); +} + +describe("GET /v1/tiles.json", () => { + it("returns TileJSON built from the archive header", async () => { + const res = await call("/v1/tiles.json", makeEnv(bucketWithArchive())); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toContain("application/json"); + expect(res.headers.get("Cache-Control")).toContain("s-maxage=86400"); + const tj = (await res.json()) as Record; + expect(tj.tilejson).toBe("3.0.0"); + expect(tj.scheme).toBe("xyz"); + expect(tj.tiles).toEqual([ + "https://maps.nyuchi.com/v1/tiles/{z}/{x}/{y}.mvt", + ]); + expect(tj.minzoom).toBe(0); + expect(tj.maxzoom).toBe(14); + expect(tj.bounds).toEqual([25.2, -22.45, 33.1, -15.55]); + expect(tj.vector_layers).toEqual([ + { id: "roads", fields: { kind: "String" } }, + ]); + }); + + it("carries every credit exactly once", async () => { + const res = await call("/v1/tiles.json", makeEnv(bucketWithArchive())); + const { attribution } = (await res.json()) as { attribution: string }; + for (const c of CREDITS) { + expect(attribution.split(c.text).length - 1, c.text).toBe(1); + } + }); + + it("uses PUBLIC_BASE_URL, ignoring a trailing slash", async () => { + const env = makeEnv(bucketWithArchive(), { + PUBLIC_BASE_URL: "https://maps.example.test/", + }); + const tj = (await (await call("/v1/tiles.json", env)).json()) as { + tiles: string[]; + }; + expect(tj.tiles[0]).toBe( + "https://maps.example.test/v1/tiles/{z}/{x}/{y}.mvt", + ); + }); + + it("falls back to the request origin when PUBLIC_BASE_URL is unset or invalid", async () => { + for (const bad of [undefined, "", "not a url", "http://evil.test"]) { + const env = makeEnv(bucketWithArchive(), { PUBLIC_BASE_URL: bad }); + const tj = (await (await call("/v1/tiles.json", env)).json()) as { + tiles: string[]; + }; + expect(tj.tiles[0], String(bad)).toBe( + "https://maps.test/v1/tiles/{z}/{x}/{y}.mvt", + ); + } + }); + + it("answers 304 for a matching ETag", async () => { + const env = makeEnv(bucketWithArchive()); + const first = await call("/v1/tiles.json", env); + const etag = first.headers.get("ETag")!; + expect(etag).toMatch(/^"[0-9a-f]{24}"$/); + const res = await call("/v1/tiles.json", env, { + headers: { "If-None-Match": etag }, + }); + expect(res.status).toBe(304); + }); + + it("answers 503 when the archive is missing", async () => { + const res = await call("/v1/tiles.json", makeEnv(new StubBucket())); + expect(res.status).toBe(503); + }); +}); + +describe("TileJSON vector_layers fallback", () => { + it("is an empty list when the archive metadata has none", async () => { + const bucket = new StubBucket().put( + "tiles/test.pmtiles", + buildPmtiles([{ z: 0, x: 0, y: 0, data: new Uint8Array([1]) }]), + ); + const tj = (await ( + await call("/v1/tiles.json", makeEnv(bucket)) + ).json()) as { + vector_layers: unknown[]; + }; + expect(tj.vector_layers).toEqual([]); + }); +}); diff --git a/test/tiles.test.ts b/test/tiles.test.ts new file mode 100644 index 0000000..361cf18 --- /dev/null +++ b/test/tiles.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from "vitest"; +import { + StubBucket, + buildPmtiles, + call, + gzip, + makeCtx, + makeEnv, +} from "./helpers"; + +const MVT = new TextEncoder().encode("fake-mvt-tile-bytes"); + +async function archiveBucket( + opts: { compress?: boolean; tileType?: number } = {}, +) { + const compress = opts.compress ?? true; + const data = compress ? await gzip(MVT) : MVT; + const bucket = new StubBucket().put( + "tiles/test.pmtiles", + buildPmtiles( + [ + { z: 0, x: 0, y: 0, data }, + { z: 2, x: 1, y: 1, data }, + ], + { + tileCompression: compress ? 2 : 1, + tileType: opts.tileType ?? 1, + maxZoom: 3, + }, + ), + "arch1", + ); + return { bucket, data }; +} + +describe("GET /v1/tiles/{z}/{x}/{y}.mvt", () => { + it("serves a tile with the compressed bytes passed through", async () => { + const { bucket, data } = await archiveBucket(); + const res = await call("/v1/tiles/2/1/1.mvt", makeEnv(bucket)); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe( + "application/vnd.mapbox-vector-tile", + ); + expect(res.headers.get("Content-Encoding")).toBe("gzip"); + expect(res.headers.get("Cache-Control")).toContain("s-maxage=2592000"); + expect(res.headers.get("ETag")).toBe('"arch1:2/1/1"'); + expect(new Uint8Array(await res.arrayBuffer())).toEqual(data); + }); + + it("serves uncompressed archives without Content-Encoding", async () => { + const { bucket, data } = await archiveBucket({ compress: false }); + const res = await call("/v1/tiles/0/0/0.mvt", makeEnv(bucket)); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Encoding")).toBeNull(); + expect(new Uint8Array(await res.arrayBuffer())).toEqual(data); + }); + + it("only ever reads the archive with range requests", async () => { + const { bucket } = await archiveBucket(); + await call("/v1/tiles/2/1/1.mvt", makeEnv(bucket)); + const archiveReads = bucket.gets.filter((g) => g.key.endsWith(".pmtiles")); + expect(archiveReads.length).toBeGreaterThan(1); + expect(archiveReads.every((g) => g.range !== undefined)).toBe(true); + }); + + it("answers 304 when If-None-Match matches", async () => { + const { bucket } = await archiveBucket(); + const env = makeEnv(bucket); + const first = await call("/v1/tiles/2/1/1.mvt", env); + const etag = first.headers.get("ETag")!; + const reads = bucket.gets.length; + const res = await call("/v1/tiles/2/1/1.mvt", env, { + headers: { "If-None-Match": etag }, + }); + expect(res.status).toBe(304); + expect(res.headers.get("ETag")).toBe(etag); + // Answered from the cached header: no directory or tile read from R2. + expect(bucket.gets.length).toBe(reads); + }); + + it("answers 204 for a tile the archive does not hold", async () => { + const { bucket } = await archiveBucket(); + const res = await call("/v1/tiles/2/3/3.mvt", makeEnv(bucket)); + expect(res.status).toBe(204); + expect(res.headers.get("Cache-Control")).toContain("max-age=3600"); + expect(await res.text()).toBe(""); + }); + + it("answers 404 outside the archive's zoom range", async () => { + const { bucket } = await archiveBucket(); + const res = await call("/v1/tiles/5/0/0.mvt", makeEnv(bucket)); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: "zoom_out_of_range" }); + expect(res.headers.get("Cache-Control")).toBe( + "public, max-age=300, s-maxage=3600", + ); + }); + + it("answers 404 when the extension does not match the archive", async () => { + const { bucket } = await archiveBucket(); + const res = await call("/v1/tiles/0/0/0.png", makeEnv(bucket)); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: "format_mismatch" }); + expect(res.headers.get("Cache-Control")).toBe( + "public, max-age=300, s-maxage=3600", + ); + }); + + it.each([ + "/v1/tiles/23/0/0.mvt", + "/v1/tiles/1/2/0.mvt", + "/v1/tiles/x/0/0.mvt", + "/v1/tiles/01/0/0.mvt", + "/v1/tiles/0/0/0.exe", + "/v1/tiles/0/0/0", + "/v1/tiles/0/0/..%2F..%2F0.mvt", + ])("rejects %s with 400 and never reads R2", async (path) => { + const { bucket } = await archiveBucket(); + const res = await call(path, makeEnv(bucket)); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "invalid_tile_coordinates" }); + expect(bucket.gets).toHaveLength(0); + }); + + it("404s a dot-segment path (normalised away by the URL) with no R2 read", async () => { + const { bucket } = await archiveBucket(); + const res = await call("/v1/tiles/0/%2e%2e/0.mvt", makeEnv(bucket)); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: "not_found" }); + expect(bucket.gets).toHaveLength(0); + }); + + it.each(["%2f", "%5c", "%2e%2e%2f", "..%5c"])( + "400s an encoded separator %s in a tile file with no R2 read", + async (piece) => { + const { bucket } = await archiveBucket(); + const res = await call(`/v1/tiles/0/0/${piece}0.mvt`, makeEnv(bucket)); + expect(res.status).toBe(400); + expect(bucket.gets).toHaveLength(0); + }, + ); + + it("recovers after the archive was missing: 503, then 200 once uploaded", async () => { + const bucket = new StubBucket(); + const env = makeEnv(bucket); + const missing = await call("/v1/tiles/0/0/0.mvt", env); + expect(missing.status).toBe(503); + const { bucket: full } = await archiveBucket(); + bucket.put( + "tiles/test.pmtiles", + full.objects.get("tiles/test.pmtiles")!.data, + "arch1", + ); + const ok = await call("/v1/tiles/0/0/0.mvt", env); + expect(ok.status).toBe(200); + expect(ok.headers.get("ETag")).toBe('"arch1:0/0/0"'); + }); + + it("recovers after a transient R2 error: 500, then 200", async () => { + const { bucket } = await archiveBucket(); + const env = makeEnv(bucket); + bucket.failNext = new Error("R2 hiccup"); + expect((await call("/v1/tiles/0/0/0.mvt", env)).status).toBe(500); + expect((await call("/v1/tiles/0/0/0.mvt", env)).status).toBe(200); + }); + + it("answers 503 when the archive is not in the bucket", async () => { + const res = await call("/v1/tiles/0/0/0.mvt", makeEnv(new StubBucket())); + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ error: "archive_unavailable" }); + expect(res.headers.get("Cache-Control")).toBe("no-store"); + }); + + it("answers 500 for tile compression it cannot pass through", async () => { + const bucket = new StubBucket().put( + "tiles/test.pmtiles", + buildPmtiles([{ z: 0, x: 0, y: 0, data: MVT }], { tileCompression: 4 }), + ); + const res = await call("/v1/tiles/0/0/0.mvt", makeEnv(bucket)); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: "unsupported_tile_compression" }); + }); + + it("serves a repeat request from the edge cache, whatever the query string", async () => { + const { bucket } = await archiveBucket(); + const env = makeEnv(bucket, { + EDGE_CACHE: "on", + TILES_VERSION: "v-edge-1", + }); + const { ctx, settle } = makeCtx(); + const path = "/v1/tiles/2/1/1.mvt"; + const first = await call(path, env, undefined, ctx); + expect(first.status).toBe(200); + await first.arrayBuffer(); + await settle(); + const readsAfterFirst = bucket.gets.length; + const second = await call( + `${path}?r=${Math.random()}`, + env, + undefined, + ctx, + ); + expect(second.status).toBe(200); + expect(bucket.gets.length).toBe(readsAfterFirst); + }); + + it("keys the edge cache by TILES_VERSION: a new version misses", async () => { + const { bucket } = await archiveBucket(); + const { ctx, settle } = makeCtx(); + const path = "/v1/tiles/2/1/1.mvt"; + const a = makeEnv(bucket, { EDGE_CACHE: "on", TILES_VERSION: "v-edge-2" }); + await (await call(path, a, undefined, ctx)).arrayBuffer(); + await settle(); + const reads = bucket.gets.length; + const b = makeEnv(bucket, { EDGE_CACHE: "on", TILES_VERSION: "v-edge-3" }); + const res = await call(path, b, undefined, ctx); + expect(res.status).toBe(200); + expect(bucket.gets.length).toBeGreaterThan(reads); + }); +}); diff --git a/test/validate.test.ts b/test/validate.test.ts new file mode 100644 index 0000000..e426114 --- /dev/null +++ b/test/validate.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { + parseFontRange, + parseFontStack, + parseLang, + parseSpriteFile, + parseStyleFile, + parseTile, +} from "../src/validate"; + +describe("parseTile", () => { + it("accepts in-range coordinates", () => { + expect(parseTile("0", "0", "0.mvt")).toEqual({ + z: 0, + x: 0, + y: 0, + ext: "mvt", + }); + expect(parseTile("14", "9000", "9999.mvt")?.z).toBe(14); + }); + + it.each([ + ["23", "0", "0.mvt"], // zoom above the cap + ["1", "2", "0.mvt"], // x outside 2^z + ["1", "0", "2.mvt"], // y outside 2^z + ["-1", "0", "0.mvt"], + ["01", "0", "0.mvt"], // leading zero + ["a", "0", "0.mvt"], + ["1", "0", "0.exe"], + ["1", "0", "0.mvt.exe"], + ["1", "0", "0"], + ["1", "..", "0.mvt"], + ["1", "0", "..%2F0.mvt"], + ["1", "0", "0.mvt/../../x"], + ["99999999999", "0", "0.mvt"], + ])("rejects z=%s x=%s file=%s", (z, x, file) => { + expect(parseTile(z, x, file)).toBeNull(); + }); +}); + +describe("names", () => { + it("parses style files", () => { + expect(parseStyleFile("light.json")).toBe("light"); + expect(parseStyleFile("Light.json")).toBeNull(); + expect(parseStyleFile("../x.json")).toBeNull(); + expect(parseStyleFile("light.json.bak")).toBeNull(); + }); + + it("parses sprite files", () => { + expect(parseSpriteFile("light.png")).toEqual({ + name: "light", + retina: false, + ext: "png", + }); + expect(parseSpriteFile("dark@2x.json")?.retina).toBe(true); + expect(parseSpriteFile("dark@3x.json")).toBeNull(); + expect(parseSpriteFile("../dark.json")).toBeNull(); + expect(parseSpriteFile("dark.svg")).toBeNull(); + }); + + it("parses font stacks and ranges", () => { + expect(parseFontStack("Noto Sans Regular")).toEqual(["Noto Sans Regular"]); + expect(parseFontStack("Noto Sans Regular,Noto Sans Medium")).toHaveLength( + 2, + ); + expect(parseFontStack("a,b,c,d,e")).toBeNull(); + expect(parseFontStack("../etc")).toBeNull(); + expect(parseFontStack("Noto/Sans")).toBeNull(); + expect(parseFontStack("")).toBeNull(); + expect(parseFontRange("0-255.pbf")).toBe("0-255"); + expect(parseFontRange("256-255.pbf")).toBeNull(); + expect(parseFontRange("0-255.json")).toBeNull(); + expect(parseFontRange("0-255.pbf/../x")).toBeNull(); + }); + + it("parses lang", () => { + expect(parseLang(undefined)).toBe("en"); + expect(parseLang("sn")).toBe("sn"); + expect(parseLang("EN")).toBeNull(); + expect(parseLang("en;drop")).toBeNull(); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d18e5e0 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "types": [ + "@cloudflare/workers-types", + "@cloudflare/vitest-pool-workers/types" + ], + "strict": true, + "noUncheckedIndexedAccess": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true, + "verbatimModuleSyntax": false + }, + "include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..68ef1f1 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,28 @@ +// Vite+ configuration: the org's standard check and format settings. +// See nyuchi/.github/.github/workflows/reusable-vite-plus.yml. +// +// A plain object rather than defineConfig() from "vite-plus": this repo +// does not install vite-plus as a dependency, so `vp` comes from CI +// (voidzero-dev/setup-vp) or a global install (`npm i -g vite-plus`). +const config = { + // `vp check` reads ONLY this block. These values mirror .prettierrc, which + // the org lint uses on Markdown and JSON. Keep the two identical: two + // formatters with different settings on one file can never both pass. + fmt: { + printWidth: 80, + proseWrap: "preserve", + tabWidth: 2, + useTabs: false, + endOfLine: "lf", + trailingComma: "all", + sortPackageJson: false, + overrides: [ + { + files: ["*.md", "*.mdx"], + options: { embeddedLanguageFormatting: "off" }, + }, + ], + }, +}; + +export default config; diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..ffce089 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,16 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; + +// Tests run inside workerd via the Workers Vitest pool, against the real +// wrangler.jsonc. R2 and the rate limiter are stubbed in test/helpers.ts: +// no test ever touches a real bucket, the network or Cloudflare. +export default defineConfig({ + plugins: [ + cloudflareTest({ + wrangler: { configPath: "./wrangler.jsonc" }, + }), + ], + test: { + include: ["test/**/*.test.ts"], + }, +}); diff --git a/wrangler.jsonc b/wrangler.jsonc new file mode 100644 index 0000000..5f0f8d8 --- /dev/null +++ b/wrangler.jsonc @@ -0,0 +1,67 @@ +/** + * Nyuchi Maps: the Nyuchi platform base-map service. + * + * A thin Worker over ONE R2 bucket holding a PMTiles archive, glyph PBFs and + * sprites. Nothing here is created by this file: the bucket, the DNS record + * and the route are the owner's to provision. See docs/ARCHITECTURE.md. + */ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "nyuchi-maps", + "main": "src/index.ts", + "compatibility_date": "2026-08-01", + + // No *.workers.dev URL and no per-version preview URLs: the Worker is + // reachable only through the route below, once the owner enables it. + "workers_dev": false, + "preview_urls": false, + + // openapi/openapi.yaml is imported as text and served at /openapi.json. + "rules": [{ "type": "Text", "globs": ["**/*.yaml"], "fallthrough": true }], + + // Not active until the maps.nyuchi.com DNS record exists. The owner + // uncomments this, then deploys. + // "routes": [{ "pattern": "maps.nyuchi.com", "custom_domain": true }], + + "vars": { + // Public origin used in TileJSON and style URLs, without a trailing + // slash. maps.nyuchi.com is the owner-confirmed host. When this is unset + // or invalid the Worker falls back to the origin of the request. + "PUBLIC_BASE_URL": "https://maps.nyuchi.com", + // Comma-separated first-party origins allowed by CORS. A leading + // "*." label after the scheme matches any subdomain. Production only: + // for local development put ALLOWED_ORIGINS (with http://localhost:3000) + // in .dev.vars, see .dev.vars.example. + "ALLOWED_ORIGINS": "https://weather.mukoko.com,https://weatherstations.nyuchi.com", + // Object key of the PMTiles archive inside the bucket. + "TILES_KEY": "tiles/nyuchi-maps.pmtiles", + // Part of the edge cache key. Bump it (and deploy) whenever the archive + // object is replaced, so tiles cached from the old archive are retired. + "TILES_VERSION": "1", + }, + + // The binding NAME is what the code uses. The bucket does not exist yet: + // the name below is a placeholder to replace when the owner creates it. + "r2_buckets": [ + { + "binding": "MAPS_BUCKET", + "bucket_name": "nyuchi-maps-placeholder", + }, + ], + + // Per-IP limit on /v1 routes; tiles are counted only on an edge-cache miss. + // namespace_id is ours to choose but must be unique in the Cloudflare + // account, and is not a resource to create first. 1001 is the Mukoko API's + // IP limiter, so this Worker uses 7402. Check it is free before deploying. + "ratelimits": [ + { + "name": "RATE_LIMITER", + "namespace_id": "7402", + "simple": { "limit": 1800, "period": 60 }, + }, + ], + + "observability": { + "enabled": true, + }, +}