pmtiles-swarm 0.48.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,42 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.49.0
11
+ ### ✨ Features and improvements
12
+ - **The current build of a category can be read as a file.** `GET /latest/<category>/archive.pmtiles`
13
+ is what `/archives/<infohash>/archive.pmtiles` is, addressed the way a style or a long-lived config
14
+ wants to address it: by what it is rather than by which build it happens to be. Point any PMTiles
15
+ reader at it and it keeps working across rebuilds, with no infohash to chase.
16
+
17
+ - **Every `/latest/` endpoint now carries an ETag, and the tag is the infohash.** Everything under
18
+ `/archives/` is content-addressed and cached for a year, because the URL changes when the content
19
+ does. A `/latest/` URL is the opposite — stable on purpose, so the content underneath it moves and
20
+ the URL alone gives a cache no way to notice. These endpoints had a five-minute TTL and nothing
21
+ else, which is a guess: for those five minutes every client and every proxy in front of one serves
22
+ the previous build and not one of them can tell. They are now `max-age=60, must-revalidate` with a
23
+ validator that changes exactly when the archive does — and, because it is the infohash, one that
24
+ two nodes behind a load balancer agree on instead of each deriving its own from a body hash or an
25
+ mtime. The TileJSON, the `.torrent` redirect, the magnet, the single-item feed and the `/latest/`
26
+ index are all covered.
27
+
28
+ ### 🐞 Bug fixes
29
+ - **A range request could splice two builds together.** `/latest/<category>/archive.pmtiles` honours
30
+ `If-Range`, and refuses as a range — answering in full instead — anything conditioned on a build
31
+ that is no longer current. This is the failure the ETag exists to prevent: a PMTiles reader does
32
+ not fetch a file, it fetches a header, then a root directory, then leaf directories, then tiles,
33
+ over minutes or hours. A rebuild landing partway through leaves it reading old offsets against new
34
+ bytes, which decodes as the wrong tile or as nothing, with no error anywhere naming the cause.
35
+
36
+ - **`/archives/<infohash>/archive.pmtiles` was unreachable from a browser on another origin**, and
37
+ sent a validator no PMTiles reader would use. It had no CORS header at all — unlike the tile and
38
+ TileJSON routes beside it — so a page elsewhere could not fetch it. Its ETag was also whatever
39
+ Express derives from the file's size and mtime, which is weak (the official reader discards any tag
40
+ beginning with `W/`) and different on every node, so two nodes behind one load balancer would hand
41
+ a reader two tags for byte-identical archives and it would conclude the file had moved under it.
42
+ Both range routes now send the infohash as a strong tag, and both expose `ETag` and `Content-Range`
43
+ to cross-origin JavaScript — without which the reader compares against `null`, the comparison never
44
+ fires, and it splices builds in silence.
45
+
10
46
  ## 0.48.0
11
47
  ### ✨ Features and improvements
12
48
  - **An archive can now be read as a file, by byte range.** `GET /archives/<infohash>/archive.pmtiles`
package/README.md CHANGED
@@ -785,7 +785,9 @@ which the endpoint answers 501.
785
785
  | `GET` | `/archives/:infoHash/archive.torrent` | The `.torrent` a torrent-aware client joins with — **public** |
786
786
  | `GET` | `/archives/:infoHash/archive.pmtiles` | The archive itself, by byte range — what any PMTiles reader wants, and a valid web seed. Complete archives only — **public** |
787
787
  | `GET` | `/archives/:infoHash/preview` | Map preview for one archive — admin, not public |
788
+ | `GET` | `/latest/` | Every category and the endpoints that resolve to its newest build — **public**, the index of everything below |
788
789
  | `GET` | `/latest/:category/tiles.json`, `/:name.torrent`, `/magnet` | The newest build in a category — **public**. The torrent name is yours to choose, so a link can read `planetiler-openmaptiles-latest.torrent`; it redirects to the immutable URL, which names the download after the build it actually is |
790
+ | `GET` | `/latest/:category/archive.pmtiles` | The newest build in a category as a file, by byte range — **public**. What to point a PMTiles reader at when you want "whichever is current" rather than one build |
789
791
  | `GET` | `/feed.xml`, `/feed/:category.xml`, `/latest/:category.xml` | RSS — **public** |
790
792
 
791
793
  Everything under `/api/` is guarded once a credential is configured; tiles, TileJSON and the feeds
package/docs/haproxy.md CHANGED
@@ -220,7 +220,18 @@ says so, which is worse than having no check at all.
220
220
  **Cache tiles by infohash aggressively.** `/archives/<infohash>/…` is immutable
