pmtiles-swarm 0.9.1 โ†’ 0.12.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,100 @@
7
7
  ### ๐Ÿž Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.12.0
11
+ ### โœจ Features and improvements
12
+ - **A category can now be addressed without a server at all.** A node that builds can publish a
13
+ signed DHT record (BEP 46) naming whichever archive is currently newest in each category, so a
14
+ style can point at a magnet that never goes stale:
15
+
16
+ ```
17
+ magnet:?xs=urn:btpk:<public key>&s=openmaptiles&dn=โ€ฆ&ws=โ€ฆ
18
+ ```
19
+
20
+ No infohash in it, which is the whole point โ€” an infohash is what goes stale on the next build,
21
+ and it is why the fragment convention added in 0.11.0 could not be used for `/latest/` URLs. The
22
+ salt is the category name, so **one keypair addresses every category** rather than needing one
23
+ each.
24
+
25
+ Turn it on with `mutable.publish` and a key from the new **`pmtiles-swarm publisher-key`**
26
+ command. Off by default.
27
+
28
+ **Only the node that builds needs the key.** Serving nodes receive the public half on the catalog
29
+ entry, through the same sync that already carries `magnet` and `webSeeds`, and assemble the
30
+ identical magnet from it โ€” there is nothing secret in one. Ten nodes behind a balancer hand out
31
+ the same string and none of them can publish. Run exactly one publisher: two under one key would
32
+ fight over the sequence number.
33
+
34
+ It is a **signing key rather than a credential**. Whoever holds it can tell your subscribers that
35
+ any archive is the current build, signed, and clients will believe it.
36
+
37
+ Records expire from the DHT after roughly two hours, so the node republishes on a timer
38
+ (`republishSeconds`, default 1800). That timer is the feature, not an optimisation โ€” without it
39
+ a record published once works all afternoon and quietly stops resolving by evening. A category
40
+ whose put fails does not stop the others.
41
+
42
+ **`bittorrent-dht` is now a direct dependency** rather than reached for through webtorrent's
43
+ client, so publishing works on a node running the libtorrent engine alone.
44
+ - **The TileJSON's `torrent.mutable` block carries the magnet**, built from the public key, so no
45
+ consumer has to know how to assemble one. `mutableMagnet()` also accepts a hex key now โ€” which
46
+ is all a serving node has โ€” and carries `ws=` web seeds, so a client with no peers can still
47
+ range-read the archive over HTTP.
48
+
49
+ ## 0.11.0
50
+ ### โœจ Features and improvements
51
+ - **The magnet can travel in the TileJSON URL's fragment**, and the console will build that string
52
+ for you: **Copy TileJSON URL + magnet**. A fragment is never sent in an HTTP request, so one
53
+ string serves every client โ€” maplibre-gl-js, Leaflet and plain maplibre-native fetch the
54
+ TileJSON and ignore it, while a torrent-aware client reads the magnet **before making any network
55
+ call at all**.
56
+
57
+ That last part is the point. The `torrent` block inside the TileJSON only helps once the TileJSON
58
+ has been fetched, which leaves the swarm โ€” the one part that depends on no server โ€”
59
+ unreachable exactly when the server is down. With the magnet in the fragment a client can fall
60
+ back to the `ws=` web seed (two range requests, and the TileJSON derives from the archive's own
61
+ header and metadata) or to the swarm itself.
62
+
63
+ Documented in [docs/serving-tiles.md](docs/serving-tiles.md), including the caveat worth knowing:
64
+ on a `/latest/<category>/` URL the fragment pins the build that was current when it was copied,
65
+ while the URL keeps following the category, so the two can disagree after a rebuild. Survivable,
66
+ since the fragment is only consulted when the TileJSON cannot be fetched and an older build
67
+ renders where a blank map does not โ€” and properly fixed by a mutable `xs=urn:btpk:` magnet,
68
+ which needs the BEP 46 publishing that [src/mutable.js](src/mutable.js) has machinery for and
69
+ nothing yet calls.
70
+
71
+ ## 0.10.0
72
+ ### โœจ Features and improvements
73
+ - **`GET /api/stats`**, which answers what a node has actually served. Until now a tile request was
74
+ answered and forgotten, so the most ordinary operational questions had no answer at all: which
75
+ archive is carrying the load, which zooms are being pulled, whether a node behind a balancer is
76
+ getting its share, and whether the traffic hammering it arrived directly or through the proxy.
77
+
78
+ Per-archive counters โ€” requests, bytes, a breakdown by zoom and by status, p50/p95 latency, and
79
+ a count per client address โ€” plus a fixed ring of the most recent requests. Both live in memory
80
+ and are bounded, so the cost is the same after a billion tiles as after ten. Nothing is written
81
+ to disk: a restart is how you reset it, and an access log would bring retention and disk
82
+ questions this deliberately does not have. `DELETE /api/stats` clears it, deliberately a separate
83
+ verb so a dashboard polling the endpoint cannot erase the history it is drawing.
84
+
85
+ The report names the node that answered, which is the point behind a load balancer โ€” ask each
86
+ one directly and the counters say how traffic is really distributed rather than how the balancer
87
+ believes it is. Admin-side rather than public, because it lists archives and client addresses.
88
+
89
+ Bytes are counted **as sent**, so a gzipped vector tile counts its compressed size. That is the
90
+ number that matters for bandwidth and it is not what the client ends up holding.
91
+
92
+ What a client address means depends on the proxy in front. Without `X-Forwarded-For` it is the
93
+ proxy's own address for everything arriving through it โ€” still enough to separate direct
94
+ traffic from proxied, which is usually the question being asked, but not who sent it. For real
95
+ client addresses the proxy has to send the header and `trustProxy` has to name it.
96
+
97
+ Configured under `tileStats`: `recent` sets how many requests to keep, `0` keeps the counters and
98
+ drops the ring, and `false` turns the whole thing off, after which the endpoint answers 501.
99
+ - **The archive detail shows what it has served**, in the console and on
100
+ `GET /api/torrents/<infohash>` as a `served` block. Worth reading next to `reading`: an archive
101
+ being read through the swarm while serving thousands of tiles is a different situation from one
102
+ doing neither.
103
+
10
104
  ## 0.9.1
11
105
  ### ๐Ÿž Bug fixes
12
106
  - **Requires pmtiles-torrent 0.4.2, which is what actually makes a newly built archive visible.**
package/README.md CHANGED
@@ -621,6 +621,61 @@ Exit status is 0 when the node answered and its engine is up, 1 when it did not
621
621
  works in a deployment script. `--json` gives the raw `/api/status` and `/api/torrents` replies for
