From f644c5c25e8a730fdb05d16b601d7ed459f8f2e4 Mon Sep 17 00:00:00 2001 From: Bryan Fawcett Date: Sun, 11 Oct 2026 14:14:40 +0000 Subject: [PATCH 1/4] Scaffold Nyuchi Maps: PMTiles-over-R2 Worker, styles, credits, docs, CI First slice of the Nyuchi platform base-map service (mukoko-dev/mukoko-weather#298): a TypeScript Cloudflare Worker (Hono) serving a PMTiles v3 archive from an R2 binding with range reads; TileJSON, light/dark styles, glyphs and sprites; CORS allow-list, per-IP rate limiting, strict input validation; one credits module; OpenAPI 3.1; NOTICE, architecture doc and an extract how-to; Workers Vitest suite against a stubbed R2; CI mirroring mukoko-api plus the org lint. Nothing is created or deployed: bucket name is a placeholder, the maps.nyuchi.com route is commented out, workers_dev is off, no secrets. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WnTa759tUrAwMiSoif5g6w --- .editorconfig | 40 + .github/workflows/ci.yml | 64 + .github/workflows/lint.yml | 36 + .github/workflows/staging-version.yml | 27 + .gitignore | 6 + .markdownlint.jsonc | 39 + .npmrc | 1 + .prettierignore | 30 + .prettierrc | 16 + .yamllint.yaml | 82 + NOTICE | 59 + README.md | 126 +- docs/ARCHITECTURE.md | 94 + openapi/openapi.yaml | 262 ++ package-lock.json | 4047 +++++++++++++++++++++++++ package.json | 31 + scripts/build-extract.md | 88 + scripts/extract-regions.geojson | 70 + src/archive.ts | 117 + src/cors.ts | 78 + src/credits.ts | 56 + src/http.ts | 86 + src/index.ts | 221 ++ src/ratelimit.ts | 24 + src/styles.ts | 35 + src/types.ts | 16 + src/validate.ts | 80 + src/yaml.d.ts | 4 + test/assets.test.ts | 115 + test/cors-ratelimit.test.ts | 139 + test/credits.test.ts | 26 + test/helpers.ts | 205 ++ test/openapi.test.ts | 41 + test/styles.test.ts | 86 + test/tilejson.test.ts | 79 + test/tiles.test.ts | 154 + test/validate.test.ts | 82 + tsconfig.json | 19 + vite.config.ts | 28 + vitest.config.ts | 16 + wrangler.jsonc | 59 + 41 files changed, 6882 insertions(+), 2 deletions(-) create mode 100644 .editorconfig create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/staging-version.yml create mode 100644 .gitignore create mode 100644 .markdownlint.jsonc create mode 100644 .npmrc create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 .yamllint.yaml create mode 100644 NOTICE create mode 100644 docs/ARCHITECTURE.md create mode 100644 openapi/openapi.yaml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/build-extract.md create mode 100644 scripts/extract-regions.geojson create mode 100644 src/archive.ts create mode 100644 src/cors.ts create mode 100644 src/credits.ts create mode 100644 src/http.ts create mode 100644 src/index.ts create mode 100644 src/ratelimit.ts create mode 100644 src/styles.ts create mode 100644 src/types.ts create mode 100644 src/validate.ts create mode 100644 src/yaml.d.ts create mode 100644 test/assets.test.ts create mode 100644 test/cors-ratelimit.test.ts create mode 100644 test/credits.test.ts create mode 100644 test/helpers.ts create mode 100644 test/openapi.test.ts create mode 100644 test/styles.test.ts create mode 100644 test/tilejson.test.ts create mode 100644 test/tiles.test.ts create mode 100644 test/validate.test.ts create mode 100644 tsconfig.json create mode 100644 vite.config.ts create mode 100644 vitest.config.ts create mode 100644 wrangler.jsonc 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..ab57589 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,64 @@ +# 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 + - 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 + - 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 + - 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/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..521a9f7 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true 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..2033895 --- /dev/null +++ b/NOTICE @@ -0,0 +1,59 @@ +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 built from OpenStreetMap are a Derived Database. Keep the + credit "(c) OpenStreetMap contributors" visible wherever a map is shown, + and offer any published tile file under the ODbL. + +Protomaps basemap builds (the PMTiles planet and regional extracts) + https://docs.protomaps.com/basemaps/downloads + OpenStreetMap-derived data: ODbL 1.0, as above. + CONFIRM whether the Protomaps build terms ask for a Protomaps credit; if so + 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 + Copyright The Noto Project Authors. Licence: SIL Open Font License 1.1. + Source of the prebuilt PBFs: github.com/protomaps/basemaps-assets. + CONFIRM the licence of that repository's font and sprite files 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..2b94ac6 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,124 @@ -# 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/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 a + long edge cache (`s-maxage` 30 days), an `ETag` and `304` on `If-None-Match`. +- 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 (it fails open). + `429` carries `Retry-After`. +- CORS is an allow-list from `ALLOWED_ORIGINS`; first-party apps use an + allow-listed 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_KEY` | var | Object key of the archive, `tiles/nyuchi-maps.pmtiles` | + +There are no secrets. `workers_dev` is off, no route is enabled and observability +is on. Nothing in this repo creates a bucket, a DNS record or a deployment. + +Bucket layout: + +```text +tiles/nyuchi-maps.pmtiles +fonts/{font name}/{range}.pbf e.g. fonts/Noto Sans Regular/0-255.pbf +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 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**: tile files built from OpenStreetMap are a derived database under + the Open Database Licence (ODbL) 1.0. Credit **"© OpenStreetMap contributors"** + wherever a map is shown (the TileJSON carries it). Any tile file we publish + must be offered under the ODbL. +- **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..efad255 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,94 @@ +# Architecture + +Nyuchi Maps is platform infrastructure. One Worker, one R2 bucket, no tile +server and no database. + +```text +browser / app Cloudflare edge R2 (one bucket) +MapLibre ──GET──► Cache API ─miss─► Worker (Hono) ──range──► tiles/nyuchi-maps.pmtiles + │ fonts/… sprites/… + ├─ CORS allow-list + ├─ per-IP rate limit (RATE_LIMITER) + └─ validation, ETag, Cache-Control +``` + +## 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`) on `/v1/*`, keyed by `CF-Connecting-IP`. + It fails open so a limiter fault cannot take the map down. +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 (keyed by the archive ETag); a replaced archive surfaces as an ETag + mismatch, which the library retries. +5. **Response**: tile bytes are passed through still compressed with + `Content-Encoding: gzip`, 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; 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); bad coordinates `400`; archive missing from the bucket +`503 archive_unavailable` (`no-store`). + +## 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). + 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); 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. Edge-cached tiles age out within `s-maxage` (30 days) unless purged. 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/openapi/openapi.yaml b/openapi/openapi.yaml new file mode 100644 index 0000000..f3d6dfc --- /dev/null +++ b/openapi/openapi.yaml @@ -0,0 +1,262 @@ +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; credits are carried by the TileJSON and must be + displayed by every map. + 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} + image/png: + 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: + 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. + 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 name, tile outside the archive's zoom range, or missing object. + content: + application/json: + schema: {$ref: "#/components/schemas/Error"} + RateLimited: + description: Too many requests from this IP. Retry after the `Retry-After` seconds. + 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..6c708f1 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4047 @@ +{ + "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" + }, + "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", + "yaml": "^2.9.1" + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz", + "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==", + "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.1" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz", + "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==", + "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.1" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz", + "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz", + "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==", + "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.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz", + "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==", + "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.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz", + "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==", + "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.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz", + "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==", + "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.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz", + "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==", + "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.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz", + "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==", + "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.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz", + "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==", + "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.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz", + "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==", + "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.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz", + "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==", + "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.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz", + "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==", + "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.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz", + "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==", + "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.1" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz", + "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==", + "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.1" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz", + "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==", + "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.1" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz", + "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==", + "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.1" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz", + "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==", + "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.1" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz", + "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==", + "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.1" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz", + "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==", + "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.1" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz", + "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==", + "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.1" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz", + "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz", + "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz", + "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==", + "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.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz", + "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==", + "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.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz", + "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==", + "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.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz", + "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.2", + "@img/sharp-darwin-x64": "0.35.2", + "@img/sharp-freebsd-wasm32": "0.35.2", + "@img/sharp-libvips-darwin-arm64": "1.3.1", + "@img/sharp-libvips-darwin-x64": "1.3.1", + "@img/sharp-libvips-linux-arm": "1.3.1", + "@img/sharp-libvips-linux-arm64": "1.3.1", + "@img/sharp-libvips-linux-ppc64": "1.3.1", + "@img/sharp-libvips-linux-riscv64": "1.3.1", + "@img/sharp-libvips-linux-s390x": "1.3.1", + "@img/sharp-libvips-linux-x64": "1.3.1", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", + "@img/sharp-libvips-linuxmusl-x64": "1.3.1", + "@img/sharp-linux-arm": "0.35.2", + "@img/sharp-linux-arm64": "0.35.2", + "@img/sharp-linux-ppc64": "0.35.2", + "@img/sharp-linux-riscv64": "0.35.2", + "@img/sharp-linux-s390x": "0.35.2", + "@img/sharp-linux-x64": "0.35.2", + "@img/sharp-linuxmusl-arm64": "0.35.2", + "@img/sharp-linuxmusl-x64": "0.35.2", + "@img/sharp-webcontainers-wasm32": "0.35.2", + "@img/sharp-win32-arm64": "0.35.2", + "@img/sharp-win32-ia32": "0.35.2", + "@img/sharp-win32-x64": "0.35.2" + } + }, + "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.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "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/@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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/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/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/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==", + "dev": true, + "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..f2da32a --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "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" + } +} diff --git a/scripts/build-extract.md b/scripts/build-extract.md new file mode 100644 index 0000000..a57aac3 --- /dev/null +++ b/scripts/build-extract.md @@ -0,0 +1,88 @@ +# 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` and +`Noto Sans Italic` 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/Noto Sans Regular/0-255.pbf ... +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 + +Rebuild from a newer `BUILD` date and upload over the same key. The Worker +reads the archive by ETag, so a replaced object is picked up on the next +mismatch; edge-cached tiles age out within their `s-maxage` (30 days). Purge +the cache if the change must show sooner. 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..95d3c4f --- /dev/null +++ b/src/archive.ts @@ -0,0 +1,117 @@ +import { + Compression, + EtagMismatch, + PMTiles, + SharedPromiseCache, + 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. The + * header is read once and each directory once; the cache key includes the + * archive ETag, so a replaced archive is picked up on the next mismatch. + */ +const caches = new WeakMap(); + +/** Tile bytes are passed through still compressed (see tileEncoding). */ +const passThrough = async (buf: ArrayBuffer): Promise => buf; + +export function openArchive(env: Env): PMTiles { + let cache = caches.get(env.MAPS_BUCKET); + if (!cache) { + cache = new SharedPromiseCache(200); + caches.set(env.MAPS_BUCKET, cache); + } + const source = new R2Source( + env.MAPS_BUCKET, + env.TILES_KEY || DEFAULT_TILES_KEY, + ); + // `cache` decompresses directories itself; this decompress only touches + // tile bytes, which the client can inflate. + return new PMTiles(source, cache, passThrough); +} + +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..cb9128e --- /dev/null +++ b/src/cors.ts @@ -0,0 +1,78 @@ +import type { MiddlewareHandler } from "hono"; +import { jsonError } 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. + const res = new Response(c.res.body, c.res); + if (origin !== undefined) { + res.headers.append("Vary", "Origin"); + if (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..ad0e472 --- /dev/null +++ b/src/credits.ts @@ -0,0 +1,56 @@ +/** + * 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 the licence of the + // Protomaps basemap builds / basemap style asks for one. Check the terms + // at https://docs.protomaps.com/ before the first public release; until + // then it is deliberately absent so the credit list stays what is required. +]; + +function escapeHtml(text: string): string { + return text + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +/** HTML for TileJSON / MapLibre attribution controls. */ +export function attributionHtml(): string { + return CREDITS.map( + (c) => `${escapeHtml(c.text)}`, + ).join(" | "); +} + +/** Plain text, for logs and non-HTML consumers. */ +export function attributionText(): string { + return CREDITS.map((c) => c.text).join(" | "); +} diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..f6925ec --- /dev/null +++ b/src/http.ts @@ -0,0 +1,86 @@ +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; +} + +export function jsonError( + status: number, + error: string, + extra?: HeadersInit, +): Response { + const headers = new Headers(extra); + headers.set("Content-Type", "application/json; charset=utf-8"); + 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..e7fd676 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,221 @@ +import { Hono } from "hono"; +import { parse as parseYaml } from "yaml"; +import openapiYaml from "../openapi/openapi.yaml"; +import { + ArchiveMissingError, + UnsupportedCompressionError, + openArchive, + 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, +} from "./http"; +import { rateLimit } from "./ratelimit"; +import { buildStyle } from "./styles"; +import type { AppEnv } from "./types"; +import { + parseFontRange, + parseFontStack, + parseLang, + parseSpriteFile, + parseStyleFile, + parseTile, +} from "./validate"; + +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); + +/** TileJSON 3.0.0 for the archive, carrying the credits. */ +app.get("/v1/tiles.json", async (c) => { + const header = await openArchive(c.env).getHeader(); + const ext = tileExt(header); + if (!ext) return jsonError(500, "unsupported_tile_type"); + const base = baseUrl(c.env, c.req.url); + return jsonResponse( + c, + { + tilejson: "3.0.0", + name: "Nyuchi Maps", + scheme: "xyz", + tiles: [`${base}/v1/tiles/{z}/{x}/{y}.${ext}`], + attribution: attributionHtml(), + minzoom: header.minZoom, + maxzoom: header.maxZoom, + bounds: [header.minLon, header.minLat, header.maxLon, header.maxLat], + center: [header.centerLon, header.centerLat, header.centerZoom], + }, + CACHE_ASSET, + ); +}); + +app.get("/v1/tiles/:z/:x/:file", async (c) => { + const tile = parseTile( + c.req.param("z"), + c.req.param("x"), + c.req.param("file"), + ); + if (!tile) return jsonError(400, "invalid_tile_coordinates"); + + const edge = c.env.EDGE_CACHE === "off" ? null : caches.default; + const cacheKey = new Request(c.req.url, { method: "GET" }); + 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 hit; + } + } + + const archive = openArchive(c.env); + const header = await archive.getHeader(); + const ext = tileExt(header); + if (ext !== tile.ext) { + return jsonError(404, "format_mismatch", { "Cache-Control": CACHE_MISS }); + } + if (tile.z < header.minZoom || tile.z > header.maxZoom) { + return jsonError(404, "zoom_out_of_range", { + "Cache-Control": CACHE_MISS, + }); + } + + const found = await archive.getZxy(tile.z, tile.x, tile.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 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}:${tile.z}/${tile.x}/${tile.y}"`); + } + const etag = headers.get("ETag"); + if (etag && etagMatches(c.req.header("If-None-Match"), etag)) { + return new Response(null, { status: 304, headers }); + } + + const res = new Response(found.data, { status: 200, headers }); + if (edge) { + try { + c.executionCtx.waitUntil(edge.put(cacheKey, res.clone())); + } catch { + // No execution context (tests): skip the write. + } + } + return res; +}); + +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 style = name && buildStyle(name, baseUrl(c.env, c.req.url), lang); + if (!style) return jsonError(404, "style_not_found"); + return jsonResponse(c, style, CACHE_ASSET); +}); + +/** Stream an R2 object with ETag/304 handling. */ +async function serveObject( + c: { + env: AppEnv["Bindings"]; + req: { header(n: string): string | undefined }; + }, + key: string, + contentType: string, +): Promise { + const object = await c.env.MAPS_BUCKET.get(key); + 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 (etagMatches(c.req.header("If-None-Match"), object.httpEtag)) { + await object.body.cancel(); + 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", + ); +}); + +export default app; diff --git a/src/ratelimit.ts b/src/ratelimit.ts new file mode 100644 index 0000000..efed985 --- /dev/null +++ b/src/ratelimit.ts @@ -0,0 +1,24 @@ +import type { 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. + */ +export const rateLimit: MiddlewareHandler = async (c, next) => { + const limiter = c.env.RATE_LIMITER; + if (limiter) { + const ip = c.req.header("CF-Connecting-IP") ?? "unknown"; + 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); + } + } + await next(); +}; 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..7f435d8 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,16 @@ +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; + /** 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..ffcdcea --- /dev/null +++ b/test/assets.test.ts @@ -0,0 +1,115 @@ +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); + }); + + it("404s a font or range it does not hold", async () => { + const env = makeEnv(bucket()); + expect((await call("/v1/fonts/Nope/0-255.pbf", env)).status).toBe(404); + 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([400, 404]).toContain(res.status); + 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 () => { + expect( + (await call("/v1/sprites/dark.json", makeEnv(bucket()))).status, + ).toBe(404); + }); + + 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..3642ca5 --- /dev/null +++ b/test/cors-ratelimit.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import { isOriginAllowed, parseAllowedOrigins } from "../src/cors"; +import { StubBucket, buildPmtiles, call, 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(); + }); + + 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", () => { + it("answers 429 with Retry-After when the limiter refuses", async () => { + const env = makeEnv(bucket(), { + RATE_LIMITER: { limit: async () => ({ success: false }) }, + }); + const res = await call("/v1/tiles/0/0/0.mvt", env); + expect(res.status).toBe(429); + expect(res.headers.get("Retry-After")).toBe("60"); + expect(await res.json()).toEqual({ error: "rate_limited" }); + }); + + 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: { "CF-Connecting-IP": "203.0.113.7" }, + }); + expect(keys).toEqual(["203.0.113.7"]); + }); + + it("never limits /health", async () => { + const env = makeEnv(bucket(), { + RATE_LIMITER: { limit: async () => ({ success: false }) }, + }); + expect((await call("/health", env)).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)).status).toBe(200); + const absent = makeEnv(bucket(), { RATE_LIMITER: undefined }); + expect((await call("/v1/tiles/0/0/0.mvt", absent)).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/helpers.ts b/test/helpers.ts new file mode 100644 index 0000000..d59d7aa --- /dev/null +++ b/test/helpers.ts @@ -0,0 +1,205 @@ +import { zxyToTileId } from "pmtiles"; +import app from "../src/index"; +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[] = []; + + 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 }; + }, + ): Promise { + this.gets.push({ key, range: options?.range }); + const stored = this.objects.get(key); + if (!stored) return null; + const meta = { + key, + etag: stored.etag, + httpEtag: `"${stored.etag}"`, + size: stored.data.length, + }; + const want = options?.onlyIf?.etagMatches; + if (want && want !== 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, +): Promise { + return app.fetch(new Request(`${BASE}${path}`, init), env, ctx); +} + +// ---- 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; +} + +/** 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, + } = 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("{}"); + 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/openapi.test.ts b/test/openapi.test.ts new file mode 100644 index 0000000..96938e8 --- /dev/null +++ b/test/openapi.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { StubBucket, call, makeEnv } from "./helpers"; + +const EXPECTED_PATHS = [ + "/health", + "/openapi.json", + "/v1/fonts/{stack}/{range}.pbf", + "/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/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..5c3c6d3 --- /dev/null +++ b/test/tilejson.test.ts @@ -0,0 +1,79 @@ +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, + }), + "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]); + }); + + 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); + }); +}); diff --git a/test/tiles.test.ts b/test/tiles.test.ts new file mode 100644 index 0000000..d6ab164 --- /dev/null +++ b/test/tiles.test.ts @@ -0,0 +1,154 @@ +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 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); + }); + + 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" }); + }); + + 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" }); + }); + + 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("never reads R2 for dot-segment paths (the URL is normalised away)", async () => { + const { bucket } = await archiveBucket(); + const res = await call("/v1/tiles/0/%2e%2e/0.mvt", makeEnv(bucket)); + expect([400, 404]).toContain(res.status); + expect(bucket.gets).toHaveLength(0); + }); + + 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", async () => { + const { bucket } = await archiveBucket(); + const env = makeEnv(bucket, { EDGE_CACHE: "on" }); + const { ctx, settle } = makeCtx(); + const path = "/v1/tiles/0/0/0.mvt?edge-cache-test=1"; + 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, env, undefined, ctx); + expect(second.status).toBe(200); + expect(bucket.gets.length).toBe(readsAfterFirst); + }); +}); 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..8796bf5 --- /dev/null +++ b/wrangler.jsonc @@ -0,0 +1,59 @@ +/** + * 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: the Worker is reachable only through the route + // below, once the owner enables it. + "workers_dev": 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. + "ALLOWED_ORIGINS": "https://weather.mukoko.com,https://weatherstations.nyuchi.com,http://localhost:3000", + // Object key of the PMTiles archive inside the bucket. + "TILES_KEY": "tiles/nyuchi-maps.pmtiles", + }, + + // 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. namespace_id is ours to choose; it is unique + // per account, not a resource to create first. + "ratelimits": [ + { + "name": "RATE_LIMITER", + "namespace_id": "1002", + "simple": { "limit": 600, "period": 60 }, + }, + ], + + "observability": { + "enabled": true, + }, +} From 29b54c61e0c7aa3c4372072d05997c227afa64cf Mon Sep 17 00:00:00 2001 From: Bryan Fawcett Date: Sun, 11 Oct 2026 14:18:39 +0000 Subject: [PATCH 2/4] Fix CI: version 0.1.0, audit overrides, npmrc release age; multi-source design - package.json / OpenAPI version 0.1.0 (release version policy). - Add the mukoko-api overrides (sharp ^0.35.5, undici ^7.29.1): npm audit and dependency-review now clean. - .npmrc: min-release-age=7 (Semgrep npm-missing-minimum-release-age). - Tile sources registry (src/sources.ts) and createApp(sources): further PMTiles archives are served at /v1/sources/{id}/...; the base map stays the default. No overlay is built. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WnTa759tUrAwMiSoif5g6w --- .npmrc | 2 + README.md | 19 +- docs/ARCHITECTURE.md | 19 + openapi/openapi.yaml | 56 ++- package-lock.json | 937 +++++++++---------------------------------- package.json | 7 +- src/archive.ts | 7 +- src/credits.ts | 20 +- src/index.ts | 373 +++++++++-------- src/sources.ts | 33 ++ test/helpers.ts | 9 +- test/openapi.test.ts | 2 + test/sources.test.ts | 93 +++++ 13 files changed, 637 insertions(+), 940 deletions(-) create mode 100644 src/sources.ts create mode 100644 test/sources.test.ts diff --git a/.npmrc b/.npmrc index 521a9f7..e4285a2 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,3 @@ legacy-peer-deps=true +# Wait a week before resolving newly published versions (npm 11.10+). +min-release-age=7 diff --git a/README.md b/README.md index 2b94ac6..030f45e 100644 --- a/README.md +++ b/README.md @@ -17,15 +17,16 @@ 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/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 | +| 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: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index efad255..f279f53 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -40,6 +40,25 @@ Status codes: a tile absent from the archive but inside its zoom range is `204` (cached for minutes); 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, diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index f3d6dfc..e28b110 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Nyuchi Maps - version: 0.0.1 + version: 0.1.0 summary: The Nyuchi platform base map. description: | Vector tiles, styles, glyphs and sprites for every Mukoko app and Nyuchi @@ -102,6 +102,53 @@ paths: "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} + "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] @@ -184,6 +231,13 @@ paths: "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`. diff --git a/package-lock.json b/package-lock.json index 6c708f1..2bf7564 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { "name": "nyuchi-maps", - "version": "0.0.1", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nyuchi-maps", - "version": "0.0.1", + "version": "0.1.0", "license": "Apache-2.0", "dependencies": { "@protomaps/basemaps": "5.7.2", "hono": "^4.13.12", - "pmtiles": "4.5.0" + "pmtiles": "4.5.0", + "yaml": "^2.9.1" }, "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.22.0", @@ -19,8 +20,7 @@ "prettier": "^3.9.9", "typescript": "^5.9.3", "vitest": "~4.1.11", - "wrangler": "^4.147.0", - "yaml": "^2.9.1" + "wrangler": "^4.147.0" } }, "node_modules/@cloudflare/kv-asset-handler": { @@ -690,9 +690,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz", - "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==", + "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" ], @@ -709,13 +709,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.1" + "@img/sharp-libvips-darwin-arm64": "1.3.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz", - "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==", + "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" ], @@ -732,13 +732,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.1" + "@img/sharp-libvips-darwin-x64": "1.3.4" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz", - "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==", + "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, @@ -746,7 +746,7 @@ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.2" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -756,9 +756,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz", - "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==", + "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" ], @@ -773,9 +773,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz", - "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==", + "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" ], @@ -790,9 +790,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz", - "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==", + "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" ], @@ -807,9 +807,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz", - "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==", + "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" ], @@ -824,9 +824,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz", - "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==", + "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" ], @@ -841,9 +841,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz", - "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==", + "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" ], @@ -858,9 +858,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz", - "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==", + "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" ], @@ -875,9 +875,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz", - "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==", + "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" ], @@ -892,9 +892,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz", - "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==", + "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" ], @@ -909,9 +909,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz", - "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==", + "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" ], @@ -926,9 +926,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz", - "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==", + "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" ], @@ -945,13 +945,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.1" + "@img/sharp-libvips-linux-arm": "1.3.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz", - "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==", + "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" ], @@ -968,13 +968,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.1" + "@img/sharp-libvips-linux-arm64": "1.3.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz", - "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==", + "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" ], @@ -991,13 +991,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.1" + "@img/sharp-libvips-linux-ppc64": "1.3.4" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz", - "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==", + "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" ], @@ -1014,13 +1014,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.1" + "@img/sharp-libvips-linux-riscv64": "1.3.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz", - "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==", + "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" ], @@ -1037,13 +1037,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.1" + "@img/sharp-libvips-linux-s390x": "1.3.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz", - "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==", + "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" ], @@ -1060,13 +1060,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.1" + "@img/sharp-libvips-linux-x64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz", - "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==", + "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" ], @@ -1083,13 +1083,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz", - "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==", + "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" ], @@ -1106,18 +1106,18 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.1" + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz", - "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==", + "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.1" + "@emnapi/runtime": "^1.11.3" }, "engines": { "node": ">=20.9.0" @@ -1127,9 +1127,9 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz", - "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==", + "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" ], @@ -1137,7 +1137,7 @@ "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.2" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -1147,9 +1147,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz", - "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==", + "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" ], @@ -1167,9 +1167,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz", - "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==", + "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" ], @@ -1187,9 +1187,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz", - "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==", + "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" ], @@ -2369,15 +2369,15 @@ } }, "node_modules/sharp": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz", - "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==", + "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.4" + "semver": "^7.8.5" }, "engines": { "node": ">=20.9.0" @@ -2386,31 +2386,36 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.2", - "@img/sharp-darwin-x64": "0.35.2", - "@img/sharp-freebsd-wasm32": "0.35.2", - "@img/sharp-libvips-darwin-arm64": "1.3.1", - "@img/sharp-libvips-darwin-x64": "1.3.1", - "@img/sharp-libvips-linux-arm": "1.3.1", - "@img/sharp-libvips-linux-arm64": "1.3.1", - "@img/sharp-libvips-linux-ppc64": "1.3.1", - "@img/sharp-libvips-linux-riscv64": "1.3.1", - "@img/sharp-libvips-linux-s390x": "1.3.1", - "@img/sharp-libvips-linux-x64": "1.3.1", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", - "@img/sharp-libvips-linuxmusl-x64": "1.3.1", - "@img/sharp-linux-arm": "0.35.2", - "@img/sharp-linux-arm64": "0.35.2", - "@img/sharp-linux-ppc64": "0.35.2", - "@img/sharp-linux-riscv64": "0.35.2", - "@img/sharp-linux-s390x": "0.35.2", - "@img/sharp-linux-x64": "0.35.2", - "@img/sharp-linuxmusl-arm64": "0.35.2", - "@img/sharp-linuxmusl-x64": "0.35.2", - "@img/sharp-webcontainers-wasm32": "0.35.2", - "@img/sharp-win32-arm64": "0.35.2", - "@img/sharp-win32-ia32": "0.35.2", - "@img/sharp-win32-x64": "0.35.2" + "@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": { @@ -2524,9 +2529,9 @@ } }, "node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "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": { @@ -3312,641 +3317,74 @@ "node": ">=18" } }, - "node_modules/wrangler/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" - ], + "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, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.4" - } - }, - "node_modules/wrangler/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" + "node": ">=18" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.4" + "@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/@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==", + "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": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], + "license": "MIT", "dependencies": { - "@img/sharp-wasm32": "0.35.5" + "@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": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": ">=22.0.0" } }, - "node_modules/wrangler/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" - ], + "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": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/wrangler/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/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/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/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" + "license": "MIT", + "engines": { + "node": ">=20.18.1" } }, "node_modules/wrangler/node_modules/workerd": { @@ -3996,7 +3434,6 @@ "version": "2.9.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", - "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/package.json b/package.json index f2da32a..b15e711 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nyuchi-maps", - "version": "0.0.1", + "version": "0.1.0", "private": true, "description": "Nyuchi Maps: the Nyuchi platform base-map service, a Cloudflare Worker serving PMTiles from R2", "license": "Apache-2.0", @@ -27,5 +27,10 @@ "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/src/archive.ts b/src/archive.ts index 95d3c4f..f8b3bd7 100644 --- a/src/archive.ts +++ b/src/archive.ts @@ -61,16 +61,13 @@ const caches = new WeakMap(); /** Tile bytes are passed through still compressed (see tileEncoding). */ const passThrough = async (buf: ArrayBuffer): Promise => buf; -export function openArchive(env: Env): PMTiles { +export function openArchive(env: Env, key: string): PMTiles { let cache = caches.get(env.MAPS_BUCKET); if (!cache) { cache = new SharedPromiseCache(200); caches.set(env.MAPS_BUCKET, cache); } - const source = new R2Source( - env.MAPS_BUCKET, - env.TILES_KEY || DEFAULT_TILES_KEY, - ); + const source = new R2Source(env.MAPS_BUCKET, key); // `cache` decompresses directories itself; this decompress only touches // tile bytes, which the client can inflate. return new PMTiles(source, cache, passThrough); diff --git a/src/credits.ts b/src/credits.ts index ad0e472..b1e0676 100644 --- a/src/credits.ts +++ b/src/credits.ts @@ -43,14 +43,20 @@ function escapeHtml(text: string): string { .replaceAll('"', """); } -/** HTML for TileJSON / MapLibre attribution controls. */ -export function attributionHtml(): string { - return CREDITS.map( - (c) => `${escapeHtml(c.text)}`, - ).join(" | "); +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(): string { - return CREDITS.map((c) => c.text).join(" | "); +export function attributionText(ids?: readonly string[]): string { + return select(ids) + .map((c) => c.text) + .join(" | "); } diff --git a/src/index.ts b/src/index.ts index e7fd676..e140e8f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import { Hono } from "hono"; +import { Hono, type Context } from "hono"; import { parse as parseYaml } from "yaml"; import openapiYaml from "../openapi/openapi.yaml"; import { @@ -23,9 +23,16 @@ import { jsonResponse, } from "./http"; import { 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, @@ -34,188 +41,224 @@ import { parseTile, } from "./validate"; -const app = new Hono(); +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); - -/** TileJSON 3.0.0 for the archive, carrying the credits. */ -app.get("/v1/tiles.json", async (c) => { - const header = await openArchive(c.env).getHeader(); - const ext = tileExt(header); - if (!ext) return jsonError(500, "unsupported_tile_type"); - const base = baseUrl(c.env, c.req.url); - return jsonResponse( - c, - { - tilejson: "3.0.0", - name: "Nyuchi Maps", - scheme: "xyz", - tiles: [`${base}/v1/tiles/{z}/{x}/{y}.${ext}`], - attribution: attributionHtml(), - minzoom: header.minZoom, - maxzoom: header.maxZoom, - bounds: [header.minLon, header.minLat, header.maxLon, header.maxLat], - center: [header.centerLon, header.centerLat, header.centerZoom], - }, - CACHE_ASSET, - ); -}); + 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.get("/v1/tiles/:z/:x/:file", async (c) => { - const tile = parseTile( - c.req.param("z"), - c.req.param("x"), - c.req.param("file"), + app.use("*", cors); + + app.get("/health", () => + Response.json( + { status: "ok", service: "nyuchi-maps" }, + { headers: { "Cache-Control": NO_STORE } }, + ), ); - if (!tile) return jsonError(400, "invalid_tile_coordinates"); - - const edge = c.env.EDGE_CACHE === "off" ? null : caches.default; - const cacheKey = new Request(c.req.url, { method: "GET" }); - 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 }); + + 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 header = await openArchive(c.env, sourceKey(c.env, def)).getHeader(); + const ext = tileExt(header); + if (!ext) return jsonError(500, "unsupported_tile_type"); + 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}`], + 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; + const cacheKey = new Request(c.req.url, { method: "GET" }); + 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 hit; } - return hit; } - } - const archive = openArchive(c.env); - const header = await archive.getHeader(); - const ext = tileExt(header); - if (ext !== tile.ext) { - return jsonError(404, "format_mismatch", { "Cache-Control": CACHE_MISS }); - } - if (tile.z < header.minZoom || tile.z > header.maxZoom) { - return jsonError(404, "zoom_out_of_range", { - "Cache-Control": CACHE_MISS, + 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 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 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}"`); + } + const etag = headers.get("ETag"); + if (etag && etagMatches(c.req.header("If-None-Match"), etag)) { + return new Response(null, { status: 304, headers }); + } + + const res = new Response(found.data, { status: 200, headers }); + if (edge) { + try { + c.executionCtx.waitUntil(edge.put(cacheKey, res.clone())); + } catch { + // No execution context (tests): skip the write. + } + } + return res; } - const found = await archive.getZxy(tile.z, tile.x, tile.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 }, - }); + function lookup( + sources: Readonly>, + id: string, + ): SourceDef | null { + return isName(id) && Object.hasOwn(sources, id) ? sources[id]! : null; } - const encoding = tileEncoding(header); - const headers = new Headers({ - "Content-Type": tileContentType(ext), - "Cache-Control": CACHE_TILE, + // 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"); + }); + + 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 style = name && buildStyle(name, baseUrl(c.env, c.req.url), lang); + if (!style) return jsonError(404, "style_not_found"); + return jsonResponse(c, style, CACHE_ASSET); }); - if (encoding) headers.set("Content-Encoding", encoding); - if (header.etag) { - headers.set("ETag", `"${header.etag}:${tile.z}/${tile.x}/${tile.y}"`); - } - const etag = headers.get("ETag"); - if (etag && etagMatches(c.req.header("If-None-Match"), etag)) { - return new Response(null, { status: 304, headers }); - } - const res = new Response(found.data, { status: 200, headers }); - if (edge) { - try { - c.executionCtx.waitUntil(edge.put(cacheKey, res.clone())); - } catch { - // No execution context (tests): skip the write. + /** Stream an R2 object with ETag/304 handling. */ + async function serveObject( + c: { + env: AppEnv["Bindings"]; + req: { header(n: string): string | undefined }; + }, + key: string, + contentType: string, + ): Promise { + const object = await c.env.MAPS_BUCKET.get(key); + 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 (etagMatches(c.req.header("If-None-Match"), object.httpEtag)) { + await object.body.cancel(); + return new Response(null, { status: 304, headers }); } + return new Response(object.body, { status: 200, headers }); } - return res; -}); - -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 style = name && buildStyle(name, baseUrl(c.env, c.req.url), lang); - if (!style) return jsonError(404, "style_not_found"); - return jsonResponse(c, style, CACHE_ASSET); -}); - -/** Stream an R2 object with ETag/304 handling. */ -async function serveObject( - c: { - env: AppEnv["Bindings"]; - req: { header(n: string): string | undefined }; - }, - key: string, - contentType: string, -): Promise { - const object = await c.env.MAPS_BUCKET.get(key); - if (!object) { + + 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 }); - } - const headers = new Headers({ - "Content-Type": contentType, - "Cache-Control": CACHE_ASSET, - ETag: object.httpEtag, }); - if (etagMatches(c.req.header("If-None-Match"), object.httpEtag)) { - await object.body.cancel(); - 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( + 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, - `fonts/${name}/${range}.pbf`, - "application/x-protobuf", + `sprites/${sprite.name}${sprite.retina ? "@2x" : ""}.${sprite.ext}`, + sprite.ext === "json" ? "application/json" : "image/png", ); - 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 app; +export default createApp(); 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/test/helpers.ts b/test/helpers.ts index d59d7aa..4e6b88e 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,5 +1,6 @@ import { zxyToTileId } from "pmtiles"; -import app from "../src/index"; +import app, { createApp } from "../src/index"; +import type { SourceDef } from "../src/sources"; import type { Env } from "../src/types"; export const BASE = "https://maps.test"; @@ -90,10 +91,14 @@ export async function call( env: Env, init?: RequestInit, ctx: ExecutionContext = makeCtx().ctx, + worker: { fetch: typeof app.fetch } = app, ): Promise { - return app.fetch(new Request(`${BASE}${path}`, init), env, ctx); + 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 { diff --git a/test/openapi.test.ts b/test/openapi.test.ts index 96938e8..b8a233d 100644 --- a/test/openapi.test.ts +++ b/test/openapi.test.ts @@ -5,6 +5,8 @@ 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", 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); + }); +}); From a27504d1819cd7b38410a3aad4e7cdabf4f46ce2 Mon Sep 17 00:00:00 2001 From: Bryan Fawcett Date: Sun, 11 Oct 2026 14:19:53 +0000 Subject: [PATCH 3/4] Set version back to 0.0.1 for the staging patch policy The PR targets staging, where the release version check allows the next patch (0.0.1); the minor bump belongs to the later staging to main release PR. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WnTa759tUrAwMiSoif5g6w --- openapi/openapi.yaml | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index e28b110..f872045 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Nyuchi Maps - version: 0.1.0 + version: 0.0.1 summary: The Nyuchi platform base map. description: | Vector tiles, styles, glyphs and sprites for every Mukoko app and Nyuchi diff --git a/package-lock.json b/package-lock.json index 2bf7564..1eafc04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nyuchi-maps", - "version": "0.1.0", + "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nyuchi-maps", - "version": "0.1.0", + "version": "0.0.1", "license": "Apache-2.0", "dependencies": { "@protomaps/basemaps": "5.7.2", diff --git a/package.json b/package.json index b15e711..dfd359b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nyuchi-maps", - "version": "0.1.0", + "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", From 653c01050f4d66d54e401a48071cc0bbdf05ba06 Mon Sep 17 00:00:00 2001 From: Bryan Fawcett Date: Fri, 16 Oct 2026 23:34:07 +0000 Subject: [PATCH 4/4] Fix review blockers: single gzip, cache poisoning; harden caching, limits, licences; add deploy workflow Blockers - Tile responses (origin, CORS rebuild, cache-hit, and the Cache API copy) set encodeBody "manual" so gzip tiles are not compressed twice; a real-runtime test (SELF) proves one gzip layer, on a miss, with CORS, and on a cache hit. - Use pmtiles' ResolvedValueCache (not SharedPromiseCache): a missing archive or a transient R2 error is never cached. Tests: 503 then 200 once uploaded; 500 then 200. Should-fix - jsonError no longer overwrites a caller's Cache-Control (404s keep CACHE_MISS). - Edge cache key is origin + path + TILES_VERSION (query strings cannot bypass it; bumping the var retires a replaced archive). - Vary: Origin on every response. - Rate limit: tiles counted only on an edge-cache miss; no-IP requests not limited; 1800/min; namespace_id 7402 (1001 is the Mukoko API's). - TileJSON vector_layers from the archive metadata. - Licences: OFL text and fonts/OFL.txt in the bucket layout, Devanagari stack, sprite licence still CONFIRM, Protomaps credit TODO records OSM-only attribution, one term (Produced Work). Nits: If-None-Match answered before R2 reads; archive caches renamed; styles memoised; preview_urls false; localhost moved to .dev.vars.example; reason for legacy-peer-deps; persist-credentials false; OpenAPI and architecture fixes; stricter dot-segment tests. Deploy workflow mirrors mukoko-api (main push and manual, verify first, production environment, wrangler-action pinned by SHA, smoke test /health). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WnTa759tUrAwMiSoif5g6w --- .dev.vars.example | 3 + .github/workflows/ci.yml | 6 ++ .github/workflows/deploy.yml | 145 +++++++++++++++++++++++++++++++++ .npmrc | 4 + NOTICE | 22 +++-- README.md | 47 ++++++++--- docs/ARCHITECTURE.md | 51 ++++++++---- licenses/OFL-1.1-Noto-Sans.txt | 93 +++++++++++++++++++++ openapi/openapi.yaml | 36 +++++++- scripts/build-extract.md | 23 ++++-- src/archive.ts | 101 ++++++++++++++++++++--- src/cors.ts | 16 ++-- src/credits.ts | 10 ++- src/http.ts | 30 ++++++- src/index.ts | 87 ++++++++++++++------ src/ratelimit.ts | 52 ++++++++---- src/types.ts | 2 + test/assets.test.ts | 18 ++-- test/cors-ratelimit.test.ts | 77 ++++++++++++++--- test/encoding.test.ts | 71 ++++++++++++++++ test/env.d.ts | 10 +++ test/helpers.ts | 32 +++++++- test/metadata.test.ts | 29 +++++++ test/tilejson.test.ts | 21 +++++ test/tiles.test.ts | 78 ++++++++++++++++-- wrangler.jsonc | 24 ++++-- 26 files changed, 945 insertions(+), 143 deletions(-) create mode 100644 .dev.vars.example create mode 100644 .github/workflows/deploy.yml create mode 100644 licenses/OFL-1.1-Noto-Sans.txt create mode 100644 test/encoding.test.ts create mode 100644 test/env.d.ts create mode 100644 test/metadata.test.ts 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/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab57589..37e53c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,8 @@ jobs: 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 }} @@ -43,6 +45,8 @@ jobs: 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 }} @@ -55,6 +59,8 @@ jobs: 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 }} 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/.npmrc b/.npmrc index e4285a2..876f80b 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +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/NOTICE b/NOTICE index 2033895..2e3831d 100644 --- a/NOTICE +++ b/NOTICE @@ -14,15 +14,17 @@ OpenStreetMap data (c) OpenStreetMap contributors Licence: Open Database Licence (ODbL) 1.0 https://www.openstreetmap.org/copyright - Tile archives built from OpenStreetMap are a Derived Database. Keep the - credit "(c) OpenStreetMap contributors" visible wherever a map is shown, - and offer any published tile file under the ODbL. + 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. - CONFIRM whether the Protomaps build terms ask for a Protomaps credit; if so - add it to src/credits.ts (see the TODO there). + 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) @@ -49,11 +51,15 @@ yaml 2.x (serves the OpenAPI document as JSON) Assets stored in the R2 bucket (not in this repository) ------------------------------------------------------------------------ -Glyph PBFs (fonts/) built from Noto Sans +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 the licence of that repository's font and sprite files when the - assets are imported. + 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 030f45e..c04fafc 100644 --- a/README.md +++ b/README.md @@ -30,17 +30,20 @@ owner provisions the DNS record. Behaviour worth knowing: -- Tiles are passed through still compressed (`Content-Encoding: gzip`), with a - long edge cache (`s-maxage` 30 days), an `ETag` and `304` on `If-None-Match`. +- 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 (it fails open). - `429` carries `Retry-After`. +- 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. `?lang=sn` on a style picks the label language. + allow-listed origin. Every response carries `Vary: Origin`. `?lang=sn` on a style picks the label language. Use it from MapLibre by style URL: @@ -67,16 +70,22 @@ this first slice; see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). | `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` is off, no route is enabled and observability -is on. Nothing in this repo creates a bucket, a DNS record or a deployment. +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/{font name}/{range}.pbf e.g. fonts/Noto Sans Regular/0-255.pbf +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} ``` @@ -93,6 +102,18 @@ 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 @@ -114,10 +135,12 @@ threads) and the five lint checks. Releases follow the same convention as ## Licences - **Code** (the Worker, tests, scripts): Apache-2.0, see [LICENSE](LICENSE). -- **Map data**: tile files built from OpenStreetMap are a derived database under - the Open Database Licence (ODbL) 1.0. Credit **"© OpenStreetMap contributors"** - wherever a map is shown (the TileJSON carries it). Any tile file we publish - must be offered under the ODbL. +- **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). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f279f53..3d0c5b2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,12 +4,12 @@ Nyuchi Maps is platform infrastructure. One Worker, one R2 bucket, no tile server and no database. ```text -browser / app Cloudflare edge R2 (one bucket) -MapLibre ──GET──► Cache API ─miss─► Worker (Hono) ──range──► tiles/nyuchi-maps.pmtiles - │ fonts/… sprites/… - ├─ CORS allow-list - ├─ per-IP rate limit (RATE_LIMITER) - └─ validation, ETag, Cache-Control +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 @@ -17,8 +17,13 @@ MapLibre ──GET──► Cache API ─miss─► Worker (Hono) ──range 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`) on `/v1/*`, keyed by `CF-Connecting-IP`. - It fails open so a limiter fault cannot take the map down. +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 @@ -26,18 +31,32 @@ MapLibre ──GET──► Cache API ─miss─► Worker (Hono) ──range 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 (keyed by the archive ETag); a replaced archive surfaces as an ETag - mismatch, which the library retries. + 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`, a strong `ETag` (`"://"`), + `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; CORS headers are applied after the + 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); bad coordinates `400`; archive missing from the bucket +(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 @@ -65,12 +84,14 @@ without registering one. 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); styles + 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. @@ -96,7 +117,7 @@ and enables it. 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. Edge-cached tiles age out within `s-maxage` (30 days) unless purged. A +key, bump `TILES_VERSION` and deploy (see `scripts/build-extract.md`). A scheduled refresh (a cron Worker) is a later step. ## Out of scope 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 index f872045..70916ae 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -7,8 +7,9 @@ info: 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; credits are carried by the TileJSON and must be - displayed by every map. + 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 @@ -93,8 +94,16 @@ paths: 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"} @@ -142,6 +151,16 @@ paths: 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"} @@ -265,6 +284,10 @@ components: 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: @@ -298,12 +321,17 @@ components: application/json: schema: {$ref: "#/components/schemas/Error"} NotFound: - description: Unknown name, tile outside the archive's zoom range, or missing object. + 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 the `Retry-After` seconds. + 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: diff --git a/scripts/build-extract.md b/scripts/build-extract.md index a57aac3..0d77641 100644 --- a/scripts/build-extract.md +++ b/scripts/build-extract.md @@ -55,14 +55,18 @@ brotli or zstd archives are refused with a 500. ## 5. Fonts and sprites -The styles use the `Noto Sans Regular`, `Noto Sans Medium` and -`Noto Sans Italic` font stacks and the `light` and `dark` sprites. Take the +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 ``` @@ -82,7 +86,14 @@ need an S3-compatible client such as `rclone` instead of `wrangler`. ## Refreshing -Rebuild from a newer `BUILD` date and upload over the same key. The Worker -reads the archive by ETag, so a replaced object is picked up on the next -mismatch; edge-cached tiles age out within their `s-maxage` (30 days). Purge -the cache if the change must show sooner. +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/src/archive.ts b/src/archive.ts index f8b3bd7..0a8c08f 100644 --- a/src/archive.ts +++ b/src/archive.ts @@ -2,7 +2,7 @@ import { Compression, EtagMismatch, PMTiles, - SharedPromiseCache, + ResolvedValueCache, TileType, type Header, type RangeResponse, @@ -52,25 +52,104 @@ class R2Source implements Source { } /** - * Directory and header caches live per bucket binding, per isolate. The - * header is read once and each directory once; the cache key includes the - * archive ETag, so a replaced archive is picked up on the next mismatch. + * 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 caches = new WeakMap(); +const archiveCaches = new WeakMap(); /** Tile bytes are passed through still compressed (see tileEncoding). */ const passThrough = async (buf: ArrayBuffer): Promise => buf; -export function openArchive(env: Env, key: string): PMTiles { - let cache = caches.get(env.MAPS_BUCKET); +function cacheFor(env: Env): ResolvedValueCache { + let cache = archiveCaches.get(env.MAPS_BUCKET); if (!cache) { - cache = new SharedPromiseCache(200); - caches.set(env.MAPS_BUCKET, 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); - // `cache` decompresses directories itself; this decompress only touches + // The cache decompresses directories itself; this decompress only touches // tile bytes, which the client can inflate. - return new PMTiles(source, cache, passThrough); + 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> = { diff --git a/src/cors.ts b/src/cors.ts index cb9128e..1dd10fd 100644 --- a/src/cors.ts +++ b/src/cors.ts @@ -1,5 +1,5 @@ import type { MiddlewareHandler } from "hono"; -import { jsonError } from "./http"; +import { jsonError, rebuild } from "./http"; import type { AppEnv } from "./types"; /** Split the ALLOWED_ORIGINS var into trimmed, non-empty entries. */ @@ -66,13 +66,13 @@ export const cors: MiddlewareHandler = async (c, next) => { await next(); // Rebuild the response: ones from the Cache API have immutable headers. - const res = new Response(c.res.body, c.res); - if (origin !== undefined) { - res.headers.append("Vary", "Origin"); - if (ok) { - res.headers.set("Access-Control-Allow-Origin", origin); - res.headers.set("Access-Control-Expose-Headers", EXPOSE); - } + // 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 index b1e0676..4c2eb34 100644 --- a/src/credits.ts +++ b/src/credits.ts @@ -29,10 +29,12 @@ export const CREDITS: readonly Credit[] = [ text: "© OpenStreetMap contributors", url: "https://www.openstreetmap.org/copyright", }, - // TODO(protomaps): add a Protomaps credit here ONLY if the licence of the - // Protomaps basemap builds / basemap style asks for one. Check the terms - // at https://docs.protomaps.com/ before the first public release; until - // then it is deliberately absent so the credit list stays what is required. + // 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 { diff --git a/src/http.ts b/src/http.ts index f6925ec..70659d5 100644 --- a/src/http.ts +++ b/src/http.ts @@ -29,6 +29,34 @@ export function baseUrl(env: Env, requestUrl: string): string { 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, @@ -36,7 +64,7 @@ export function jsonError( ): Response { const headers = new Headers(extra); headers.set("Content-Type", "application/json; charset=utf-8"); - headers.set("Cache-Control", NO_STORE); + if (!headers.has("Cache-Control")) headers.set("Cache-Control", NO_STORE); return new Response(JSON.stringify({ error }), { status, headers }); } diff --git a/src/index.ts b/src/index.ts index e140e8f..c7e34dc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { ArchiveMissingError, UnsupportedCompressionError, openArchive, + vectorLayers, tileContentType, tileEncoding, tileExt, @@ -21,8 +22,10 @@ import { etagMatches, jsonError, jsonResponse, + rebuild, + responseInit, } from "./http"; -import { rateLimit } from "./ratelimit"; +import { checkRateLimit, rateLimit } from "./ratelimit"; import { DEFAULT_SOURCE_ID, SOURCES, @@ -82,9 +85,14 @@ export function createApp( /** TileJSON 3.0.0 for one source, carrying its credits. */ async function tileJson(c: Ctx, def: SourceDef): Promise { - const header = await openArchive(c.env, sourceKey(c.env, def)).getHeader(); + 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 @@ -100,6 +108,7 @@ export function createApp( : `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, @@ -119,7 +128,13 @@ export function createApp( if (!req) return jsonError(400, "invalid_tile_coordinates"); const edge = c.env.EDGE_CACHE === "off" ? null : caches.default; - const cacheKey = new Request(c.req.url, { method: "GET" }); + // 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) { @@ -127,10 +142,14 @@ export function createApp( if (etag && etagMatches(c.req.header("If-None-Match"), etag)) { return new Response(null, { status: 304, headers: hit.headers }); } - return hit; + 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); @@ -143,15 +162,6 @@ export function createApp( }); } - 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 encoding = tileEncoding(header); const headers = new Headers({ "Content-Type": tileContentType(ext), @@ -161,15 +171,32 @@ export function createApp( 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 res = new Response(found.data, { status: 200, 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 { - c.executionCtx.waitUntil(edge.put(cacheKey, res.clone())); + // 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. } @@ -198,25 +225,38 @@ export function createApp( 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 style = name && buildStyle(name, baseUrl(c.env, c.req.url), 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 with ETag/304 handling. */ + /** + * Stream an R2 object. R2 evaluates If-None-Match itself (onlyIf), so a + * matching validator never transfers the body. + */ async function serveObject( - c: { - env: AppEnv["Bindings"]; - req: { header(n: string): string | undefined }; - }, + c: Ctx, key: string, contentType: string, ): Promise { - const object = await c.env.MAPS_BUCKET.get(key); + 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 }); } @@ -225,8 +265,7 @@ export function createApp( "Cache-Control": CACHE_ASSET, ETag: object.httpEtag, }); - if (etagMatches(c.req.header("If-None-Match"), object.httpEtag)) { - await object.body.cancel(); + if (!("body" in object) || !object.body) { return new Response(null, { status: 304, headers }); } return new Response(object.body, { status: 200, headers }); diff --git a/src/ratelimit.ts b/src/ratelimit.ts index efed985..ed55a08 100644 --- a/src/ratelimit.ts +++ b/src/ratelimit.ts @@ -1,24 +1,48 @@ -import type { MiddlewareHandler } from "hono"; +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. + * + * - 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 const rateLimit: MiddlewareHandler = async (c, next) => { +export async function checkRateLimit( + c: Context, +): Promise { const limiter = c.env.RATE_LIMITER; - if (limiter) { - const ip = c.req.header("CF-Connecting-IP") ?? "unknown"; - 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); - } + 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/types.ts b/src/types.ts index 7f435d8..4cc176d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,6 +9,8 @@ export interface Env { 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; } diff --git a/test/assets.test.ts b/test/assets.test.ts index ffcdcea..074529c 100644 --- a/test/assets.test.ts +++ b/test/assets.test.ts @@ -41,11 +41,17 @@ describe("GET /v1/fonts/{stack}/{range}.pbf", () => { { 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()); - expect((await call("/v1/fonts/Nope/0-255.pbf", env)).status).toBe(404); + 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); @@ -69,7 +75,7 @@ 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([400, 404]).toContain(res.status); + expect(res.status).toBe(404); // normalised to /v1/fonts/0-255.pbf expect(b.gets).toHaveLength(0); }); }); @@ -96,9 +102,11 @@ describe("GET /v1/sprites/{name}[@2x].(json|png)", () => { }); it("404s a sprite it does not hold", async () => { - expect( - (await call("/v1/sprites/dark.json", makeEnv(bucket()))).status, - ).toBe(404); + 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([ diff --git a/test/cors-ratelimit.test.ts b/test/cors-ratelimit.test.ts index 3642ca5..30f8223 100644 --- a/test/cors-ratelimit.test.ts +++ b/test/cors-ratelimit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { isOriginAllowed, parseAllowedOrigins } from "../src/cors"; -import { StubBucket, buildPmtiles, call, makeEnv } from "./helpers"; +import { StubBucket, buildPmtiles, call, makeCtx, makeEnv } from "./helpers"; const bucket = () => new StubBucket().put( @@ -63,6 +63,10 @@ describe("CORS headers", () => { 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 () => { @@ -94,14 +98,17 @@ describe("CORS headers", () => { }); 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: { limit: async () => ({ success: false }) }, - }); - const res = await call("/v1/tiles/0/0/0.mvt", env); + 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 () => { @@ -111,17 +118,57 @@ describe("rate limiting", () => { limit: async ({ key }) => (keys.push(key), { success: true }), }, }); - await call("/v1/tiles/0/0/0.mvt", env, { - headers: { "CF-Connecting-IP": "203.0.113.7" }, - }); + await call("/v1/tiles/0/0/0.mvt", env, { headers: IP }); expect(keys).toEqual(["203.0.113.7"]); }); - it("never limits /health", async () => { + 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(), { - RATE_LIMITER: { limit: async () => ({ success: false }) }, + EDGE_CACHE: "on", + TILES_VERSION: "v-limit-1", + RATE_LIMITER: { limit: async () => (calls++, { success: true }) }, }); - expect((await call("/health", env)).status).toBe(200); + 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 () => { @@ -132,8 +179,12 @@ describe("rate limiting", () => { }, }, }); - expect((await call("/v1/tiles/0/0/0.mvt", broken)).status).toBe(200); + 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)).status).toBe(200); + expect( + (await call("/v1/tiles/0/0/0.mvt", absent, { headers: IP })).status, + ).toBe(200); }); }); 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 index 4e6b88e..21f66cd 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -21,6 +21,8 @@ export interface GetCall { 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 = @@ -33,10 +35,15 @@ export class StubBucket { key: string, options?: { range?: { offset: number; length: number }; - onlyIf?: { etagMatches?: string }; + 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 = { @@ -45,8 +52,22 @@ export class StubBucket { httpEtag: `"${stored.etag}"`, size: stored.data.length, }; - const want = options?.onlyIf?.etagMatches; - if (want && want !== stored.etag) return meta; // precondition failed: no body + 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, @@ -129,6 +150,8 @@ export interface ArchiveOptions { 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. */ @@ -141,6 +164,7 @@ export function buildPmtiles( tileCompression = 1, minZoom = 0, maxZoom = 3, + metadata: metadataObject = {}, } = options; const sorted = tiles .map((t) => ({ ...t, id: zxyToTileId(t.z, t.x, t.y) })) @@ -159,7 +183,7 @@ export function buildPmtiles( for (const t of sorted) varint(t.data.length, dirBytes); sorted.forEach((_, i) => varint(i === 0 ? 1 : 0, dirBytes)); - const metadata = new TextEncoder().encode("{}"); + const metadata = new TextEncoder().encode(JSON.stringify(metadataObject)); const rootOffset = 127; const rootLength = dirBytes.length; const metaOffset = rootOffset + rootLength; 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/tilejson.test.ts b/test/tilejson.test.ts index 5c3c6d3..56e92ad 100644 --- a/test/tilejson.test.ts +++ b/test/tilejson.test.ts @@ -7,6 +7,9 @@ function bucketWithArchive() { "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", ); @@ -27,6 +30,9 @@ describe("GET /v1/tiles.json", () => { 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 () => { @@ -77,3 +83,18 @@ describe("GET /v1/tiles.json", () => { 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 index d6ab164..361cf18 100644 --- a/test/tiles.test.ts +++ b/test/tiles.test.ts @@ -68,11 +68,14 @@ describe("GET /v1/tiles/{z}/{x}/{y}.mvt", () => { 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 () => { @@ -88,6 +91,9 @@ describe("GET /v1/tiles/{z}/{x}/{y}.mvt", () => { 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 () => { @@ -95,6 +101,9 @@ describe("GET /v1/tiles/{z}/{x}/{y}.mvt", () => { 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([ @@ -113,13 +122,48 @@ describe("GET /v1/tiles/{z}/{x}/{y}.mvt", () => { expect(bucket.gets).toHaveLength(0); }); - it("never reads R2 for dot-segment paths (the URL is normalised away)", async () => { + 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([400, 404]).toContain(res.status); + 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); @@ -137,18 +181,40 @@ describe("GET /v1/tiles/{z}/{x}/{y}.mvt", () => { expect(await res.json()).toEqual({ error: "unsupported_tile_compression" }); }); - it("serves a repeat request from the edge cache", async () => { + 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" }); + const env = makeEnv(bucket, { + EDGE_CACHE: "on", + TILES_VERSION: "v-edge-1", + }); const { ctx, settle } = makeCtx(); - const path = "/v1/tiles/0/0/0.mvt?edge-cache-test=1"; + 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, env, undefined, ctx); + 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/wrangler.jsonc b/wrangler.jsonc index 8796bf5..5f0f8d8 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -11,9 +11,10 @@ "main": "src/index.ts", "compatibility_date": "2026-08-01", - // No *.workers.dev URL: the Worker is reachable only through the route - // below, once the owner enables it. + // 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 }], @@ -28,10 +29,15 @@ // 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. - "ALLOWED_ORIGINS": "https://weather.mukoko.com,https://weatherstations.nyuchi.com,http://localhost:3000", + // "*." 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: @@ -43,13 +49,15 @@ }, ], - // Per-IP limit on /v1 routes. namespace_id is ours to choose; it is unique - // per account, not a resource to create first. + // 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": "1002", - "simple": { "limit": 600, "period": 60 }, + "namespace_id": "7402", + "simple": { "limit": 1800, "period": 60 }, }, ],