221
221
  by construction — an infohash names those bytes and no others — and is served
222
222
  with `max-age=31536000, immutable`. `/latest/<category>/…` is the opposite: it
223
- moves on every build and is served with `max-age=300`.
223
+ moves on every build, and is served with `max-age=60, must-revalidate` and an
224
+ ETag naming the build it resolved to.
225
+
226
+ **Do not strip or rewrite the ETag on either.** It is the infohash, which is
227
+ how a PMTiles reader notices that the archive moved underneath a read already
228
+ in progress — a read that spans minutes, because the reader fetches a header,
229
+ then directories, then tiles. A proxy that drops the tag leaves the reader
230
+ comparing against nothing, and it will assemble one file out of two builds
231
+ without an error anywhere. Compression is the usual culprit: a proxy that
232
+ gzips a response is required to weaken the tag, and a weak tag is one the
233
+ reader discards. Archives are served as `application/octet-stream` and should
234
+ not be compressed at all.
224
235
 
225
236
  WebRTC does not pass through Cloudflare, and neither does BitTorrent. Browser
226
237
  peers reach the node over ICE, and a `wss://` tracker is the only part of that
@@ -112,6 +112,7 @@ category is already the grouping, so it is what "latest" is asked of:
112
112
 
113
113
  ```
114
114
  GET /latest/{category}/tiles.json TileJSON for the newest in that category
115
+ GET /latest/{category}/archive.pmtiles the newest build itself, by byte range
115
116
  GET /latest/{category}/archive.torrent 302 to that build's .torrent
116
117
  GET /latest/{category}/magnet its magnet URI
117
118
  GET /latest/{category}.xml a feed holding only the current build
@@ -127,8 +128,7 @@ template at `/latest/` instead would make every tile a moving target and throw
127
128
  that away — a client would have no way to know whether two tiles came from the
128
129
  same build.
129
130
 
130
- So it is cached for five minutes rather than a year, and carries a `latest`
131
- block naming what it resolved to:
131
+ So it carries a `latest` block naming what it resolved to:
132
132
 
133
133
  ```json