622
622
  anything that wants to parse rather than read.
623
623
 
624
+ ## Seeing what a node is actually serving
625
+
626
+ ```sh
627
+ curl -s -H "authorization: Bearer $KEY" http://172.16.1.49:8091/api/stats | jq
628
+ ```
629
+
630
+ ```json
631
+ {
632
+ "node": "planetgen",
633
+ "since": "2026-08-12T14:02:11.004Z",
634
+ "requests": 18422,
635
+ "bytes": 743112904,
636
+ "archives": {
637
+ "4813a0e68e4b88def6d4ef3c4eabde84ffc0c068": {
638
+ "name": "planetiler-openmaptiles-260803.pmtiles",
639
+ "requests": 18422,
640
+ "bytes": 743112904,
641
+ "byZoom": { "0": 12, "7": 3311, "14": 9022 },
642
+ "byStatus": { "200": 18104, "204": 301, "404": 17 },
643
+ "clients": { "172.16.1.2": 17980, "172.16.1.41": 442 },
644
+ "p50ms": 3,
645
+ "p95ms": 41
646
+ }
647
+ },
648
+ "recent": [
649
+ { "at": "โ€ฆ", "ip": "172.16.1.41", "z": 14, "x": 4823, "y": 6155,
650
+ "status": 200, "bytes": 41221, "ms": 2 }
651
+ ]
652
+ }
653
+ ```
654
+
655
+ Counters per archive, plus a fixed ring of the most recent requests. Both live in
656
+ memory and are bounded, so the cost is the same after a billion tiles as after
657
+ ten, and a restart is how you reset it. Nothing is written to disk โ€” an access
658
+ log brings retention and disk questions this deliberately does not have.
659
+
660
+ `node` names which machine answered, which is the point behind a load balancer:
661
+ ask each node directly and the counters tell you how traffic is actually
662
+ distributed, rather than what the balancer believes.
663
+
664
+ **What the client address means depends on your proxy.** `clients` records what
665
+ the process can see. Without `X-Forwarded-For` that is the proxy's own address
666
+ for everything arriving through it โ€” still enough to separate direct traffic
667
+ from proxied, which is usually the question, but not who sent it. For real
668
+ client addresses, have the proxy send the header and set `trustProxy` to name
669
+ it; see [docs/haproxy.md](docs/haproxy.md).
670
+
671
+ Bytes are counted **as sent**, so a gzipped vector tile counts its compressed
672
+ size. That is the number that matters for bandwidth, and it is not what the
673
+ client ends up holding.
674
+
675
+ Configured under `tileStats`: `recent` sets how many requests to keep (`0` keeps
676
+ the counters and drops the ring), and `"tileStats": false` turns it off, after
677
+ which the endpoint answers 501.
678
+
624
679
  ## API
625
680
 
626
681
  | Method | Path | Purpose |
@@ -655,6 +710,7 @@ anything that wants to parse rather than read.
655
710
  | `POST` | `/api/torrents/:infoHash/hooks/complete` | Run the completion hook again for one archive |
656
711
  | `GET` `POST` `DELETE` | `/api/tokens`, `/api/tokens/:id` | Mint, list and revoke access tokens |
657
712
  | `GET` `POST` | `/api/restart` | What a restart would do, and doing it |
713
+ | `GET` `DELETE` | `/api/stats` | What this node has served โ€” per-archive counters and the last N requests; `DELETE` clears them |
658
714
  | `GET` `PATCH` | `/api/config` | Read and change settings |
659
715
  | `POST` | `/api/login`, `/api/logout` | Console sign-in |
660
716
  | `GET` | `/api/session` | Who this request is, and whether a credential is needed at all |
@@ -160,6 +160,120 @@ Both peer-to-peer engines reuse the client already seeding the archive rather
160
160
  than starting a second one. One peer pool, one port, one DHT node โ€” and for
161
161
  libtorrent, one sidecar process.
162
162
 
