pmtiles-swarm 0.59.0 → 0.60.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,74 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.60.0
11
+ ### ✨ Features and improvements
12
+ - **A tile URL that survives a rebuild.** Every archive is addressed by infohash, which is
13
+ what makes a tile cacheable for a year and what makes it useless in an application: the URL
14
+ changes with every build, so anything that wrote one down is pinned to a build that
15
+ eventually stops existing. A category is the only stable handle this system has, and now it
16
+ has a tile endpoint of its own — `/latest/<category>/{z}/{x}/{y}.<ext>` — which resolves to
17
+ whichever build is current on every request.
18
+
19
+ The category TileJSON advertises it as `tiles`, so a style written once keeps working across
20
+ a rebuild without being re-fetched for the URLs alone. The immutable template is still
21
+ published beside it as `latest.tiles`, because it is still the better URL for anything that
22
+ can re-read the document: it is content-addressed, so it caches for a year and never
23
+ revalidates. Offering only one of the two would be choosing for the consumer, and the right
24
+ answer differs by consumer.
25
+
26
+ The console offers the template as a copyable **XYZ** button next to the TileJSON one, which
27
+ is the form a Leaflet layer, an OpenLayers source or a GIS client actually wants.
28
+
29
+ Deliberately not a redirect to the immutable URL, though every other `/latest/` route is one.
30
+ A redirect costs a round trip and a map asks for hundreds of tiles: what is a negligible
31
+ indirection for a `.torrent` is the difference between a map that feels immediate and one
32
+ that does not. It is cached as the moving target it is — `max-age=300, must-revalidate`,
33
+ tagged with the build it resolved to, so a revalidation is a 304 while that build stands and
34
+ a miss the moment it moves.
35
+
36
+ - **A benchmark for "why does this feel slower than the other one", in `tools/tile-bench.mjs`.**
37
+ It reads two TileJSON documents, picks tiles inside the zoom range and bounds both can serve,
38
+ and requests the same set from each — one server at a time, because run together they compete
39
+ for the same link and each measures the other's load as its own latency.
40
+
41
+ It reports percentiles rather than an average, since what makes a map feel slow is the tail
42
+ and a mean built from nineteen fast requests does not move for the twentieth. Time to first
43
+ byte is separated from the total, which is the difference between a slow lookup and a slow
44
+ link — they want opposite fixes.
45
+
46
+ It also detects a pool of unequal nodes, which is a common cause and an invisible one: half
47
+ the tiles arrive quickly and half do not, and balanced evenly the mean looks tolerable
48
+ throughout. Two distinct groups are reported as two, with a histogram, and `--header` tallies
49
+ a response header naming which backend answered. `--a-origin`/`--b-origin` send the tile
50
+ requests somewhere other than the document was read from, which is the only way to measure
51
+ one node directly: a node with `publicUrl` set answers with that name however it was asked.
52
+
53
+ - **`docs/haproxy.md` covers a pool whose nodes are not the same speed.** Round robin assumes
54
+ the pool is interchangeable, and a tile server on an NVMe disk and one on a spinning disk are
55
+ not — an archive read is a seek into a large file, which is what a spinning disk is worst at.
56
+ `backup`, `weight` and least-connections are compared, along with what each does and does not
57
+ fix.
58
+
59
+ ### 🐞 Bug fixes
60
+ - **An archive at 100% and seeding could serve no tiles until the node was restarted.** Which
61
+ source an archive is read through is decided once, when a reader opens it, and every other
62
+ thing that can change that answer already invalidates the reader: a pause, a resume, a mode
63
+ change, a move, a finished download. A finished *check* did not — and it is the easiest of
64
+ them to reach, because during `checking_files` libtorrent reports `progress` as the fraction
65
+ hashed so far, which is indistinguishable from a download sitting at the same figure.
66
+
67
+ So a tile read arriving mid-check opened against the swarm, correctly, and kept that handle
68
+ afterwards. The archive then read from a swarm whose only member is this node, while the
69
+ whole file sat on the disk beside it, and nothing evicted the handle short of a restart. The
70
+ completion sweep looked straight past it: an entry already recorded complete never reaches
71
+ the code that would have noticed.
72
+
73
+ The sweep now drops a reader that is going to the swarm for an archive the disk says is
74
+ whole. The disk is checked before the handle is dropped rather than after, or a reader that
75
+ would only re-open against the swarm anyway would be invalidated on every sweep, for ever.
76
+ Cache mode is left alone: it reads from the swarm because that is what it is for.
77
+
10
78
  ## 0.59.0