134
134
  {
@@ -149,6 +149,45 @@ following along to the next one.
149
149
  Categories that are not published are not resolvable here either — `/latest/`
150
150
  answers 404 for them exactly as the feeds do.
151
151
 
152
+ ### How a client knows the build moved
153
+
154
+ Every one of these carries an `ETag`, and the tag is the infohash of the archive
155
+ it resolved to:
156
+
157
+ ```
158
+ ETag: "913d671f3a28c5b8d605e28cf6bf01e293d36e86"
159
+ Cache-Control: public, max-age=60, must-revalidate
160
+ ```
161
+
162
+ A short TTL on its own is a guess. At five minutes, every client and every proxy
163
+ in front of one serves the previous build for up to five minutes after a rollover
164
+ and not one of them can tell it is doing so. The infohash is the honest answer:
165
+ it changes exactly when the archive changes, never otherwise, and it is the same
166
+ value on every node in the swarm — so two nodes behind a load balancer agree
167
+ about what is current rather than each inventing a tag from a body hash or an
168
+ mtime.
169
+
170
+ For `/latest/{category}/archive.pmtiles` this is not a nicety. A PMTiles reader
171
+ does not fetch a file; it fetches a header, then a root directory, then leaf
172
+ directories, then tiles, over minutes or hours. If a rebuild lands partway
173
+ through, offsets read from the old build address bytes in the new one — which
174
+ does not fail loudly, it decodes as the wrong tile or as nothing. So `If-Range`
175
+ is honoured: a range conditioned on a build that is no longer current is refused
176
+ _as a range_ and answered in full. The official PMTiles JavaScript reader closes
177
+ the loop from the other side, comparing the ETag of every response against the
178
+ one it saw first and re-reading the header when they differ.
179
+
180
+ Two consequences worth knowing about:
181
+
182
+ - **The tag must survive the proxy.** A proxy that strips it leaves the reader
183
+ comparing against nothing. One that gzips the response is required to weaken
184
+ it, and the reader discards any tag beginning with `W/`. Archives go out as
185
+ `application/octet-stream` and should not be compressed.
186
+ - **A browser must be allowed to read it.** `ETag` is not among the handful of
187
+ response headers exposed to cross-origin JavaScript by default, so these
188
+ routes send `Access-Control-Expose-Headers`. Without it the reader sees
189
+ `null`, the comparison never fires, and it splices two builds in silence.
190
+
152
191
  ## Where the bytes come from
153
192
 
154
193
  This is the part that makes serving tiles from a torrent client worth doing at
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.48.0",
3
+ "version": "0.49.0",
4
4
  "description": "BitTorrent distribution for PMTiles map archives: create torrents, watch folders, publish and subscribe to RSS feeds, and seed through qBittorrent or an embedded client",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/api.js CHANGED
@@ -1993,6 +1993,38 @@ export function createApp({
1993
1993
  );
1994
1994
  };
1995
1995
 
1996
+ /**
1997
+ * Tags a response as "whichever build of this category is current".
1998
+ *
1999
+ * Everything under `/archives/` is addressed by infohash and may be cached
2000
+ * for a year, because the URL changes when the content does. A `/latest/`
2001
+ * URL is the exact opposite: it is stable on purpose, so the content
2002
+ * underneath it moves and the URL alone gives a cache no way to notice. A
2003
+ * short TTL was the only thing these endpoints had, and a TTL is a guess —
2004
+ * at five minutes, every client and every proxy in front of one serves the
2005
+ * previous build for up to five minutes after a rollover, and not one of
2006
+ * them can tell whether it is doing so.
2007
+ *
2008
+ * The infohash is the honest validator. It changes exactly when the archive
2009
+ * a category resolves to changes, never otherwise, and it is the same value
2010
+ * on every node in the swarm — so two nodes behind a load balancer agree
2011
+ * about what is current, rather than each inventing a tag of its own from a
2012
+ * body hash or an mtime and defeating the cache whenever a client is sent
2013
+ * to the other one.
2014
+ * @param {import('express').Response} res - The response to tag.
2015
+ * @param {object} entry - The archive this URL resolved to.
2016
+ */
2017
+ const tagAsLatest = (res, entry) => {
2018
+ // Quoted, because that is what an entity-tag is. Unquoted, a validator
2019
+ // never matches an If-None-Match and every revalidation misses silently.
2020
+ res.setHeader('etag', `"${entry.infoHash}"`);
2021
+ // must-revalidate because serving this one stale is not merely "slightly
2022
+ // old": a reader holding part of one build and asking for the rest after
2023
+ // a rollover assembles a file that never existed. A minute of cache with
2024
+ // a mandatory check at the end of it is the trade.
2025
+ res.setHeader('cache-control', 'public, max-age=60, must-revalidate');
2026
+ };
2027
+
1996
2028
  // Everything a category offers, in one place. A category is the only stable
1997
2029
  // handle this system has — every archive is addressed by infohash, which is
1998
2030
  // what makes a tile immutable, but leaves nothing for a style to point at
@@ -2096,14 +2128,24 @@ export function createApp({
2096
2128
  '/latest/',
2097
2129
  route(async (req, res) => {
2098
2130
  res.setHeader('access-control-allow-origin', '*');
2099
- // Short, because the answer changes when a build lands.
2100
- res.setHeader('cache-control', 'public, max-age=60');
2131
+ const categories = describeCategories(req);
2132
+ // Tagged over the categories alone, deliberately. Express would happily
2133
+ // hash the whole body for us, but `generatedAt` is a fresh timestamp on
2134
+ // every request — so that tag would differ every time and no client
2135
+ // could ever revalidate. What a consumer polls this for is whether the
2136
+ // set of categories or the build each resolves to has moved, and that
2137
+ // is exactly what this covers.
2138
+ res.setHeader(
2139
+ 'etag',
2140
+ `"${crypto.createHash('sha1').update(JSON.stringify(categories)).digest('hex')}"`,
2141
+ );
2142
+ res.setHeader('cache-control', 'public, max-age=60, must-revalidate');
2101
2143
  res.json({
2102
2144
  // Named so a consumer can refuse a document it does not understand,
2103
2145
  // the same as the catalogue does.
2104
2146
  format: 'pmtiles-swarm-categories/1',
2105
2147
  generatedAt: new Date().toISOString(),
2106
- categories: describeCategories(req),
2148
+ categories,
2107
2149
  });
2108
2150
  }),
2109
2151
  );
@@ -2124,10 +2166,13 @@ export function createApp({
2124
2166
  }
2125
2167
 
2126
2168
  res.setHeader('access-control-allow-origin', '*');
2127
- // Deliberately short-lived, and the only mutable document in the system.
2128
- // Everything it points at is content-addressed and cached for a year;
2129
- // this is the one thing that has to be re-read to notice a new build.
2130
- res.setHeader('cache-control', 'public, max-age=300');
2169
+ // Short-lived, and the only mutable document in the system: everything
2170
+ // it points at is content-addressed and cached for a year, and this is
2171
+ // the one thing that has to be re-read to notice a new build. The tag
2172
+ // makes that re-read cheap — a client already holding the current build
2173
+ // gets a 304 and no body, which Express does on its own once an ETag is
2174
+ // set before the response goes out.
2175
+ tagAsLatest(res, entry);
2131
2176
  res.json({
2132
2177
  ...buildTileJson(entry, baseUrl(req)),
2133
2178
  // Names what it resolved to, so a consumer can tell one build from the
@@ -2154,11 +2199,12 @@ export function createApp({
2154
2199
  app.get('/latest/:category/:name.torrent', (req, res) => {
2155
2200
  const entry = newestIn(req.params.category, req);
2156
2201
  if (!entry) return res.status(404).json({ error: 'no such category' });
2157
- // Short, and said explicitly. A 302 is not cacheable unless a response
2158
- // says so, but "unless it says so" is a thing intermediaries have been
2159
- // known to disagree about — and this one moves on every build, which is
2160
- // the whole point of it.
2161
- res.setHeader('cache-control', 'public, max-age=300');
2202
+ // A 302 is not cacheable unless a response says so, and "unless it says
2203
+ // so" is a thing intermediaries have been known to disagree about — so
2204
+ // this one says so, and names what it resolved to while it is at it. The
2205
+ // tag is the infohash being redirected to, which is precisely the thing
2206
+ // that changes when this redirect starts pointing somewhere else.
2207
+ tagAsLatest(res, entry);
2162
2208
  res.redirect(
2163
2209
  302,
2164
2210
  `${baseUrl(req)}/archives/${entry.infoHash}/archive.torrent`,
@@ -2168,10 +2214,102 @@ export function createApp({
2168
2214
  app.get('/latest/:category/magnet', (req, res) => {
2169
2215
  const entry = newestIn(req.params.category, req);
2170
2216
  if (!entry) return res.status(404).json({ error: 'no such category' });
2171
- res.setHeader('cache-control', 'public, max-age=300');
2217
+ tagAsLatest(res, entry);
2172
2218
  res.type('text/plain').send(entry.magnet ?? '');
2173
2219
  });
2174
2220
 
2221
+ /**
2222
+ * The headers a browser must be told it may read from a range response.
2223
+ *
2224
+ * Only a handful of response headers reach JavaScript on a cross-origin
2225
+ * fetch, and not one of the headers a PMTiles reader depends on is among
2226
+ * them. The official reader records the ETag of the first response and
2227
+ * compares every response after it against that value — which is how it
2228
+ * notices that the archive moved underneath a read in progress. Cross-origin
2229
+ * and unexposed, it reads `null` instead, a comparison against null never
2230
+ * fires, and it would splice two builds together in silence: the exact
2231
+ * failure the tag exists to prevent. Content-Range earns its place through a
2232
+ * narrower case — an archive smaller than the reader's first 16 KiB request
2233
+ * is answered 416, and it parses the real length out of that header before
2234
+ * retrying.
2235
+ */
2236
+ const RANGE_CORS_HEADERS = {
2237
+ 'access-control-allow-origin': '*',
2238
+ 'access-control-expose-headers':
2239
+ 'ETag, Content-Range, Content-Length, Accept-Ranges',
2240
+ };
2241
+
2242
+ /**
2243
+ * The current build of a category, as a file, by byte range.
2244
+ *
2245
+ * The same thing `/archives/<infohash>/archive.pmtiles` is, addressed the
2246
+ * way a style or a long-lived configuration wants to address it: by what it
2247
+ * is rather than by which build it happens to be. Point any PMTiles reader
2248
+ * at this and it keeps working across rebuilds, with no infohash to chase.
2249
+ *
2250
+ * The hazard is the one the immutable route never has to think about. A
2251
+ * PMTiles reader does not fetch a file; it fetches a header, then a root
2252
+ * directory, then leaf directories, then tiles, over minutes or hours. If a
2253
+ * rebuild lands partway through, the offsets it read from the old build
2254
+ * address bytes in the new one. That does not fail loudly — it decodes as
2255
+ * the wrong tile, or as nothing, with no error anywhere that names the
2256
+ * cause. So the validator is the infohash and `If-Range` is honoured: a
2257
+ * range conditioned on a build that is no longer current is refused as a
2258
+ * range and answered in full, which a reader survives, instead of being
2259
+ * spliced, which it does not.
2260
+ */
2261
+ app.get('/latest/:category/archive.pmtiles', (req, res) => {
2262
+ const entry = newestIn(req.params.category, req);
2263
+ if (!entry) return res.status(404).json({ error: 'no such category' });
2264
+
2265
+ if (entry.complete === false) {
2266
+ return res.status(409).json({
2267
+ error:
2268
+ 'the newest archive in this category is not complete here, so a ' +
2269
+ 'byte range would answer with unwritten space rather than data',
2270
+ });
2271
+ }
2272
+ const file = entry.savePath ? path.join(entry.savePath, entry.name) : null;
2273
+ if (!file) return res.status(404).json({ error: 'no file for it here' });
2274
+
2275
+ res.sendFile(
2276
+ file,
2277
+ {
2278
+ acceptRanges: true,
2279
+ // Set here rather than left to send: this URL means "whichever is
2280
+ // current", so the one claim it must never make is that the answer
2281
+ // cannot have changed.
2282
+ cacheControl: false,
2283
+ // Suppressed on purpose. With both validators present a client is
2284
+ // free to condition If-Range on the date, and a build restored from a
2285
+ // backup or copied with its timestamps intact is newer while looking
2286
+ // older — which passes a date comparison and splices two archives
2287
+ // together. The infohash cannot be fooled that way, so it is the only
2288
+ // validator offered here.
2289
+ lastModified: false,
2290
+ // Applied on send's `headers` event, which fires before it decides
2291
+ // anything — so these are the values its own freshness and If-Range
2292
+ // checks read, and the ETag it compares against is ours.
2293
+ headers: {
2294
+ 'content-type': 'application/octet-stream',
2295
+ ...RANGE_CORS_HEADERS,
2296
+ etag: `"${entry.infoHash}"`,
2297
+ 'cache-control': 'public, max-age=60, must-revalidate',
2298
+ },
2299
+ },
2300
+ (error) => {
2301
+ if (!error || res.headersSent) return;
2302
+ const missing = error.code === 'ENOENT';
2303
+ const status = missing ? 404 : (error.status ?? 500);
2304
+ res.status(status).json({
2305
+ error: missing
2306
+ ? 'the catalog has this archive but its file is not there'
2307
+ : error.message,
2308
+ });
2309
+ },
2310
+ );
2311
+ });
2312
+
2175
2313
  // The newest item on its own, for a subscriber that only ever wants the
2176
2314
  // current build and should not have to parse a backlog to find it.
2177
2315
  app.get('/latest/:category.xml', (req, res) => {
@@ -2180,6 +2318,11 @@ export function createApp({
2180
2318
  return res.status(404).json({ error: 'no such feed' });
2181
2319
  }
2182
2320
  const entry = newestIn(category, req);
2321
+ // A category with nothing in it has no build to name, so it gets no
2322
+ // validator: a tag meaning "still empty" is indistinguishable from one
2323
+ // meaning "still this build", and a reader would cache the emptiness past
2324
+ // the arrival of the thing it was waiting for.
2325
+ if (entry) tagAsLatest(res, entry);
2183
2326
  res.type('application/rss+xml').send(
2184
2327
  renderFeed(entry ? [entry] : [], {
2185
2328
  title: `${config.feedTitle ?? 'PMTiles archives'} — ${category}, latest`,
@@ -2473,7 +2616,19 @@ export function createApp({
2473
2616
  // own contents, so a byte at an offset is the same byte for ever.
2474
2617
  maxAge: '1y',
2475
2618
  immutable: true,
2476
- headers: { 'content-type': 'application/octet-stream' },
2619
+ headers: {
2620
+ 'content-type': 'application/octet-stream',
2621
+ ...RANGE_CORS_HEADERS,
2622
+ // Named by the infohash rather than left to send, which derives a
2623
+ // tag from the file's size and mtime. That one is weak — the
2624
+ // official reader discards any tag beginning with `W/` — and it is
2625
+ // different on every node, so two nodes behind one load balancer
2626
+ // would hand a reader two tags for byte-identical archives and it
2627
+ // would conclude the file had changed under it. The infohash is
2628
+ // strong, and it is the same everywhere in the swarm because it is
2629
+ // the content.
2630
+ etag: `"${entry.infoHash}"`,
2631
+ },
2477
2632
  },
2478
2633
  (error) => {
2479
2634
  if (!error || res.headersSent) return;