163
+ ## Carrying the magnet in the URL fragment
164
+
165
+ The `torrent` block below solves the problem *after* the TileJSON has been
166
+ fetched. It does not solve the one before it: a torrent-aware client that cannot
167
+ reach this server has nothing to work with, so the swarm โ€” the part that does
168
+ not depend on any server โ€” is unreachable precisely when the server is down.
169
+
170
+ The fix is to put the magnet in the URL **fragment**:
171
+
172
+ ```json
173
+ "sources": {
174
+ "openmaptiles": {
175
+ "type": "vector",
176
+ "url": "https://swarm.example.org/latest/openmaptiles/tiles.json#magnet:?xt=urn:btih:4813a0e6โ€ฆ&dn=โ€ฆ&tr=โ€ฆ&ws=โ€ฆ"
177
+ }
178
+ }
179
+ ```
180
+
181
+ A fragment is never sent in an HTTP request, so the same string works
182
+ everywhere:
183
+
184
+ | Client | What happens |
185
+ | --- | --- |
186
+ | maplibre-gl-js, Leaflet, anything | fetches the TileJSON, ignores the fragment |
187
+ | maplibre-native without a plugin | the same |
188
+ | torrent-aware | reads the magnet **before any network call**, and still has it if the fetch fails |
189
+
190
+ The console's **Copy TileJSON URL + magnet** button produces exactly this.
191
+
192
+ A magnet needs no encoding in a fragment โ€” RFC 3986 allows `?`, `&`, `=` and `:`
193
+ there โ€” and leaving it readable matters for something people paste into a style
194
+ file by hand.
195
+
196
+ ### What a client should do with it
197
+
198
+ Three paths, each a strict fallback of the one above:
199
+
200
+ 1. **The TileJSON URL.** One request, the full document including
201
+ `vector_layers`. Fastest, and what an ordinary client does anyway.
202
+ 2. **The `ws=` web seed.** Two HTTP range requests โ€” the header and root
203
+ directory near the start of the archive, the JSON metadata at the far end โ€”
204
+ and the TileJSON can be derived from them. Works when this API is down but
205
+ the file is still on a web server, and it is the same order of cost as (1).
206
+ 3. **The swarm.** No HTTP at all. Slowest from cold, because BEP 9 has to
207
+ deliver the metainfo first, and the only one that survives the server
208
+ disappearing entirely.
209
+
210
+ Everything those need is in the magnet: `xt` identifies the archive, `dn` names
211
+ the file, `tr` finds peers, `ws` gives the HTTP fallback.
212
+
213
+ ### One caveat on `/latest/` URLs
214
+
215
+ `/latest/<category>/tiles.json` follows the category, and a magnet naming an
216
+ infohash does not โ€” it pins the build that was current when the URL was copied.
217
+ So the fragment goes stale on the next build while the URL does not.
218
+
219
+ That is survivable, because the fragment is only consulted when the TileJSON
220
+ cannot be fetched, and an older build renders where a blank map does not. But it
221
+ means the two halves can disagree, and the fix is a **mutable** magnet
222
+ (`xs=urn:btpk:โ€ฆ`, BEP 46) whose target is resolved over the DHT rather than
223
+ baked into the string. See [src/mutable.js](../src/mutable.js) โ€” note that
224
+ nothing publishes those records yet.
225
+
226
+ For an immutable `/archives/<infohash>/tiles.json` URL the question does not
227
+ arise: both halves name the same fixed archive.
228
+
229
+ ### A fragment that survives a rebuild
230
+
231
+ The caveat above โ€” a pinned infohash going stale โ€” is what BEP 46 fixes. A node
232
+ that publishes signs a DHT record naming whichever infohash is current, and the
233
+ magnet then names the **category** rather than a build:
234
+
235
+ ```
236
+ magnet:?xs=urn:btpk:<public key>&s=openmaptiles&dn=โ€ฆ&ws=โ€ฆ
237
+ ```
238
+
239
+ No infohash anywhere, so nothing to go stale. A client resolves the record over
240
+ the DHT and joins whatever is current.
241
+
242
+ Turn it on with a key on the node that builds:
243
+
244
+ ```sh
245
+ pmtiles-swarm publisher-key > /etc/pmtiles-swarm/publisher.pem
246
+ chmod 600 /etc/pmtiles-swarm/publisher.pem
247
+ ```
248
+
249
+ ```json
250
+ { "mutable": { "publish": true, "keyPath": "/etc/pmtiles-swarm/publisher.pem" } }
251
+ ```
252
+
253
+ The magnet then appears in every TileJSON as `torrent.mutable.magnet`, and the
254
+ console's copy button uses it for category URLs.
255
+
256
+ **Only the node that builds needs the key.** Serving nodes receive the public
257
+ half on the catalog entry, through the same subscription sync that carries
258
+ `magnet` and `webSeeds`, and assemble the identical magnet from it โ€” there is
259
+ nothing secret in one. Ten nodes behind a balancer hand out the same string and
260
+ none of them can publish.
261
+
262
+ **Run exactly one publisher.** Two nodes publishing under one key would fight
263
+ over the sequence number, each overwriting the other's claim about what is
264
+ current.
265
+
266
+ **It is a signing key, not a credential.** Whoever holds it can tell your
267
+ subscribers that any archive is the current build, signed, and they will believe
268
+ it. Treat it the way you would a code-signing key: lose it and every style
269
+ pointing at that public key breaks permanently.
270
+
271
+ **Records expire after roughly two hours**, so the node republishes on a timer
272
+ (`republishSeconds`, default 1800). That timer is not an optimisation โ€” without
273
+ it a record published once works all afternoon and stops resolving by evening.
274
+ If the publisher is offline longer than that, the DHT path goes quiet until it
275
+ returns; the HTTP TileJSON URL is unaffected.
276
+
163
277
  ## The `torrent` block
164
278
 
165
279
  TileJSON documents from this server carry a non-standard `torrent` member:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.9.1",
3
+ "version": "0.12.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",
@@ -32,6 +32,7 @@
32
32
  "dependencies": {
33
33
  "@maplibre/maplibre-gl-inspect": "^1.8.2",
34
34
  "bencode": "^4.0.1",
35
+ "bittorrent-dht": "^11.0.12",
35
36
  "chokidar": "^5.0.0",
36
37
  "create-torrent": "^6.1.0",
37
38
  "express": "^5.2.1",
package/src/api.js CHANGED
@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
2
2
  import fsSync from 'node:fs';
3
3
  import fs from 'node:fs/promises';
4
4
  import { createRequire } from 'node:module';
5
+ import os from 'node:os';
5
6
  import path from 'node:path';
6
7
  import { fileURLToPath } from 'node:url';
7
8
  import express from 'express';
@@ -70,6 +71,7 @@ export function createApp({
70
71
  warm,
71
72
  config,
72
73
  speed,
74
+ stats,
73
75
  reloaders = {},
74
76
  shutdown,
75
77
  }) {
@@ -496,6 +498,39 @@ export function createApp({
496
498
  }),
497
499
  );
498
500
 
501
+ // What this node has served, which is a different question from whether it
502
+ // is healthy. Admin-side: it names archives and client addresses, and a
503
+ // public endpoint reporting who else is using a node is a privacy question
504
+ // nobody asked for.
505
+ app.get(
506
+ '/api/stats',
507
+ route(async (req, res) => {
508
+ if (!stats) {
509
+ return res.status(501).json({ error: 'tile statistics are disabled' });
510
+ }
511
+ const recent = req.query.recent === undefined ? undefined : Number(req.query.recent);
512
+ res.setHeader('cache-control', 'no-store');
513
+ res.json({
514
+ node: config.nodeName ?? os.hostname(),
515
+ ...stats.snapshot({ recent }),
516
+ });
517
+ }),
518
+ );
519
+
520
+ // Deliberately a separate verb: reading counters should never be able to
521
+ // clear them, or a dashboard polling the endpoint would erase the history
522
+ // it is drawing.
523
+ app.delete(
524
+ '/api/stats',
525
+ route(async (_req, res) => {
526
+ if (!stats) {
527
+ return res.status(501).json({ error: 'tile statistics are disabled' });
528
+ }
529
+ stats.reset();
530
+ res.status(204).end();
531
+ }),
532
+ );
533
+
499
534
  // Settings. Everything read per request takes effect immediately because the
500
535
  // running config object is the one being mutated; everything bound at startup
501
536
  // is written to the file and reported back as needing a restart, rather than
@@ -730,7 +765,12 @@ export function createApp({
730
765
  // Optional call: a missing method throws before any .catch could see it.
731
766
  const diskBytes =
732
767
  (await library.diskUsage?.(entry.infoHash).catch(() => null)) ?? null;
733
- res.json({ ...entry, status, reading, diskBytes });
768
+ // Null until a tile has been asked for, and reset by a restart โ€” the
769
+ // same lifetime as `reading`, and worth reading alongside it: an archive
770
+ // being read through the swarm while serving thousands of tiles is a
771
+ // different situation from one doing neither.
772
+ const served = stats?.forArchive(entry.infoHash) ?? null;
773
+ res.json({ ...entry, status, reading, diskBytes, served });
734
774
  }),
735
775
  );