11
79
  ### ✨ Features and improvements
12
80
  - **Requires pmtiles-torrent 0.10.2**, which is what actually ends the re-checking: a
package/docs/haproxy.md CHANGED
@@ -114,6 +114,70 @@ gives archive affinity, at the price of concentrating one archive on one node.
114
114
  For nodes holding complete copies there is no cold read to avoid, so this is a
115
115
  cost with no benefit.
116
116
 
117
+ ### When the nodes are not the same speed
118
+
119
+ Round robin assumes the pool is interchangeable, and a tile server on an NVMe
120
+ disk and one on a spinning disk are not. Every archive read is a seek into a
121
+ large file — the PMTiles directory, then the leaf, then the tile — and that is
122
+ precisely the access pattern a spinning disk is worst at. The two can easily
123
+ differ by an order of magnitude.
124
+
125
+ What that does to a pool is worse than the average suggests. Balanced evenly,
126
+ half the tiles arrive quickly and half do not, and a map is judged by the slow
127
+ half: panning stutters wherever the slow node answered. The mean looks
128
+ tolerable throughout, which is why this is easy to miss and easy to blame on
129
+ the software.
130
+
131
+ Three ways out, and they answer different questions.
132
+
133
+ **`backup`** keeps the slow node out of service entirely until the fast one
134
+ fails:
135
+
136
+ ```
137
+ backend pmtiles-swarm
138
+ option httpchk GET /health HTTP/1.1
139
+ http-check expect status 200
140
+ server node1 172.16.1.49:8090 check inter 5s
141
+ server node2 172.16.1.41:8090 check inter 5s backup
142
+ ```
143
+
144
+ Every request goes to `node1` while it is healthy, and the pool falls to
145
+ `node2` when it is not. Latency is then the fast node's latency, and the slow
146
+ one is redundancy rather than capacity. This is the right answer when the fast
147
+ node can carry the load alone, which for tile serving it usually can.
148
+
149
+ **`weight`** keeps both in service and sends proportionally less to the slow
150
+ one:
151
+
152
+ ```
153
+ server node1 172.16.1.49:8090 check inter 5s weight 200
154
+ server node2 172.16.1.41:8090 check inter 5s weight 20
155
+ ```
156
+
157
+ Worth it only when you actually need the second node's throughput. It does not
158
+ remove the slow answers, it makes them rarer — a tenth of requests at ten times
159
+ the latency is still a tenth of requests a person notices.
160
+
161
+ **Least Connections** is the better of the two ways to keep both in service,
162
+ and it needs no numbers chosen by hand. A slow node holds each connection
163
+ longer, so it accumulates open ones and stops being picked until it catches up:
164
+ the pool tunes itself to what the disks are doing today rather than to a weight
165
+ somebody guessed last year. It shares `weight`'s limitation — the slow answers
166
+ become proportionally rarer, not absent — and it pairs with `backup` without
167
+ conflict, since a backup server is held out of the pool whatever the algorithm.
168
+
169
+ `tools/tile-bench.mjs` shows which situation you are in. A pool of unequal
170
+ nodes answers in two distinct groups rather than one spread, and the tool says
171
+ so explicitly; `--header` tallies a response header naming the backend, if
172
+ HAProxy is set to add one:
173
+
174
+ ```
175
+ http-response set-header X-Served-By %s
176
+ ```
177
+
178
+ Run it before and after the change. What should move is the tail: `total p90`
179
+ and `total p99` collapse towards `p50` once one node is answering everything.
180
+
117
181
  ### HTTP/2
118
182
 
119
183
  Enable it on the frontend and leave _HTTP/2 without TLS_ unchecked: the client
@@ -219,9 +283,16 @@ says so, which is worse than having no check at all.
219
283
 
220
284
  **Cache tiles by infohash aggressively.** `/archives/<infohash>/…` is immutable
221
285
  by construction — an infohash names those bytes and no others — and is served
