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 +36 -0
- package/README.md +2 -0
- package/docs/haproxy.md +12 -1
- package/docs/serving-tiles.md +41 -2
- package/package.json +1 -1
- package/src/api.js +169 -14
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=
|
|
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
|
package/docs/serving-tiles.md
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
2100
|
-
|
|
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
|
|
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
|
-
//
|
|
2128
|
-
//
|
|
2129
|
-
//
|
|
2130
|
-
|
|
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
|
-
//
|
|
2158
|
-
//
|
|
2159
|
-
//
|
|
2160
|
-
// the
|
|
2161
|
-
|
|
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
|
|
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: {
|
|
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;
|