736
776
 
@@ -2033,6 +2073,31 @@ export function createApp({
2033
2073
  if (!res.writableEnded) controller.abort();
2034
2074
  });
2035
2075
 
2076
+ // Counted on the way out rather than at each return: this handler ends
2077
+ // in six different places (200, 204, 404, 415, 400, a read error), and
2078
+ // one hook catches all of them without any of them having to remember.
2079
+ // Abandoned requests are not counted -- a panning map cancels constantly
2080
+ // and those were never served.
2081
+ if (stats) {
2082
+ const startedAt = process.hrtime.bigint();
2083
+ res.on('finish', () => {
2084
+ stats.record({
2085
+ infoHash,
2086
+ name: entry.name,
2087
+ z,
2088
+ x,
2089
+ y,
2090
+ status: res.statusCode,
2091
+ bytes: Number(res.getHeader('content-length')) || 0,
2092
+ ms: Number(process.hrtime.bigint() - startedAt) / 1e6,
2093
+ // Whatever this process can see. Behind a proxy that sends no
2094
+ // X-Forwarded-For this is the proxy's address, which is itself
2095
+ // the answer to "did this arrive directly or through HAProxy".
2096
+ ip: req.ip,
2097
+ });
2098
+ });
2099
+ }
2100
+
2036
2101
  let tile;