222
- with `max-age=31536000, immutable`. `/latest/<category>/…` is the opposite: it
223
- moves on every build, and is served with `max-age=60, must-revalidate` and an
224
- ETag naming the build it resolved to.
286
+ with `max-age=31536000, immutable`.
287
+
288
+ Everything under `/latest/<category>/` is the opposite, because it moves on
289
+ every build. The documents — `tiles.json`, the feeds — are served with
290
+ `max-age=60, must-revalidate`; the tiles with `max-age=300`. Both carry an ETag
291
+ naming the build they resolved to, so a revalidation is a 304 while the build
292
+ stands and a miss the moment it moves. Let Cloudflare revalidate rather than
293
+ overriding either with a page rule: the whole point of the category tile URL is
294
+ that it is safe to write into an application, and it is only safe while the
295
+ edge notices a rebuild.
225
296
 
226
297
  **Do not strip or rewrite the ETag on either.** It is the infohash, which is
227
298
  how a PMTiles reader notices that the archive moved underneath a read already
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.59.0",
3
+ "version": "0.60.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
@@ -32,7 +32,7 @@ import {
32
32
  expandTemplate,
33
33
  } from './sources.js';
34
34
  import { limitFor, remaining } from './seeding.js';
35
- import { buildTileJson, extensionMatches } from './tilejson.js';
35
+ import { buildTileJson, extensionMatches, tileExtension } from './tilejson.js';
36
36
  import { SUMMARY_VERSION } from './pmtiles-probe.js';
37
37
  import { TileReadError } from './tiles.js';
38
38
 
@@ -2158,6 +2158,14 @@ export function createApp({
2158
2158
  servable,
2159
2159
  endpoints: {
2160
2160
  tileJson: servable ? `${base}/latest/${category}/tiles.json` : null,
2161
+ // The XYZ template itself, which is what most things outside this
2162
+ // system actually want: a leaflet layer, an OpenLayers source, a
2163
+ // GIS client, anything that takes a URL with braces in it rather
2164
+ // than a TileJSON document. It follows the category, so it can be
2165
+ // written into an application and survive a rebuild.
2166
+ xyz: servable
2167
+ ? `${base}/latest/${category}/{z}/{x}/{y}.${tileExtension(newest.pmtiles?.format)}`
2168
+ : null,
2161
2169
  // The same URL with the ways into the swarm in the fragment,
2162
2170
  // which is what a style should carry. A fragment is never sent in
2163
2171
  // a request, so an ordinary client fetches the TileJSON and
@@ -2253,8 +2261,23 @@ export function createApp({
2253
2261
  // makes that re-read cheap — a client already holding the current build
2254
2262
  // gets a 304 and no body, which Express does on its own once an ETag is
2255
2263
  // set before the response goes out.
2264
+ // Both templates are built, because both are wanted and they are wanted
2265
+ // for opposite reasons.
2266
+ //
2267
+ // `tiles` points at the category, so a style written once keeps working
2268
+ // across a rebuild. That is what the endpoint is for, and an infohash
2269
+ // template cannot do it: it changes every build, so anything holding it
2270
+ // is pinned to a build that eventually stops existing.
2271
+ //
2272
+ // The immutable template is still published, under `latest.tiles`, and
2273
+ // is still the better URL for anything that can re-read this document —
2274
+ // it is content-addressed, so it caches for a year and never
2275
+ // revalidates. Offering only one of the two would be choosing for the
2276
+ // consumer, and the right answer differs by consumer.
2277
+ const pinned = buildTileJson(entry, baseUrl(req));
2278
+ const stableRoot = `${baseUrl(req)}/latest/${encodeURIComponent(req.params.category)}`;
2256
2279
  const doc = {
2257
- ...buildTileJson(entry, baseUrl(req)),
2280
+ ...buildTileJson(entry, baseUrl(req), { tilesRoot: stableRoot }),
2258
2281
  // Names what it resolved to, so a consumer can tell one build from the
2259
2282
  // next without diffing the tile URLs.
2260
2283
  latest: {
@@ -2262,6 +2285,7 @@ export function createApp({
2262
2285
  infohash: entry.infoHash,
2263
2286
  name: entry.name,
2264
2287
  createdAt: entry.createdAt,
2288
+ tiles: pinned.tiles,
2265
2289
  },
2266
2290
  };
2267
2291
  tagAsLatest(res, doc);
@@ -2833,119 +2857,191 @@ export function createApp({
2833
2857
  }),
2834
2858
  );
2835
2859
 
2836
- app.get(
2837
- '/archives/:infoHash/:z/:x/:y.:ext',
2838
- route(async (req, res) => {
2839
- const { infoHash, ext } = req.params;
2840
- let entry = catalog.get(infoHash);
2841
- if (!entry) return res.status(404).json({ error: 'unknown archive' });
2842
- // MBTiles passes through here and is turned away by the store instead,
2843
- // which is the only layer that knows whether the download has finished.
2844
- if (entry.kind && entry.kind !== 'pmtiles' && entry.kind !== 'mbtiles') {
2845
- return res.status(415).json({
2846
- error: `this is a ${entry.kind} archive, and only PMTiles and MBTiles can be served as tiles`,
2860
+ /**
2861
+ * Serves one tile, from an archive the caller has already resolved.
2862
+ *
2863
+ * Shared by the two ways of naming an archive, which differ in one respect
2864
+ * and want the same behaviour in every other. `/archives/<infohash>/…` pins
2865
+ * content, so the answer can be cached for a year and never revalidated.
2866
+ * `/latest/<category>/…` resolves to whichever build is current, so it
2867
+ * cannot: the same URL returns different bytes after a rebuild, and a client
2868
+ * holding it for a year would hold a build that no longer exists.
2869
+ *
2870
+ * The difference is expressed entirely in the caching headers rather than in
2871
+ * two implementations, because everything else — the coordinate checks, the
2872
+ * summary backfill, the abort on a cancelled request, the 204-against-404
2873
+ * decision for a sparse archive — has to be identical or one of the two is
2874
+ * quietly a different endpoint.
2875
+ * @param {object} found - The catalog entry to read from.
2876
+ * @param {import('express').Request} req - The request.
2877
+ * @param {import('express').Response} res - The response.
2878
+ * @param {object} [options] - `immutable` false for a resolved category.
2879
+ * @returns {Promise<void>} - Resolves once answered.
2880
+ */
2881
+ const serveTile = async (found, req, res, options = {}) => {
2882
+ const { ext } = req.params;
2883
+ let entry = found;
2884
+ const infoHash = entry.infoHash;
2885
+ // MBTiles passes through here and is turned away by the store instead,
2886
+ // which is the only layer that knows whether the download has finished.
2887
+ if (entry.kind && entry.kind !== 'pmtiles' && entry.kind !== 'mbtiles') {
2888
+ return res.status(415).json({
2889
+ error: `this is a ${entry.kind} archive, and only PMTiles and MBTiles can be served as tiles`,
2890
+ });
2891
+ }
2892
+
2893
+ const z = Number(req.params.z);
2894
+ const x = Number(req.params.x);
2895
+ const y = Number(req.params.y);
2896
+ if (![z, x, y].every(Number.isInteger)) {
2897
+ return res.status(400).json({ error: 'z, x and y must be integers' });
2898
+ }
2899
+ const limit = 2 ** z;
2900
+ if (z < 0 || z > 26 || x < 0 || y < 0 || x >= limit || y >= limit) {
2901
+ return res.status(400).json({ error: 'tile coordinates out of range' });
2902
+ }
2903
+ // An MBTiles archive never went through the PMTiles prober, so it
2904
+ // reaches here with no summary and nothing to check the extension
2905
+ // against. Read one now: it is a handful of rows out of a local SQLite
2906
+ // file, kept in the catalog, so this is paid once per archive rather
2907
+ // than per tile. A failure is left to the read below to report, which
2908
+ // already knows how to say "not complete yet".
2909
+ if (!entry.pmtiles?.format) {
2910
+ const summary = await tiles.summarize(entry.infoHash).catch(() => null);
2911
+ if (summary) {
2912
+ entry = await catalog.put({
2913
+ infoHash: entry.infoHash,
2914
+ pmtiles: summary,
2915
+ summarySource: 'header',
2847
2916
  });
2848
2917
  }
2918
+ }
2849
2919
 
2850
- const z = Number(req.params.z);
2851
- const x = Number(req.params.x);
2852
- const y = Number(req.params.y);
2853
- if (![z, x, y].every(Number.isInteger)) {
2854
- return res.status(400).json({ error: 'z, x and y must be integers' });
2855
- }
2856
- const limit = 2 ** z;
2857
- if (z < 0 || z > 26 || x < 0 || y < 0 || x >= limit || y >= limit) {
2858
- return res.status(400).json({ error: 'tile coordinates out of range' });
2859
- }
2860
- // An MBTiles archive never went through the PMTiles prober, so it
2861
- // reaches here with no summary and nothing to check the extension
2862
- // against. Read one now: it is a handful of rows out of a local SQLite
2863
- // file, kept in the catalog, so this is paid once per archive rather
2864
- // than per tile. A failure is left to the read below to report, which
2865
- // already knows how to say "not complete yet".
2866
- if (!entry.pmtiles?.format) {
2867
- const summary = await tiles.summarize(entry.infoHash).catch(() => null);
2868
- if (summary) {
2869
- entry = await catalog.put({
2870
- infoHash: entry.infoHash,
2871
- pmtiles: summary,
2872
- summarySource: 'header',
2873
- });
2874
- }
2875
- }
2920
+ if (!extensionMatches(entry, ext)) {
2921
+ return res.status(400).json({
2922
+ error: `this archive holds ${entry.pmtiles?.format ?? 'unknown'} tiles`,
2923
+ });
2924
+ }
2876
2925
 
2877
- if (!extensionMatches(entry, ext)) {
2878
- return res.status(400).json({
2879
- error: `this archive holds ${entry.pmtiles?.format ?? 'unknown'} tiles`,
2926
+ const controller = new AbortController();
2927
+ // A panning map abandons requests constantly. Without this the swarm
2928
+ // keeps fetching pieces for tiles nobody is waiting for any more.
2929
+ res.on('close', () => {
2930
+ if (!res.writableEnded) controller.abort();
2931
+ });
2932
+
2933
+ // Counted on the way out rather than at each return: this handler ends
2934
+ // in six different places (200, 204, 404, 415, 400, a read error), and
2935
+ // one hook catches all of them without any of them having to remember.
2936
+ // Abandoned requests are not counted -- a panning map cancels constantly
2937
+ // and those were never served.
2938
+ if (stats) {
2939
+ const startedAt = process.hrtime.bigint();
2940
+ res.on('finish', () => {
2941
+ stats.record({
2942
+ infoHash,
2943
+ name: entry.name,
2944
+ z,
2945
+ x,
2946
+ y,
2947
+ status: res.statusCode,
2948
+ bytes: Number(res.getHeader('content-length')) || 0,
2949
+ ms: Number(process.hrtime.bigint() - startedAt) / 1e6,
2950
+ // Whatever this process can see. Behind a proxy that sends no
2951
+ // X-Forwarded-For this is the proxy's address, which is itself
2952
+ // the answer to "did this arrive directly or through HAProxy".
2953
+ ip: req.ip,
2880
2954
  });
2881
- }
2955
+ });
2956
+ }
2882
2957
 
2883
- const controller = new AbortController();
2884
- // A panning map abandons requests constantly. Without this the swarm
2885
- // keeps fetching pieces for tiles nobody is waiting for any more.
2886
- res.on('close', () => {
2887
- if (!res.writableEnded) controller.abort();
2958
+ let tile;
2959
+ try {
2960
+ tile = await tiles.getTile(infoHash, z, x, y, {
2961
+ signal: controller.signal,
2888
2962
  });
2963
+ } catch (error) {
2964
+ if (error.name === 'AbortError') return;
2965
+ if (error instanceof TileReadError) {
2966
+ return res.status(error.status).json({ error: error.message });
2967
+ }
2968
+ throw error;
2969
+ }
2889
2970
 
2890
- // Counted on the way out rather than at each return: this handler ends
2891
- // in six different places (200, 204, 404, 415, 400, a read error), and
2892
- // one hook catches all of them without any of them having to remember.
2893
- // Abandoned requests are not counted -- a panning map cancels constantly
2894
- // and those were never served.
2895
- if (stats) {
2896
- const startedAt = process.hrtime.bigint();
2897
- res.on('finish', () => {
2898
- stats.record({
2899
- infoHash,
2900
- name: entry.name,
2901
- z,
2902
- x,
2903
- y,
2904
- status: res.statusCode,
2905
- bytes: Number(res.getHeader('content-length')) || 0,
2906
- ms: Number(process.hrtime.bigint() - startedAt) / 1e6,
2907
- // Whatever this process can see. Behind a proxy that sends no
2908
- // X-Forwarded-For this is the proxy's address, which is itself
2909
- // the answer to "did this arrive directly or through HAProxy".
2910
- ip: req.ip,
2911
- });
2971
+ res.setHeader('access-control-allow-origin', '*');
2972
+ // The tag is the resolved infohash either way, which is what makes the
2973
+ // category URL cheap to hold: when the build has not moved a
2974
+ // revalidation is a 304 with no body, and when it has, the tag changes
2975
+ // on its own without anything having to remember to invalidate.
2976
+ res.setHeader('etag', `"${infoHash}-${z}-${x}-${y}"`);
2977
+ res.setHeader(
2978
+ 'cache-control',
2979
+ options.immutable === false
2980
+ ? // A category resolves to whatever build is current, so the same
2981
+ // URL returns different bytes after a rebuild. Five minutes bounds
2982
+ // how long a client can be looking at the previous build, and
2983
+ // must-revalidate is what stops a cache serving it beyond that.
2984
+ 'public, max-age=300, must-revalidate'
2985
+ : // An infohash pins content, so a tile under one can never change.
2986
+ // When a mutable archive is updated the infohash changes and so
2987
+ // does this URL, which makes cache invalidation automatic.
2988
+ 'public, max-age=31536000, immutable',
2989
+ );
2990
+
2991
+ // A missing tile is normal, and which status says so matters.
2992
+ //
2993
+ // 404 tells MapLibre the tile is absent, so it overzooms the parent —
2994
+ // which is the only way a sparse raster-dem renders terrain at all.
2995
+ // 204 tells it the tile is empty but present, so it draws nothing and
2996
+ // does not fall back.
2997
+ //
2998
+ // Vector wants 204 (an empty tile means no features here); raster wants
2999
+ // 404. Same rule and same name as tileserver-gl's `sparse`.
3000
+ if (!tile) return res.status(isSparse(entry) ? 404 : 204).end();
3001
+
3002
+ res.type(entry.pmtiles?.contentType ?? 'application/octet-stream');
3003
+ if (tile.encoding) res.setHeader('content-encoding', tile.encoding);
3004
+ res.send(tile.data);
3005
+ };
3006
+
3007
+ app.get(
3008
+ '/archives/:infoHash/:z/:x/:y.:ext',
3009
+ route(async (req, res) => {
3010
+ const entry = catalog.get(req.params.infoHash);
3011
+ if (!entry) return res.status(404).json({ error: 'unknown archive' });
3012
+ if (entry.kind && entry.kind !== 'pmtiles' && entry.kind !== 'mbtiles') {
3013
+ return res.status(415).json({
3014
+ error: `this is a ${entry.kind} archive, and only PMTiles and MBTiles can be served as tiles`,
2912
3015
  });
2913
3016
  }
3017
+ return serveTile(entry, req, res);
3018
+ }),
3019
+ );
2914
3020
 
2915
- let tile;
2916
- try {
2917
- tile = await tiles.getTile(infoHash, z, x, y, {
2918
- signal: controller.signal,
3021
+ // The one tile URL that survives a rebuild.
3022
+ //
3023
+ // Every archive is addressed by infohash, which is what makes a tile
3024
+ // cacheable for a year and what makes it useless in an application: the URL
3025
+ // changes with every build. A category is the stable handle, so it needs a
3026
+ // tile endpoint of its own — and this is the URL the category TileJSON
3027
+ // advertises, so a style pointed at `/latest/<category>/tiles.json` keeps
3028
+ // working across rebuilds without being re-fetched for the URLs alone.
3029
+ //
3030
+ // Not a redirect to the immutable URL, though every other `/latest/` route
3031
+ // is one. A redirect costs a round trip, and a map asks for hundreds of
3032
+ // tiles: what is a negligible indirection for a `.torrent` is the difference
3033
+ // between a map that feels immediate and one that does not.
3034
+ app.get(
3035
+ '/latest/:category/:z/:x/:y.:ext',
3036
+ route(async (req, res) => {
3037
+ const entry = newestIn(req.params.category, req);
3038
+ if (!entry) return res.status(404).json({ error: 'no such category' });
3039
+ if (entry.kind && entry.kind !== 'pmtiles' && entry.kind !== 'mbtiles') {
3040
+ return res.status(415).json({
3041
+ error: `the newest archive in this category is a ${entry.kind} archive, and only PMTiles and MBTiles can be served as tiles`,
2919
3042
  });
2920
- } catch (error) {
2921
- if (error.name === 'AbortError') return;
2922
- if (error instanceof TileReadError) {
2923
- return res.status(error.status).json({ error: error.message });
2924
- }
2925
- throw error;
2926
3043
  }
2927
-
2928
- res.setHeader('access-control-allow-origin', '*');
2929
- // An infohash pins content, so a tile under one can never change. When a
2930
- // mutable archive is updated the infohash changes and so does this URL,
2931
- // which makes cache invalidation automatic.
2932
- res.setHeader('cache-control', 'public, max-age=31536000, immutable');
2933
- res.setHeader('etag', `"${infoHash}-${z}-${x}-${y}"`);
2934
-
2935
- // A missing tile is normal, and which status says so matters.
2936
- //
2937
- // 404 tells MapLibre the tile is absent, so it overzooms the parent —
2938
- // which is the only way a sparse raster-dem renders terrain at all.
2939
- // 204 tells it the tile is empty but present, so it draws nothing and
2940
- // does not fall back.
2941
- //
2942
- // Vector wants 204 (an empty tile means no features here); raster wants
2943
- // 404. Same rule and same name as tileserver-gl's `sparse`.
2944
- if (!tile) return res.status(isSparse(entry) ? 404 : 204).end();
2945
-
2946
- res.type(entry.pmtiles?.contentType ?? 'application/octet-stream');
2947
- if (tile.encoding) res.setHeader('content-encoding', tile.encoding);
2948
- res.send(tile.data);
3044
+ return serveTile(entry, req, res, { immutable: false });
2949
3045
  }),
2950
3046
  );
2951
3047
 
package/src/incomplete.js CHANGED
@@ -191,6 +191,15 @@ export class CompletionWatcher {
191
191
  await this.#library.captureMetadata?.(entry.infoHash).catch(() => null);
192
192
  }
193
193
 
194
+ // Before the `complete` gate below, deliberately. An archive already
195
+ // recorded complete is the case this covers: it was re-checking when a
196
+ // tile reader opened it, so the reader went to the swarm — and when the
197
+ // check finished nothing noticed, because finalize only runs for a
198
+ // download. See Library.refreshReader.
199
+ if (entry.status?.progress >= 1) {
200
+ await this.#library.refreshReader?.(entry.infoHash).catch(() => null);
201
+ }
202
+
194
203
  if (entry.complete) continue;
195
204
 
196
205
  // The engine's account wins whenever it has one: a client allocates the
package/src/library.js CHANGED
@@ -2778,6 +2778,60 @@ export class Library {
2778
2778
  return { cleared: before };
2779
2779
  }
2780
2780
 
2781
+ /**
2782
+ * Drops a reader still going to the swarm for an archive that is now whole.
2783
+ *
2784
+ * Which source an archive is read through is decided once, when it is
2785
+ * opened, and every other thing that can change the answer — a pause, a
2786
+ * resume, a mode change, a move, a finished download — invalidates the
2787
+ * reader itself. A finished *check* is the one that does not, and it is the
2788
+ * easiest of them to hit: during `checking_files` libtorrent reports
2789
+ * `progress` as the fraction hashed so far, which is indistinguishable from
2790
+ * a download at the same figure. So a tile read arriving mid-check opens
2791
+ * against the swarm, correctly, and then keeps that handle after the check
2792
+ * finishes and the whole file is sitting on disk.
2793
+ *
2794
+ * What that looks like is an archive at 100% and seeding whose tiles will
2795
+ * not load, because the swarm it is being read from has one member: this
2796
+ * node. It survives until something evicts the handle, which in practice
2797
+ * means a restart.
2798
+ *
2799
+ * The disk is checked before the handle is dropped, and that is not
2800
+ * belt-and-braces: without it an archive that re-opens against the swarm
2801
+ * anyway would be invalidated again on every sweep, for ever.
2802
+ * @param {string} infoHash - Which archive.
2803
+ * @returns {Promise<boolean>} - Whether a stale reader was dropped.
2804
+ */
2805
+ async refreshReader(infoHash) {
2806
+ // Cheap and synchronous, and false for all but a handful of archives —
2807
+ // this runs for every entry on the completion timer.
2808
+ if (this.#tiles?.status(infoHash)?.mode !== 'swarm') return false;
2809
+
2810
+ const entry = this.#catalog.get(infoHash);
2811
+ if (!entry?.savePath || !entry?.name) return false;
2812
+
2813
+ // Cache mode reads from the swarm because that is what it is for, not
2814
+ // because anything went stale.
2815
+ if (entry.mode === 'cache') return false;
2816
+
2817
+ const whole = await alreadyComplete({
2818
+ savePath: entry.savePath,
2819
+ name: entry.name,
2820
+ size: entry.size,
2821
+ });
2822
+ if (!whole) return false;
2823
+
2824
+ const dropped = await this.#tiles.invalidate(infoHash).catch(() => false);
2825
+ if (dropped) {
2826
+ console.log(
2827
+ `[tiles] ${entry.name}: was being read from the swarm while a whole ` +
2828
+ 'copy sat on disk, which is what a finished re-check leaves behind. ' +
2829
+ 'Reopening it locally.',
2830
+ );
2831
+ }
2832
+ return dropped;
2833
+ }
2834
+
2781
2835
  /**
2782
2836
  * Rewrites an archive's web seed list, in the .torrent and everywhere else.
2783
2837
  *
package/src/tilejson.js CHANGED
@@ -23,21 +23,45 @@ const URL_EXTENSION = {
23
23
  mlt: 'mlt',
24
24
  };
25
25
 
26
+ /**
27
+ * The extension a tile URL uses for one archive format.
28
+ *
29
+ * Exported because the console publishes the XYZ template beside the TileJSON,
30
+ * and a second copy of this map is a second thing to forget when a format is
31
+ * added — which would show up as a working document advertising a URL that
32
+ * answers 400.
33
+ * @param {string} [format] - The archive's tile format.
34
+ * @returns {string} - The extension, without a dot.
35
+ */
36
+ export function tileExtension(format) {
37
+ return URL_EXTENSION[format] ?? 'bin';
38
+ }
39
+
26
40
  /**
27
41
  * Builds the TileJSON document for one catalog entry.
42
+ *
43
+ * `tilesRoot` is how a category document points its tiles at itself rather
44
+ * than at the build it happens to have resolved to. The infohash URL is the
45
+ * right default — it pins content, which is what lets a tile be cached for a
46
+ * year — but it is the wrong thing to write into an application, because it
47
+ * changes with every rebuild. A category has to be able to hand out a URL that
48
+ * does not.
28
49
  * @param {object} entry - Catalog entry.
29
50
  * @param {string} baseUrl - Public base URL, without a trailing slash.
51
+ * @param {object} [options] - Overrides.
52
+ * @param {string} [options.tilesRoot] - Root for the tile template.
30
53
  * @returns {object} - A TileJSON 3.0.0 document.
31
54
  */
32
- export function buildTileJson(entry, baseUrl) {
55
+ export function buildTileJson(entry, baseUrl, options = {}) {
33
56
  const summary = entry.pmtiles ?? {};
34
- const extension = URL_EXTENSION[summary.format] ?? 'bin';
57
+ const extension = tileExtension(summary.format);
35
58
  const root = `${baseUrl}/archives/${entry.infoHash}`;
59
+ const tilesRoot = options.tilesRoot ?? root;
36
60
 
37
61
  const doc = {
38
62
  tilejson: '3.0.0',
39
63
  scheme: 'xyz',
40
- tiles: [`${root}/{z}/{x}/{y}.${extension}`],
64
+ tiles: [`${tilesRoot}/{z}/{x}/{y}.${extension}`],
41
65
  name: summary.name ?? entry.name,
42
66
  minzoom: summary.minZoom ?? 0,
43
67
  maxzoom: summary.maxZoom ?? 14,
@@ -3715,6 +3715,7 @@ Every piece is hashed against the ` +
3715
3715
  ends.tileJson
3716
3716
  ? `<div class="links">
3717
3717
  ${copyable(ends.tileJson, 'TileJSON')}
3718
+ ${copyable(ends.xyz, 'XYZ')}
3718
3719
  ${link(ends.preview, 'preview')}
3719
3720
  ${link(ends.torrent, '.torrent', true)}
3720
3721
  ${copyable(ends.magnet, 'magnet', true)}