2037
2102
  try {
2038
2103
  tile = await tiles.getTile(infoHash, z, x, y, {
package/src/config.js CHANGED
@@ -397,6 +397,54 @@ const DEFAULTS = {
397
397
  * instead sidesteps the question entirely.
398
398
  */
399
399
  trustProxy: false,
400
+ /**
401
+ * Announcing the current build of each category over the DHT (BEP 46).
402
+ *
403
+ * A category is the only stable handle this system has โ€” every archive is
404
+ * addressed by its infohash, so a style pointing at one goes stale on the
405
+ * next build. `/latest/<category>/` fixes that with a server; this fixes it
406
+ * without one, as a signed DHT record naming whichever infohash is current.
407
+ *
408
+ * **Only the node that builds needs this.** Serving nodes carry the public
409
+ * half on the catalog entry and hand it out in the TileJSON; publishing is
410
+ * the only thing the secret is used for. Two nodes publishing under one key
411
+ * would fight over the sequence number, so run exactly one publisher.
412
+ *
413
+ * The key is a signing key, not a credential: whoever holds it can tell your
414
+ * subscribers that any archive is the current build, signed. Treat it the way
415
+ * you would a code-signing key. Generate one with
416
+ * `pmtiles-swarm publisher-key`.
417
+ *
418
+ * Records expire from the DHT after roughly two hours, so this republishes on
419
+ * a timer. Nothing else keeps them alive.
420
+ */
421
+ mutable: {
422
+ /** Publish records. Off unless a key is configured and this is set. */
423
+ publish: false,
424
+ /** PEM file holding the ed25519 keypair. Never leaves the publisher. */
425
+ keyPath: undefined,
426
+ /** How often to republish, in seconds. */
427
+ republishSeconds: 1800,
428
+ },
429
+ /**
430
+ * What this node has served, at `GET /api/stats`.
431
+ *
432
+ * Per-archive counters and a fixed ring of recent requests, both in memory,
433
+ * so the cost does not grow with traffic. Nothing is written to disk: a
434
+ * restart is how you reset it, and an access log would bring retention and
435
+ * disk questions this deliberately does not have.
436
+ *
437
+ * `recent: 0` keeps the counters and drops the per-request ring. Setting
438
+ * `tileStats` to `false` turns the whole thing off.
439
+ *
440
+ * Note what the client address means behind a proxy: without
441
+ * `X-Forwarded-For` it is the proxy's own address, which still answers
442
+ * whether a request arrived directly or through it, but not who sent it.
443
+ */
444
+ tileStats: {
445
+ /** Recent requests kept for inspection. */
446
+ recent: 200,
447
+ },
400
448
  /**
401
449
  * Tile serving: a TileJSON endpoint and z/x/y tiles per archive.
402
450
  *
package/src/index.js CHANGED
@@ -18,6 +18,7 @@ import { SeedingLimits } from './seeding.js';
18
18
  import { closeServer, installSignalHandlers, runStoppers } from './shutdown.js';
19
19
  import { ScheduledSourceManager } from './sources.js';
20
20
  import { SubscriptionManager } from './subscriptions.js';
21
+ import { TileStats } from './tile-stats.js';
21
22
  import { TileStore } from './tiles.js';
22
23
  import { HeadWarmer } from './prewarm.js';
23
24
  import { WarmRunner } from './warm.js';
@@ -108,6 +109,7 @@ async function main() {
108
109
  Usage:
109
110
  pmtiles-swarm [--config FILE] start the node
110
111
  pmtiles-swarm status [--config FILE] ask a running node what it is doing
112
+ pmtiles-swarm publisher-key print a new BEP 46 signing key
111
113
 
112
114
  --config, -c path to a JSON config file
113
115
  --port, -p override the listen port
@@ -134,6 +136,20 @@ PMTILES_SWARM_PUBLIC_URL
134
136
  return;
135
137
  }
136
138
 
139
+ if (positionals[0] === 'publisher-key') {
140
+ const { generatePublisherKey, publisherKeyToPem } = await import('./mutable.js');
141
+ const key = generatePublisherKey();
142
+ // The PEM on stdout so it can be redirected to a file; everything else on
143
+ // stderr so that redirect stays clean.
144
+ process.stdout.write(publisherKeyToPem(key));
145
+ console.error('');
146
+ console.error(`public key: ${Buffer.from(key.publicKey).toString('hex')}`);
147
+ console.error('Save the PEM where only this node can read it, and point');
148
+ console.error('mutable.keyPath at it. It signs what your subscribers');
149
+ console.error('believe is the current build, so treat it as a signing key.');
150
+ return;
151
+ }
152
+
137
153
  if (positionals.length > 0) {
138
154
  console.error(`unknown command: ${positionals[0]}`);
139
155
  console.error('try: pmtiles-swarm status');
@@ -236,6 +252,50 @@ PMTILES_SWARM_PUBLIC_URL
236
252
  );
237
253
  }
238
254
 
255
+ // What this node has served. In memory and bounded, so it costs the same
256
+ // after a billion tiles as after ten; `tileStats.recent: 0` keeps the
257
+ // counters and drops the per-request ring, and `false` turns it off.
258
+ const stats =
259
+ config.tileStats === false
260
+ ? null
261
+ : new TileStats({ recent: config.tileStats?.recent });
262
+
263
+ // Announcing the current build of each category over the DHT. Only ever on
264
+ // the node that builds: the key signs what subscribers believe is current,
265
+ // and two publishers under one key would fight over the sequence number.
266
+ let publisher;
267
+ if (config.mutable?.publish && config.mutable?.keyPath) {
268
+ try {
269
+ const [{ publisherKeyFromPem }, { MutablePublisher }, DHT] = await Promise.all([
270
+ import('./mutable.js'),
271
+ import('./publisher.js'),
272
+ import('bittorrent-dht').then((m) => m.default),
273
+ ]);
274
+ const pem = await fs.readFile(config.mutable.keyPath, 'utf8');
275
+ const dht = new DHT();
276
+ publisher = new MutablePublisher({
277
+ catalog,
278
+ dht,
279
+ key: publisherKeyFromPem(pem),
280
+ intervalMs: (config.mutable.republishSeconds ?? 1800) * 1000,
281
+ });
282
+ stoppers.unshift({
283
+ label: 'mutable publisher',
284
+ stop: () => {
285
+ publisher.stop();
286
+ dht.destroy();
287
+ },
288
+ ms: 2000,
289
+ });
290
+ publisher.start();
291
+ } catch (error) {
292
+ // Never fatal: a node that cannot publish should still serve. Loudly
293
+ // reported, because the failure is otherwise invisible until a
294
+ // subscriber's style quietly stops resolving.
295
+ console.error(`[mutable] not publishing: ${error.message}`);
296
+ }
297
+ }
298
+
239
299
  const tiles = new TileStore({ catalog, engine, config });
240
300
  library.attachTiles(tiles);
241
301
  const warm = new WarmRunner(tiles);
@@ -291,6 +351,7 @@ PMTILES_SWARM_PUBLIC_URL
291
351
  warm,
292
352
  config,
293
353
  speed,
354
+ stats,
294
355
  reloaders,
295
356
  shutdown: () => runStoppers(stoppers),
296
357
  });
package/src/mutable.js CHANGED
@@ -82,21 +82,35 @@ function rawPublicKey(publicKey) {
82
82
  *
83
83
  * Note this carries no infohash: `xs=urn:btpk:` names the public key, and the
84
84
  * client resolves it through the DHT to whatever infohash is current.
85
- * @param {Uint8Array} publicKey - Raw 32-byte public key.
85
+ * @param {Uint8Array | string} publicKey - Raw 32-byte public key, or its hex form.
86
+ * A serving node has only the hex, off the catalog entry, and must be able to
87
+ * build this string without ever seeing the raw key or the private half.
86
88
  * @param {object} [options] - Extra magnet parameters.
87
89
  * @param {string} [options.name] - Display name for the archive.
88
90
  * @param {string[]} [options.trackers] - Tracker announce URLs.
91
+ * @param {string[]} [options.webSeeds] - BEP 19 web seeds.
89
92
  * @param {string} [options.salt] - Salt, when one key publishes several archives.
90
93
  * @returns {string} - A BEP 46 magnet URI.
91
94
  */
92
95
  export function mutableMagnet(publicKey, options = {}) {
93
- const hex = Buffer.from(publicKey).toString('hex');
96
+ // Buffer.from(string) would read hex as UTF-8 and produce a 64-byte key, so
97
+ // the two forms have to be told apart rather than coerced.
98
+ const hex =
99
+ typeof publicKey === 'string'
100
+ ? publicKey.toLowerCase()
101
+ : Buffer.from(publicKey).toString('hex');
94
102
  const parts = [`magnet:?xs=urn:btpk:${hex}`];
95
103
  if (options.name) parts.push(`dn=${encodeURIComponent(options.name)}`);
96
104
  if (options.salt) parts.push(`s=${encodeURIComponent(options.salt)}`);
97
105
  for (const tracker of options.trackers ?? []) {
98
106
  parts.push(`tr=${encodeURIComponent(tracker)}`);
99
107
  }
108
+ // Carried because it is what makes a magnet useful with no peers at all: a
109
+ // client can range-read the archive over HTTP and still be correct, which is
110
+ // the difference between a slow first paint and a blank map.
111
+ for (const seed of options.webSeeds ?? []) {
112
+ parts.push(`ws=${encodeURIComponent(seed)}`);
113
+ }
100
114
  return parts.join('&');
101
115
  }
102
116
 
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Announcing the current build of a category over the DHT (BEP 46).
3
+ *
4
+ * A category is the only stable handle this system has. Every archive is
5
+ * addressed by its infohash, which is what makes a tile immutable and leaves a
6
+ * style with nothing to point at that survives a rebuild. `/latest/<category>/`
7
+ * solves that with a server; this solves it without one โ€” a signed DHT record,
8
+ * addressed by public key, naming whichever infohash is current.
9
+ *
10
+ * Only the node that builds needs the private key. Serving nodes carry the
11
+ * public half on the catalog entry and hand it out in the TileJSON, and
12
+ * publishing is the only operation the secret is used for. Two nodes
13
+ * publishing under one key would fight over `seq`, so this is deliberately a
14
+ * single-publisher design.
15
+ *
16
+ * The salt is the category name, so one keypair addresses every category
17
+ * rather than needing one each.
18
+ *
19
+ * The part that rots quietly: DHT nodes drop mutable items after roughly two
20
+ * hours. A record published once works all afternoon and is gone by evening,
21
+ * so republishing on a timer is not an optimisation here โ€” it is the feature.
22
+ */
23
+
24
+ import { mutableMagnet, publishInfoHash } from './mutable.js';
25
+
26
+ /** Republish well inside the ~2h a DHT keeps an item. */
27
+ const DEFAULT_INTERVAL_MS = 30 * 60 * 1000;
28
+
29
+ /** Grace period before the first publish, for the DHT to find peers. */
30
+ const DEFAULT_READY_MS = 15_000;
31
+
32
+ export class MutablePublisher {
33
+ #catalog;
34
+ #dht;
35
+ #key;
36
+ #intervalMs;
37
+ #timer = null;
38
+ #stopped = false;
39
+ #log;
40
+ /** Last published infohash per category, so an unchanged build stays quiet. */
41
+ #published = new Map();
42
+
43
+ /**
44
+ * @param {object} options - Wiring.
45
+ * @param {object} options.catalog - Catalog to read categories from.
46
+ * @param {object} options.dht - A bittorrent-dht instance.
47
+ * @param {object} options.key - Keypair from `publisherKeyFromPem`.
48
+ * @param {number} [options.intervalMs] - Republish interval.
49
+ * @param {Function} [options.log] - Where to report.
50
+ */
51
+ constructor({ catalog, dht, key, intervalMs, log = console.log }) {
52
+ this.#catalog = catalog;
53
+ this.#dht = dht;
54
+ this.#key = key;
55
+ this.#intervalMs = intervalMs ?? DEFAULT_INTERVAL_MS;
56
+ this.#log = log;
57
+ }
58
+
59
+ /** @returns {string} - The public key, hex, which is safe to publish. */
60
+ get publicKeyHex() {
61
+ return Buffer.from(this.#key.publicKey).toString('hex');
62
+ }
63
+
64
+ /**
65
+ * The newest archive in each category โ€” what a record points at.
66
+ * @returns {Map<string, object>} - Category to catalog entry.
67
+ */
68
+ #current() {
69
+ const newest = new Map();
70
+ for (const category of this.#catalog.categories()) {
71
+ // byCategory() is newest first, the same rule /latest/<category>/ uses,
72
+ // so the record and the endpoint cannot disagree about what is current.
73
+ const [entry] = this.#catalog.byCategory(category);
74
+ if (entry) newest.set(category, entry);
75
+ }
76
+ return newest;
77
+ }
78
+
79
+ /**
80
+ * Publishes every category once.
81
+ * @param {object} [options] - Behaviour.
82
+ * @param {boolean} [options.force] - Log even when nothing changed.
83
+ * @returns {Promise<object[]>} - What was published.
84
+ */
85
+ async publishAll(options = {}) {
86
+ const done = [];
87
+ for (const [category, entry] of this.#current()) {
88
+ if (this.#stopped) break;
89
+ // Republished even when unchanged: the record expires whether or not the
90
+ // build has moved. `force` only decides whether it is worth saying.
91
+ const changed = this.#published.get(category) !== entry.infoHash;
92
+ try {
93
+ const result = await publishInfoHash(this.#dht, this.#key, entry.infoHash, {
94
+ salt: category,
95
+ });
96
+ this.#published.set(category, entry.infoHash);
97
+ done.push({ category, infoHash: entry.infoHash, ...result });
98
+
99
+ // Stamped on the entry so the TileJSON's torrent block carries the
100
+ // identity without any endpoint needing to know a publisher exists.
101
+ await this.#catalog.put({
102
+ infoHash: entry.infoHash,
103
+ mutable: { publicKey: this.publicKeyHex, salt: category, seq: result.seq },
104
+ });
105
+
106
+ if (changed || options.force) {
107
+ this.#log(
108
+ `[mutable] ${category} -> ${entry.infoHash.slice(0, 12)} ` +
109
+ `(seq ${result.seq}, ${result.nodes} nodes)`,
110
+ );
111
+ }
112
+ } catch (error) {
113
+ // One category failing must not stop the rest.
114
+ this.#log(`[mutable] ${category} failed: ${error.message}`);
115
+ }
116
+ }
117
+ return done;
118
+ }
119
+
120
+ /**
121
+ * The magnet a style should point at for a category.
122
+ *
123
+ * Public key only โ€” there is nothing secret in it, which is why every
124
+ * serving node can hand it out and none of them can publish.
125
+ * @param {string} category - Which category.
126
+ * @param {object} [entry] - Newest entry, for the name and web seeds.
127
+ * @returns {string} - A BEP 46 magnet URI.
128
+ */
129
+ magnetFor(category, entry) {
130
+ return mutableMagnet(this.#key.publicKey, {
131
+ salt: category,
132
+ name: entry?.name,
133
+ webSeeds: entry?.webSeeds,
134
+ });
135
+ }
136
+
137
+ /**
138
+ * Starts publishing, and keeps republishing before the records expire.
139
+ * @param {object} [options] - Timing.
140
+ * @param {number} [options.readyMs] - Grace before the first publish.
141
+ * @returns {void}
142
+ */
143
+ start(options = {}) {
144
+ // A put into a DHT that has not found peers yet reaches nobody, and
145
+ // bootstrapping is the first thing a fresh node does. Waiting costs one
146
+ // interval of staleness at worst and makes the first publish mean
147
+ // something.
148
+ const first = setTimeout(() => {
149
+ if (this.#stopped) return;
150
+ this.publishAll({ force: true }).catch((error) =>
151
+ this.#log(`[mutable] first publish failed: ${error.message}`),
152
+ );
153
+ }, options.readyMs ?? DEFAULT_READY_MS);
154
+ first.unref?.();
155
+
156
+ this.#timer = setInterval(() => {
157
+ this.publishAll().catch((error) =>
158
+ this.#log(`[mutable] republish failed: ${error.message}`),
159
+ );
160
+ }, this.#intervalMs);
161
+ this.#timer.unref?.();
162
+
163
+ this.#log(
164
+ `[mutable] publishing ${this.#catalog.categories().length} categories as ` +
165
+ `${this.publicKeyHex.slice(0, 16)}โ€ฆ every ${Math.round(this.#intervalMs / 60000)}m`,
166
+ );
167
+ }
168
+
169
+ /**
170
+ * Stops republishing.
171
+ * @returns {void}
172
+ */
173
+ stop() {
174
+ this.#stopped = true;
175
+ if (this.#timer) clearInterval(this.#timer);
176
+ this.#timer = null;
177
+ }
178
+ }
@@ -0,0 +1,249 @@
1
+ /**
2
+ * What this node has actually served.
3
+ *
4
+ * A tile request is answered and forgotten, which leaves the most ordinary
5
+ * operational questions unanswerable: which archive is carrying the load, which
6
+ * zooms are being asked for, whether a node behind a balancer is getting its
7
+ * share, and whether the thing hammering it arrived directly or through the
8
+ * proxy. Everything here is in memory and bounded โ€” counters that never grow
9
+ * past the number of archives, and a fixed ring of recent requests โ€” so it
10
+ * costs the same whether the node has served a hundred tiles or a billion.
11
+ *
12
+ * Deliberately not persisted. Restarting is how you reset it, and a node that
13
+ * writes an access log has retention and disk questions this does not.
14
+ */
15
+
16
+ /** How many recent requests to keep, when the config says nothing. */
17
+ const DEFAULT_RECENT = 200;
18
+
19
+ /** How many durations to keep per archive for percentiles. */
20
+ const SAMPLE_SIZE = 512;
21
+
22
+ /**
23
+ * A fixed-size sample of durations, for percentiles without unbounded memory.
24
+ *
25
+ * Keeps the most recent SAMPLE_SIZE values rather than a true reservoir: what
26
+ * an operator wants is "how is it behaving now", and an even sample over all
27
+ * time hides a node that became slow ten minutes ago.
28
+ */
29
+ class Durations {
30
+ #values = [];
31
+ #next = 0;
32
+
33
+ /**
34
+ * Records one duration.
35
+ * @param {number} ms - How long the request took.
36
+ * @returns {void}
37
+ */
38
+ add(ms) {
39
+ if (!Number.isFinite(ms)) return;
40
+ if (this.#values.length < SAMPLE_SIZE) {
41
+ this.#values.push(ms);
42
+ return;
43
+ }
44
+ this.#values[this.#next] = ms;
45
+ this.#next = (this.#next + 1) % SAMPLE_SIZE;
46
+ }
47
+
48
+ /**
49
+ * The value below which the given fraction of samples fall.
50
+ * @param {number} fraction - Between 0 and 1.
51
+ * @returns {number | null} - Milliseconds, or null with no samples.
52
+ */
53
+ percentile(fraction) {
54
+ if (this.#values.length === 0) return null;
55
+ const sorted = [...this.#values].sort((a, b) => a - b);
56
+ const index = Math.min(
57
+ sorted.length - 1,
58
+ Math.max(0, Math.ceil(fraction * sorted.length) - 1),
59
+ );
60
+ return sorted[index];
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Per-archive counters and a ring of recent requests.
66
+ */
67
+ export class TileStats {
68
+ #archives = new Map();
69
+ #recent = [];
70
+ #recentNext = 0;
71
+ #limit;
72
+ #since = new Date().toISOString();
73
+ #total = 0;
74
+ #bytes = 0;
75
+
76
+ /**
77
+ * @param {object} [options] - Configuration.
78
+ * @param {number} [options.recent] - How many recent requests to keep.
79
+ */
80
+ constructor(options = {}) {
81
+ const asked = Number(options.recent);
82
+ // Zero is a meaningful answer โ€” counters without the ring โ€” so it is not
83
+ // treated as "unset". Negative or unparseable falls back to the default.
84
+ this.#limit = Number.isFinite(asked) && asked >= 0 ? Math.floor(asked) : DEFAULT_RECENT;
85
+ }
86
+
87
+ /**
88
+ * Records one served tile request.
89
+ * @param {object} entry - What was served.
90
+ * @param {string} entry.infoHash - Which archive.
91
+ * @param {string} [entry.name] - Its filename, for readability.
92
+ * @param {number} entry.z - Zoom.
93
+ * @param {number} [entry.x] - Column.
94
+ * @param {number} [entry.y] - Row.
95
+ * @param {number} entry.status - HTTP status answered.
96
+ * @param {number} [entry.bytes] - Body size.
97
+ * @param {number} [entry.ms] - How long it took.
98
+ * @param {string} [entry.ip] - Who asked, as seen by this process.
99
+ * @param {string} [entry.at] - ISO timestamp, for tests.
100
+ * @returns {void}
101
+ */
102
+ record(entry) {
103
+ const { infoHash, z, status } = entry;
104
+ if (!infoHash) return;
105
+
106
+ let archive = this.#archives.get(infoHash);
107
+ if (!archive) {
108
+ archive = {
109
+ name: entry.name,
110
+ requests: 0,
111
+ bytes: 0,
112
+ byZoom: new Map(),
113
+ byStatus: new Map(),
114
+ clients: new Map(),
115
+ durations: new Durations(),
116
+ firstSeen: entry.at ?? new Date().toISOString(),
117
+ lastSeen: null,
118
+ };
119
+ this.#archives.set(infoHash, archive);
120
+ }
121
+ // A rename or a later import can fill this in after the first request.
122
+ if (!archive.name && entry.name) archive.name = entry.name;
123
+
124
+ const bytes = Number.isFinite(entry.bytes) ? entry.bytes : 0;
125
+ archive.requests += 1;
126
+ archive.bytes += bytes;
127
+ archive.lastSeen = entry.at ?? new Date().toISOString();
128
+ archive.durations.add(entry.ms);
129
+ this.#total += 1;
130
+ this.#bytes += bytes;
131
+
132
+ if (Number.isInteger(z)) {
133
+ archive.byZoom.set(z, (archive.byZoom.get(z) ?? 0) + 1);
134
+ }
135
+ if (Number.isFinite(status)) {
136
+ archive.byStatus.set(status, (archive.byStatus.get(status) ?? 0) + 1);
137
+ }
138
+ if (entry.ip) {
139
+ // Counted rather than listed: a busy node sees a handful of distinct
140
+ // sources โ€” the proxy, a few LAN clients โ€” and the count is the answer
141
+ // to "is this arriving directly or through HAProxy".
142
+ const seen = archive.clients.get(entry.ip) ?? { requests: 0, bytes: 0 };
143
+ seen.requests += 1;
144
+ seen.bytes += bytes;
145
+ archive.clients.set(entry.ip, seen);
146
+ }
147
+
148
+ if (this.#limit === 0) return;
149
+ const row = {
150
+ at: archive.lastSeen,
151
+ ip: entry.ip ?? null,
152
+ infoHash,
153
+ name: archive.name ?? null,
154
+ z: Number.isInteger(z) ? z : null,
155
+ x: Number.isInteger(entry.x) ? entry.x : null,
156
+ y: Number.isInteger(entry.y) ? entry.y : null,
157
+ status: status ?? null,
158
+ bytes,
159
+ ms: Number.isFinite(entry.ms) ? entry.ms : null,
160
+ };
161
+ if (this.#recent.length < this.#limit) {
162
+ this.#recent.push(row);
163
+ } else {
164
+ this.#recent[this.#recentNext] = row;
165
+ this.#recentNext = (this.#recentNext + 1) % this.#limit;
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Everything recorded, as plain JSON.
171
+ * @param {object} [options] - Shaping.
172
+ * @param {number} [options.recent] - How many recent rows to return.
173
+ * @returns {object} - The report.
174
+ */
175
+ snapshot(options = {}) {
176
+ const archives = {};
177
+ for (const infoHash of this.#archives.keys()) {
178
+ archives[infoHash] = this.forArchive(infoHash);
179
+ }
180
+
181
+ const asked = Number(options.recent);
182
+ const wanted = Number.isFinite(asked) && asked >= 0 ? Math.floor(asked) : this.#limit;
183
+
184
+ return {
185
+ since: this.#since,
186
+ requests: this.#total,
187
+ bytes: this.#bytes,
188
+ archives,
189
+ recent: this.recent(wanted),
190
+ };
191
+ }
192
+
193
+ /**
194
+ * What one archive has served, or null if it has served nothing.
195
+ *
196
+ * Separate from snapshot() so the per-archive detail endpoint can answer
197
+ * "what is this one doing" without building a report about every other.
198
+ * @param {string} infoHash - Which archive.
199
+ * @returns {object | null} - Its counters.
200
+ */
201
+ forArchive(infoHash) {
202
+ const a = this.#archives.get(infoHash);
203
+ if (!a) return null;
204
+ return {
205
+ name: a.name ?? null,
206
+ requests: a.requests,
207
+ bytes: a.bytes,
208
+ byZoom: Object.fromEntries([...a.byZoom].sort((x, y) => x[0] - y[0])),
209
+ byStatus: Object.fromEntries([...a.byStatus].sort((x, y) => x[0] - y[0])),
210
+ clients: Object.fromEntries(
211
+ [...a.clients]
212
+ .sort((x, y) => y[1].requests - x[1].requests)
213
+ .map(([ip, seen]) => [ip, seen.requests]),
214
+ ),
215
+ p50ms: a.durations.percentile(0.5),
216
+ p95ms: a.durations.percentile(0.95),
217
+ firstSeen: a.firstSeen,
218
+ lastSeen: a.lastSeen,
219
+ };
220
+ }
221
+
222
+ /**
223
+ * The most recent requests, newest first.
224
+ * @param {number} [count] - How many to return.
225
+ * @returns {object[]} - Recent rows.
226
+ */
227
+ recent(count = this.#limit) {
228
+ if (this.#recent.length === 0 || count <= 0) return [];
229
+ // The ring is oldest-first from the write cursor once it has wrapped.
230
+ const ordered =
231
+ this.#recent.length < this.#limit
232
+ ? [...this.#recent]
233
+ : [...this.#recent.slice(this.#recentNext), ...this.#recent.slice(0, this.#recentNext)];
234
+ return ordered.reverse().slice(0, count);
235
+ }
236
+
237
+ /**
238
+ * Forgets everything, as a restart would.
239
+ * @returns {void}
240
+ */
241
+ reset() {
242
+ this.#archives.clear();
243
+ this.#recent = [];
244
+ this.#recentNext = 0;
245
+ this.#total = 0;
246
+ this.#bytes = 0;
247
+ this.#since = new Date().toISOString();
248
+ }
249
+ }
package/src/tilejson.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { mutableMagnet } from './mutable.js';
1
2
  import { TileStore } from './tiles.js';
2
3
 
3
4
  /**
@@ -95,6 +96,13 @@ function buildTorrentBlock(entry, root) {
95
96
  publicKey: entry.mutable.publicKey,
96
97
  salt: entry.mutable.salt,
97
98
  seq: entry.mutable.seq,
99
+ // Built here so a consumer does not have to know how to assemble one,
100
+ // and buildable by any node, because it contains only the public half.
101
+ magnet: mutableMagnet(entry.mutable.publicKey, {
102
+ salt: entry.mutable.salt,
103
+ name: entry.name,
104
+ webSeeds: entry.webSeeds,
105
+ }),
98
106
  };
99
107
  }
100
108
  return block;
@@ -1349,6 +1349,15 @@
1349
1349
  <div><span>origin</span><b>${escapeHtml(originOf(entry).label)}</b>
1350
1350
  <span class="sub">${escapeHtml(originOf(entry).detail)}</span></div>
1351
1351
  <div><span>reading via</span><b>${escapeHtml(entry.reading?.mode ?? 'not open')}</b></div>
1352
+ <div><span>tiles served</span><b>${
1353
+ entry.served ? entry.served.requests.toLocaleString() : 'โ€”'
1354
+ }</b><span class="sub">${
1355
+ entry.served
1356
+ ? `${bytes(entry.served.bytes)} sent ยท ${
1357
+ entry.served.p50ms === null ? 'โ€”' : `${Math.round(entry.served.p50ms)} ms`
1358
+ } median`
1359
+ : 'since this node started'
1360
+ }</span></div>
1352
1361
  <div><span>format</span><b>${escapeHtml(summary.format ?? 'โ€”')}</b></div>
1353
1362
  <div><span>zoom</span><b>${summary.minZoom ?? 'โ€”'}โ€“${summary.maxZoom ?? 'โ€”'}</b></div>
1354
1363
  <div><span>web seeds</span><b>${(entry.webSeeds ?? []).length}</b></div>
@@ -1371,6 +1380,7 @@
1371
1380
  <button id="copy-magnet">Copy magnet</button>
1372
1381
  <button id="copy-hash">Copy infohash</button>
1373
1382
  ${servable ? `<button id="copy-tilejson">Copy TileJSON URL</button>
1383
+ <button id="copy-tilejson-swarm" title="The same URL with the magnet in the fragment. A fragment is never sent to the server, so ordinary clients fetch the TileJSON exactly as before; a torrent-aware client reads the magnet from it and can still join the swarm when this server is unreachable.">Copy TileJSON URL + magnet</button>
1374
1384
  <a href="${tileJson}" target="_blank" rel="noreferrer"><button type="button">Open TileJSON</button></a>
1375
1385
  <a href="${base}/archives/${entry.infoHash}/preview" target="_blank" rel="noreferrer"><button type="button">${
1376
1386
  summary.format === 'pbf' ? 'Inspect' : 'Preview'
@@ -1642,6 +1652,20 @@
1642
1652
  $('copy-magnet').onclick = () => copy(entry.magnet ?? '', 'Magnet link');
1643
1653
  if (servable) {
1644
1654
  $('copy-tilejson').onclick = () => copy(tileJson, 'TileJSON URL');
1655
+ // The magnet rides in the fragment, which is client-side only and
1656
+ // never sent in the request. So the same string serves both: an
1657
+ // ordinary client fetches the TileJSON and ignores the fragment, and
1658
+ // a torrent-aware one reads the magnet before making any call at all
1659
+ // โ€” which is what lets it start when this server is down.
1660
+ //
1661
+ // A magnet is legal in a fragment unencoded (RFC 3986 allows ?, &, =
1662
+ // and : there), and leaving it readable matters for something people
1663
+ // paste into a style file by hand.
1664
+ $('copy-tilejson-swarm').onclick = () =>
1665
+ copy(
1666
+ entry.magnet ? `${tileJson}#${entry.magnet}` : tileJson,
1667
+ entry.magnet ? 'TileJSON URL with magnet' : 'TileJSON URL (no magnet on this archive)',
1668
+ );
1645
1669
  }
1646
1670
  $('copy-hash').onclick = () => copy(entry.infoHash, 'Infohash');
1647
1671