pmtiles-swarm 0.28.0 โ†’ 0.30.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,68 @@
7
7
  ### ๐Ÿž Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.30.0
11
+ ### โœจ Features and improvements
12
+ - **The style URL now carries the `.torrent` URL as well as the magnet.** Piece hashes reach a
13
+ browser only from a peer, over BEP 9 โ€” there is no other route to them โ€” so a magnet alone
14
+ leaves a page waiting on a tracker connection and a WebRTC handshake before it can read a byte,
15
+ and never gets there at all on a network that blocks the trackers. One ordinary HTTPS request for
16
+ the metainfo removes that dependency, and saves a conventional client the same round trip.
17
+
18
+ Both handles now ride in the fragment of the `styleUrl` on `/api/categories` and `/latest/`, and
19
+ of what the console's **Copy TileJSON URL + swarm** button produces. A fragment is still never
20
+ sent in a request, so an ordinary client fetches the TileJSON and ignores all of it.
21
+
22
+ For a category the `.torrent` handle is category-scoped too โ€” `/latest/<category>/archive.torrent`
23
+ redirects to whatever build is current โ€” so unlike a plain magnet it does not go stale on the next
24
+ build. That was previously true only where the node publishes a BEP 46 key.
25
+
26
+ **This is a format change.** The fragment used to be a bare `#magnet:?โ€ฆ`; it is now
27
+ `#torrent=โ€ฆ&magnet=โ€ฆ` with both values percent-encoded, because `&` separates them and a magnet
28
+ is full of them. A reader that took the whole fragment for a magnet must read `magnet=` out of it
29
+ โ€” `URLSearchParams` does it in one call. The bare form was not kept for the single-handle case: a
30
+ fragment whose shape depends on what happened to be available means every reader has to handle
31
+ both anyway.
32
+
33
+ - **The Added column shows the same date and time on every row.** It used to shorten today's rows
34
+ to a time and older ones to a date, which reads as two different quantities in one column and
35
+ makes the eye stop to work out which it is looking at. Seconds are dropped rather than the date,
36
+ since nothing here is sorted finely enough for them to matter; hovering still gives them.
37
+
38
+ - _...Add new stuff here..._
39
+
40
+ ### ๐Ÿž Bug fixes
41
+ - _...Add new stuff here..._
42
+
43
+ ## 0.29.0
44
+ ### โœจ Features and improvements
45
+ - **A connection indicator in the header, for whether the swarm can reach this node.** A node
46
+ nothing can connect to still downloads and still uploads โ€” it dials out and its transfers work โ€”
47
+ so none of its own traffic reveals that half the swarm can never start a conversation with it.
48
+ What it loses is invisible and permanent: fewer peers, slower starts, and a seed nobody fetches
49
+ from unless they were introduced to it first.
50
+
51
+ Green when something has connected inward, amber when the node is listening and nothing ever
52
+ has, red when it is not listening at all. libtorrent answers from
53
+ `net.has_incoming_connections`, which latches for the session, so a reachable node that is
54
+ merely quiet stays green rather than flickering when its last peer leaves. WebTorrent keeps no
55
+ such gauge, so it is assembled from the wires โ€” each carries the direction it was made in โ€” and
56
+ latched for the same reason.
57
+
58
+ Reported per engine rather than blended. Two engines means two listening ports, forwarded
59
+ separately, and one can be reachable while the other is not; a single verdict would have to hide
60
+ the one somebody needs to fix. The header shows the primary and names both on hover.
61
+
62
+ The amber state reads "no incoming yet", not "firewalled". On a node no peer has tried those are
63
+ the same observation, and claiming the first would put a warning on a node that is merely new.
64
+ An engine that cannot answer hides the indicator instead of showing red โ€” not being able to ask
65
+ is not the same as being unreachable, and a red light on a healthy node is worse than none.
66
+
67
+ Needs pmtiles-torrent 0.5.0 for the libtorrent engine; against an older sidecar the indicator
68
+ simply stays hidden.
69
+
70
+ ### ๐Ÿž Bug fixes
71
+
10
72
  ## 0.28.0
11
73
  ### โœจ Features and improvements
12
74
  - **The archive list can be searched and sorted, and says when each archive was added.** `Added` is
package/README.md CHANGED
@@ -60,9 +60,11 @@ day are followed by a dated URL template or by reading a directory listing, on a
60
60
  per source.
61
61
 
62
62
  **Has a console.** Everything above is done from a web UI in the shape of a torrent client:
63
- archives with progress, ratio and expiry; tabbed detail with trackers, peers, HTTP sources and
64
- content; and a settings screen covering monitored folders, watched web locations, remote nodes,
65
- save locations, access tokens and the external-program hooks.
63
+ archives with progress, ratio, expiry and when each was added, searchable and sortable by any of
64
+ them; tabbed detail with trackers, peers, HTTP sources and content; a chart of what the swarm has
65
+ been sending and receiving, kept in SQLite so it survives a restart; an indicator saying whether
66
+ peers can reach this node at all; and a settings screen covering monitored folders, watched web
67
+ locations, remote nodes, save locations, access tokens and the external-program hooks.
66
68
 
67
69
  **Publishes RSS.** `/feed.xml`, and `/feed/<category>.xml` per category. Plain RSS 2.0 with
68
70
  torrent enclosures, so **qBittorrent's built-in RSS auto-downloader can subscribe today** with no
@@ -739,7 +741,7 @@ which the endpoint answers 501.
739
741
 
740
742
  | Method | Path | Purpose |
741
743
  | --------------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
742
- | `GET` | `/api/status` | Engine health, counts, watched folders, save locations and free space |
744
+ | `GET` | `/api/status` | Engine health, counts, watched folders, save locations, free space, and whether the swarm can reach this node |
743
745
  | `GET` | `/api/torrents` | Catalog joined with live swarm state and what is left of each seeding limit |
744
746
  | `POST` | `/api/torrents` | Add via `{path}`, `{url}`, `{magnet}`, `{torrentUrl}`, or a raw `.torrent` body |
745
747
  | `DELETE` | `/api/torrents/:infoHash` | Remove (`?deleteData=true` to delete data too) |
@@ -255,19 +255,22 @@ remember where it had got to.
255
255
 
256
256
  ### Bootstrapping without the server
257
257
 
258
- A torrent-aware client still has to _learn_ the magnet from somewhere, and until
259
- it does, the swarm โ€” the part that depends on no server โ€” is unreachable
260
- precisely when the server is down. The fix is that the magnet travels in the
258
+ A torrent-aware client still has to _learn_ where to join from somewhere, and
259
+ until it does, the swarm โ€” the part that depends on no server โ€” is unreachable
260
+ precisely when the server is down. The fix is that two handles travel in the
261
261
  **fragment** of the TileJSON URL a style already carries:
262
262
 
263
263
  ```
264
- https://swarm.example.org/latest/openmaptiles/tiles.json#magnet:?xs=urn:btpk:โ€ฆ&s=openmaptiles&ws=โ€ฆ
264
+ https://โ€ฆ/latest/openmaptiles/tiles.json#torrent=<the .torrent URL>&magnet=<magnet:?xs=urn:btpk:โ€ฆ>
265
265
  ```
266
266
 
267
267
  A fragment is never sent in an HTTP request, so ordinary clients fetch the
268
- TileJSON and ignore it while a swarm-aware one reads the magnet before making any
269
- call. With a BEP 46 key in it (`xs=urn:btpk:`) rather than an infohash, that
270
- string does not go stale on the next build either. See
268
+ TileJSON and ignore it while a swarm-aware one joins before making any call. The
269
+ `.torrent` is the fast handle โ€” a browser can get piece hashes no other way than
270
+ from a peer, so fetching the metainfo over HTTPS is what lets a page start
271
+ without waiting on one โ€” and the magnet is the one that needs no server at all.
272
+ With a BEP 46 key in it (`xs=urn:btpk:`) rather than an infohash, neither goes
273
+ stale on the next build. See
271
274
  [serving-tiles.md](serving-tiles.md#a-fragment-that-survives-a-rebuild).
272
275
 
273
276
  ---
@@ -566,6 +566,28 @@ Note what the client address means behind a proxy: without `X-Forwarded-For` it
566
566
  the proxy's own address, which still answers whether a request arrived directly or
567
567
  through it, but not who sent it.
568
568
 
569
+ ### `traffic`
570
+
571
+ What the swarm side has been moving, at `GET /api/traffic` and as the chart in the
572
+ console. Upload and download speed per archive, sampled on a timer.
573
+
574
+ | setting | default | |
575
+ | ----------------------- | ------- | ------------------------- |
576
+ | `traffic.sampleSeconds` | `15` | how finely it looks |
577
+ | `traffic.keepHours` | `168` | how far back it remembers |
578
+
579
+ Two knobs because they are two questions. Unlike `tileStats` this is kept in
580
+ SQLite, in `stats.db` beside the catalog rather than beside the configuration, and
581
+ the difference is deliberate: tile counters answer a question about now and are
582
+ cheap to rebuild by waiting, while a bandwidth history answers a question about
583
+ the past and cannot be rebuilt at all โ€” restarting to pick up a new version would
584
+ erase exactly the week somebody wanted to look at.
585
+
586
+ A week of 15-second samples is roughly 40,000 rows per archive, a few megabytes
587
+ for a node carrying twenty. Both settings reload in place. See
588
+ [running-as-a-service.md](running-as-a-service.md#the-statistics-database) for
589
+ sizing and for what a locked-down unit file has to allow.
590
+
569
591
  ## Checking for changed sources
570
592
 
571
593
  `originCheckIntervalSeconds` (default `0`, disabled) controls how often to
@@ -586,8 +608,8 @@ A changed source does not invalidate the existing torrent. See
586
608
 
587
609
  | setting | default | |
588
610
  | --------------------------- | ------- | ------------------------------------------------------ |
589
- | `fetchAttempts` | `10` | how many times to resume a download that stopped early |
590
- | `fetchRetrySeconds` | `5` | how long to wait before resuming |
611
+ | `fetchAttempts` | `10` | consecutive failures without progress before giving up |
612
+ | `fetchRetrySeconds` | `30` | base delay, multiplied by the consecutive count |
591
613
  | `resumeSaveIntervalSeconds` | `300` | how often to write resume data. `0` disables it |
592
614
 
593
615
  A planet archive is hours of transfer, and a connection that drops partway is
@@ -598,6 +620,28 @@ an ETag or Last-Modified to prove the file has not changed underneath. Where it
598
620
  does not, the download restarts, because splicing two builds together produces a
599
621
  torrent for bytes that never existed.
600
622
 
623
+ **The budget counts consecutive failures that moved nothing, not failures.** An
624
+ attempt that transferred bytes proves the source and the route are alive, so it
625
+ resets the count. Counting every failure made the budget a property of the whole
626
+ download rather than of the trouble it is in, which for a large archive are not
627
+ the same thing: a 700 GiB transfer reached 226 GB across six separate stalls and
628
+ then spent its last four attempts on one bad minute, because a quarter of a
629
+ terabyte of progress counted for nothing. Progress is measured against the
630
+ high-water mark rather than the previous attempt, since an attempt can fail
631
+ having written less than was already on disk.
632
+
633
+ The delay grows with the consecutive count โ€” at the default, 30s then 60s then
634
+ 90s, so ten of them span something over twenty minutes rather than the
635
+ forty-five seconds a flat delay gave. Because progress resets the count, an
636
+ unlucky download can go round more times than `fetchAttempts` names; that is
637
+ intended, and bounded by a ceiling of ten times the budget so a source dribbling
638
+ a few bytes before dropping cannot retry for ever.
639
+
640
+ **Giving up keeps the partial file.** The staging path is derived from the URL,
641
+ so re-adding the same URL in the console resumes from where it stopped instead of
642
+ starting again, and the error says how many bytes are there to resume from. Only
643
+ cancelling deletes it.
644
+
601
645
  Resume data is what lets a restart skip re-hashing the store โ€” on an 800 GB
602
646
  archive, the difference between instant and half an hour. A clean stop always
603
647
  writes it; `resumeSaveIntervalSeconds` is for the stops that are not clean, where
package/docs/engines.md CHANGED
@@ -223,6 +223,35 @@ unforwarded NAT can never connect to each other, so the ones that need you most
223
223
  cannot serve. UPnP and NAT-PMP are on by default in both engines and will often open it for you;
224
224
  a router with either disabled will not say so.
225
225
 
226
+ ### The console says whether it worked
227
+
228
+ Nothing about a node's own traffic reveals that half the swarm cannot reach it. It dials out, its
229
+ transfers work, and it looks healthy โ€” the cost is invisible and permanent. So the console header
230
+ carries an indicator, also in `GET /api/status` as `reachability`:
231
+
232
+ | Colour | State | Means |
233
+ | ------ | ---------- | ------------------------------------------------------ |
234
+ | green | `open` | something has connected inward โ€” the port is reachable |
235
+ | amber | `unproven` | listening, and nothing ever has |
236
+ | red | `offline` | not listening at all |
237
+ | hidden | `unknown` | the engine cannot answer |
238
+
239
+ libtorrent answers from `net.has_incoming_connections`; WebTorrent has no such gauge, so it is
240
+ assembled from the wires, each of which carries the direction it was made in. Both latch: the
241
+ question is whether the swarm _can_ reach this node, not whether somebody is connected right now,
242
+ so a reachable node that is merely quiet stays green rather than dropping to amber when its last
243
+ peer leaves.
244
+
245
+ Reported per engine, not blended. Two engines means two listening ports, forwarded separately, and
246
+ one can be reachable while the other is not; the header shows the primary and names both on hover.
247
+
248
+ **Amber is not a fault.** On a node no peer has tried, blocked and untried are the same
249
+ observation, and nothing available separates them โ€” which is why it reads "no incoming yet" rather
250
+ than "firewalled". On a busy node it will turn green within minutes; if it does not, the port is
251
+ worth checking. For the same reason an engine that cannot be asked hides the indicator instead of
252
+ showing red: not being able to ask is not the same as being unreachable. The libtorrent side needs
253
+ pmtiles-torrent 0.5.0 or newer.
254
+
226
255
  ### Every libtorrent network setting
227
256
 
228
257
  ```json
@@ -346,7 +375,9 @@ checked and the unreadable ones are joined by magnet instead.
346
375
 
347
376
  ## Writing another engine
348
377
 
349
- Implement `connect`, `add`, `remove`, `list`, `get`, `destroy`, and optionally `peers`.
378
+ Implement `connect`, `add`, `remove`, `list`, `get`, `destroy`, and optionally `peers` and
379
+ `reachability`. Anything optional that is missing is simply not offered โ€” an engine without
380
+ `reachability` hides the indicator rather than reporting a node unreachable.
350
381
  The interface is deliberately small; see
351
382
  [`src/engines/types.js`](../src/engines/types.js) for the contract and
352
383
  [`src/engines/webtorrent.js`](../src/engines/webtorrent.js) for the shortest example.
@@ -231,10 +231,15 @@ rather than exceptional. Each attempt continues from the bytes already on disk w
231
231
  HTTP range request, so a drop costs the retry delay instead of everything transferred so
232
232
  far.
233
233
 
234
+ The budget counts _consecutive_ failures that moved nothing: an attempt that transferred
235
+ bytes proves the source and the route are alive, so it resets the count. The delay grows
236
+ with that count โ€” 30s, 60s, 90s โ€” so ten of them span an outage rather than a moment. When
237
+ it does give up the partial file is kept, and re-adding the same URL resumes from it.
238
+
234
239
  ```json
235
240
  {
236
241
  "fetchAttempts": 10,
237
- "fetchRetrySeconds": 5
242
+ "fetchRetrySeconds": 30
238
243
  }
239
244
  ```
240
245
 
@@ -566,6 +566,15 @@ own, and not `torrentPort`. `6883` sits clear of both.
566
566
  Note that each engine runs its own DHT as well, so a node with both engines has
567
567
  three UDP participants. Only this one is yours to place.
568
568
 
569
+ ### Checking the forward actually happened
570
+
571
+ A forward that did not take is invisible from here: the node still dials out, its
572
+ transfers still work, and nothing in its own numbers says half the swarm can
573
+ never open a connection to it. The console header answers it โ€” green once
574
+ something has connected inward, amber while nothing has, red if the engine is not
575
+ listening at all. Give a new node a few minutes of seeding before reading it;
576
+ amber on a node no peer has tried yet means untried, not blocked.
577
+
569
578
  ## Updating
570
579
 
571
580
  ```sh
@@ -192,20 +192,20 @@ Both peer-to-peer engines reuse the client already seeding the archive rather
192
192
  than starting a second one. One peer pool, one port, one DHT node โ€” and for
193
193
  libtorrent, one sidecar process.
194
194
 
195
- ## Carrying the magnet in the URL fragment
195
+ ## Carrying the swarm in the URL fragment
196
196
 
197
197
  The `torrent` block below solves the problem _after_ the TileJSON has been
198
198
  fetched. It does not solve the one before it: a torrent-aware client that cannot
199
199
  reach this server has nothing to work with, so the swarm โ€” the part that does
200
200
  not depend on any server โ€” is unreachable precisely when the server is down.
201
201
 
202
- The fix is to put the magnet in the URL **fragment**:
202
+ The fix is to put the ways into the swarm in the URL **fragment**:
203
203
 
204
204
  ```json
205
205
  "sources": {
206
206
  "openmaptiles": {
207
207
  "type": "vector",
208
- "url": "https://swarm.example.org/latest/openmaptiles/tiles.json#magnet:?xt=urn:btih:4813a0e6โ€ฆ&dn=โ€ฆ&tr=โ€ฆ&ws=โ€ฆ"
208
+ "url": "https://swarm.example.org/latest/openmaptiles/tiles.json#torrent=https%3A%2F%2Fswarm.example.org%2Flatest%2Fopenmaptiles%2Farchive.torrent&magnet=magnet%3A%3Fxt%3Durn%3Abtih%3A4813a0e6โ€ฆ"
209
209
  }
210
210
  }
211
211
  ```
@@ -213,34 +213,72 @@ The fix is to put the magnet in the URL **fragment**:
213
213
  A fragment is never sent in an HTTP request, so the same string works
214
214
  everywhere:
215
215
 
216
- | Client | What happens |
217
- | --------------------------------- | --------------------------------------------------------------------------------- |
218
- | maplibre-gl-js, Leaflet, anything | fetches the TileJSON, ignores the fragment |
219
- | maplibre-native without a plugin | the same |
220
- | torrent-aware | reads the magnet **before any network call**, and still has it if the fetch fails |
216
+ | Client | What happens |
217
+ | --------------------------------- | ------------------------------------------------------------------- |
218
+ | maplibre-gl-js, Leaflet, anything | fetches the TileJSON, ignores the fragment |
219
+ | maplibre-native without a plugin | the same |
220
+ | torrent-aware | joins **before any network call**, and still can if the fetch fails |
221
221
 
222
- The console's **Copy TileJSON URL + magnet** button produces exactly this.
222
+ The console's **Copy TileJSON URL + swarm** button produces exactly this, and so
223
+ does the `styleUrl` on every row of `/api/categories` and `/latest/`.
223
224
 
224
- A magnet needs no encoding in a fragment โ€” RFC 3986 allows `?`, `&`, `=` and `:`
225
- there โ€” and leaving it readable matters for something people paste into a style
226
- file by hand.
225
+ ### Two handles, and why both
226
+
227
+ They are not a ladder from worse to better. They fail in different directions.
228
+
229
+ | Handle | Needs | Gets you |
230
+ | ---------- | --------------- | --------------------------------------------------- |
231
+ | `torrent=` | this host | the metainfo itself, over one ordinary HTTP request |
232
+ | `magnet=` | a peer, no host | everything, eventually, from the swarm alone |
233
+
234
+ `torrent=` is not redundant with the URL it is attached to. **Piece hashes reach
235
+ a browser only from a peer, over BEP 9** โ€” there is no other route to them โ€” so
236
+ a magnet alone leaves a page waiting on a tracker connection and a WebRTC
237
+ handshake before it can read a byte, and never gets there at all on a network
238
+ that blocks the trackers. Fetching the metainfo over HTTPS removes that
239
+ dependency entirely. It also saves a conventional client the same round trip.
240
+
241
+ `magnet=` is the handle that needs nothing of this node, which is the case the
242
+ fragment exists for in the first place. A client with a DHT should prefer it if
243
+ this host is unreachable; a browser, which has neither DHT nor UDP, should reach
244
+ for the `.torrent` first.
245
+
246
+ For a `/latest/<category>/` URL the `.torrent` handle points at the category too,
247
+ so it redirects to whatever build is current and does not go stale โ€” see the
248
+ caveat below, which it answers for the half of the fragment that a BEP 46 magnet
249
+ otherwise has to.
250
+
251
+ Both are percent-encoded, since `&` separates them and a magnet is full of them.
252
+ `URLSearchParams` reads the fragment; `get('magnet')` gives back the magnet
253
+ exactly.
254
+
255
+ > **This changed in 0.30.0.** The fragment used to be a bare `#magnet:?โ€ฆ` with
256
+ > nothing else in it. A reader that took the whole fragment for a magnet needs to
257
+ > read `magnet=` out of it now. The bare form was not kept for the single-handle
258
+ > case, because a fragment whose shape depends on what happened to be available
259
+ > means every reader has to handle both anyway.
227
260
 
228
261
  ### What a client should do with it
229
262
 
230
- Three paths, each a strict fallback of the one above:
263
+ Four paths, each a fallback of the one above:
231
264
 
232
265
  1. **The TileJSON URL.** One request, the full document including
233
266
  `vector_layers`. Fastest, and what an ordinary client does anyway.
234
- 2. **The `ws=` web seed.** Two HTTP range requests โ€” the header and root
235
- directory near the start of the archive, the JSON metadata at the far end โ€”
236
- and the TileJSON can be derived from them. Works when this API is down but
237
- the file is still on a web server, and it is the same order of cost as (1).
238
- 3. **The swarm.** No HTTP at all. Slowest from cold, because BEP 9 has to
267
+ 2. **The `torrent=` metainfo.** One request, and it yields piece hashes, the
268
+ file name and the web seeds โ€” enough to join and to range-read without any
269
+ peer having spoken yet. Works when the TileJSON route is down but this host
270
+ is up, and it is the only one of these a browser can use to start quickly.
271
+ 3. **The `ws=` web seed** inside the magnet. Two HTTP range requests โ€” the header
272
+ and root directory near the start of the archive, the JSON metadata at the far
273
+ end โ€” and the TileJSON can be derived from them. Works when this API is down
274
+ but the file is still on a web server.
275
+ 4. **The swarm.** No HTTP at all. Slowest from cold, because BEP 9 has to
239
276
  deliver the metainfo first, and the only one that survives the server
240
277
  disappearing entirely.
241
278
 
242
- Everything those need is in the magnet: `xt` identifies the archive, `dn` names
243
- the file, `tr` finds peers, `ws` gives the HTTP fallback.
279
+ Everything those need is in the two handles: `xt` identifies the archive, `dn`
280
+ names the file, `tr` finds peers, `ws` gives the HTTP fallback, and `torrent=`
281
+ gives the metainfo without asking the swarm for it.
244
282
 
245
283
  ### One caveat on `/latest/` URLs
246
284
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.28.0",
3
+ "version": "0.30.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
@@ -66,7 +66,43 @@ function route(handler) {
66
66
  * @returns {import('express').Express} - The configured app.
67
67
  */
68
68
  /**
69
- * A TileJSON URL carrying a magnet in its fragment.
69
+ * Attaches whatever swarm handles exist to a URL, in its fragment.
70
+ *
71
+ * Two handles, and they are not a ladder from worse to better โ€” they fail in
72
+ * different directions:
73
+ *
74
+ * - `torrent=` is the metainfo, and needs this host. Not redundant with the URL
75
+ * it is attached to: piece hashes reach a browser only from a peer over
76
+ * BEP 9, so this is the one handle that works from a page on a network that
77
+ * blocks the trackers, and the one that saves every client the metadata round
78
+ * trip that a magnet costs.
79
+ * - `magnet=` needs no host at all, and needs a peer. The better handle for a
80
+ * client with a DHT, the weaker one for a browser, which has neither DHT nor
81
+ * UDP.
82
+ *
83
+ * Ordered by what a client should reach for first when it can fetch.
84
+ *
85
+ * Both are percent-encoded, which is a change from the bare `#magnet:?โ€ฆ` this
86
+ * used to emit: `&` separates the handles now, and a magnet is full of them.
87
+ * That does break a reader that took the whole fragment for a magnet, and the
88
+ * alternative -- keeping the bare form when there is only one handle -- is
89
+ * worse, because it makes the shape of the fragment depend on what happened to
90
+ * be available, so every reader has to handle both anyway. One shape,
91
+ * `URLSearchParams` reads it, and a magnet survives the round trip exactly.
92
+ *
93
+ * @param {string} url - The URL a style points at.
94
+ * @param {object} handles - `{torrent, magnet}`, either of which may be absent.
95
+ * @returns {string} - The URL, with a fragment if there is anything to put in one.
96
+ */
97
+ function withSwarmHandles(url, { torrent, magnet }) {
98
+ const parts = [];
99
+ if (torrent) parts.push(`torrent=${encodeURIComponent(torrent)}`);
100
+ if (magnet) parts.push(`magnet=${encodeURIComponent(magnet)}`);
101
+ return parts.length > 0 ? `${url}#${parts.join('&')}` : url;
102
+ }
103
+
104
+ /**
105
+ * A TileJSON URL carrying the ways into the swarm in its fragment.
70
106
  * @param {string} category - Which category.
71
107
  * @param {object} newest - Its newest entry.
72
108
  * @param {string} base - Public base URL.
@@ -90,7 +126,14 @@ function styleUrlFor(category, newest, base) {
90
126
  webSeeds: newest.webSeeds,
91
127
  })
92
128
  : newest?.magnet;
93
- return magnet ? `${url}#${magnet}` : url;
129
+ // Category-scoped rather than the newest build's immutable URL, for the same
130
+ // reason the TileJSON in front of it is: it redirects to whatever is current,
131
+ // so a style holding this keeps working across rebuilds. The magnet beside it
132
+ // only manages that when the node publishes a BEP 46 key; this handle manages
133
+ // it always, which makes it the more durable half of the pair as well as the
134
+ // faster one.
135
+ const torrent = `${base}/latest/${category}/archive.torrent`;
136
+ return withSwarmHandles(url, { torrent, magnet });
94
137
  }
95
138
 
96
139
  export function createApp({
@@ -530,6 +573,17 @@ export function createApp({
530
573
  ? config.incompleteSuffix || null
531
574
  : null,
532
575
  engine: { name: engine.name, ok: engineOk, error: engineError },
576
+ // Whether the swarm can reach us, as opposed to whether we can reach
577
+ // it. A node that cannot be connected to still downloads and still
578
+ // uploads, so nothing about its own traffic reveals that half the
579
+ // swarm can never start a conversation with it.
580
+ reachability:
581
+ typeof engine.reachability === 'function'
582
+ ? await engine.reachability().catch((error) => ({
583
+ state: 'unknown',
584
+ error: error.message,
585
+ }))
586
+ : null,
533
587
  archives: catalog.list().length,
534
588
  categories: catalog.categories(),
535
589
  watching: config.watch.map((w) => w.path),
@@ -1868,11 +1922,12 @@ export function createApp({
1868
1922
  servable,
1869
1923
  endpoints: {
1870
1924
  tileJson: servable ? `${base}/latest/${category}/tiles.json` : null,
1871
- // The same URL with a magnet in the fragment, which is what a
1872
- // style should carry. A fragment is never sent in a request, so
1873
- // an ordinary client fetches the TileJSON and ignores it, while
1874
- // a swarm-aware one has the magnet before it makes any call --
1875
- // and can therefore still start when this server cannot answer.
1925
+ // The same URL with the ways into the swarm in the fragment,
1926
+ // which is what a style should carry. A fragment is never sent in
1927
+ // a request, so an ordinary client fetches the TileJSON and
1928
+ // ignores it, while a swarm-aware one has somewhere to join before
1929
+ // it makes any call -- and can therefore still start when this
1930
+ // server cannot answer.
1876
1931
  //
1877
1932
  // The mutable magnet where there is one, because a category is
1878
1933
  // precisely where an infohash goes stale: it names the category
@@ -303,6 +303,29 @@ export class CompositeEngine {
303
303
  * Every archive, with the peers and speeds of all engines added together.
304
304
  * @returns {Promise<import('./types.js').TorrentStatus[]>} - Merged status.
305
305
  */
306
+ /**
307
+ * Reachability, per engine rather than blended into one verdict.
308
+ *
309
+ * Two engines means two listening ports, and they are forwarded separately.
310
+ * One can be reachable while the other is not, so a single answer would have
311
+ * to either pick a winner or average two facts into something that is not
312
+ * true of either -- and the one it got wrong is the one somebody needs to
313
+ * fix. The primary leads because it is the engine that downloads.
314
+ * @returns {Promise<object>} - `{state, engines}`.
315
+ */
316
+ async reachability() {
317
+ const engines = [];
318
+ for (const engine of [this.#primary, ...this.#secondaries]) {
319
+ if (typeof engine.reachability !== 'function') continue;
320
+ const report = await engine.reachability().catch((error) => ({
321
+ state: 'unknown',
322
+ error: error.message,
323
+ }));
324
+ if (report) engines.push({ engine: engine.name, ...report });
325
+ }
326
+ return { ...(engines[0] ?? { state: 'unknown' }), engines };
327
+ }
328
+
306
329
  async list() {
307
330
  if (this.#stopping) return [];
308
331
  const primary = await this.#primary.list();
@@ -284,6 +284,25 @@ export class LibtorrentEngine {
284
284
  return result?.trackers ?? [];
285
285
  }
286
286
 
287
+ /**
288
+ * Whether peers can open a connection to this node, or only the reverse.
289
+ *
290
+ * See the sidecar's op_reachability for what the three states mean and why
291
+ * the middle one is "unproven" rather than "firewalled": on a node with no
292
+ * peers, blocked and untried are the same observation.
293
+ * @returns {Promise<object|null>} - The report, or null when unavailable.
294
+ */
295
+ async reachability() {
296
+ if (this.#stopping) return null;
297
+ try {
298
+ return await this.#call('reachability', {});
299
+ } catch (error) {
300
+ // An engine that cannot answer is not an engine that is unreachable, and
301
+ // reporting it as offline would put a red light on a healthy node.
302
+ return { state: 'unknown', error: error.message };
303
+ }
304
+ }
305
+
287
306
  async list() {
288
307
  // A node that is shutting down still has a console polling it and a sweep
289
308
  // or two in flight. Answering "the sidecar exited" to each of them fills
@@ -60,6 +60,7 @@
60
60
  * @property {(filePath: string, options?: object) => Promise<object>} [createTorrent] - Builds a torrent from a local file, where the engine can do it better than the default โ€” libtorrent produces hybrid v1+v2, which create-torrent cannot.
61
61
  * @property {(infoHash: string) => Promise<object[]>} [trackerStatus] - Per-tracker announce results, where the engine keeps them.
62
62
  * @property {(infoHash: string) => Promise<Uint8Array | null>} [metadata] - The torrent's metainfo once known, so an archive joined by magnet can be written down rather than re-fetched over BEP 9 on every start.
63
+ * @property {() => Promise<object | null>} [reachability] - Whether peers can open a connection to this node, or only the reverse. Three states: `open` (something has connected inward), `unproven` (listening, but nothing ever has) and `offline` (not listening at all). The middle is deliberately not called firewalled -- on a node with no peers, blocked and untried are the same observation.
63
64
  * @property {() => Promise<void>} destroy - Releases resources.
64
65
  */
65
66
 
@@ -34,6 +34,7 @@ import {
34
34
  export class WebTorrentSeedEngine {
35
35
  #options;
36
36
  #client = null;
37
+ #everIncoming = false;
37
38
  /**
38
39
  * A client error that means nothing will ever work.
39
40
  *
@@ -319,6 +320,43 @@ export class WebTorrentSeedEngine {
319
320
  * Lists every torrent the client holds.
320
321
  * @returns {Promise<import('./types.js').TorrentStatus[]>} - Normalised statuses.
321
322
  */
323
+ /**
324
+ * Whether peers can open a connection to this node, or only the reverse.
325
+ *
326
+ * WebTorrent keeps no equivalent of libtorrent's has_incoming_connections
327
+ * gauge, so it is assembled from the wires: every one carries the direction
328
+ * it was made in, and a type ending "Incoming" is somebody who reached us.
329
+ *
330
+ * Latched rather than sampled, which is the whole reason for the field. A
331
+ * wire is gone the moment the peer leaves, so asking "is one open now" would
332
+ * report a reachable node as unproven every time it went quiet. What is worth
333
+ * knowing is whether it has ever happened at all.
334
+ * @returns {Promise<object|null>} - The report, or null when not started.
335
+ */
336
+ async reachability() {
337
+ const client = this.#client;
338
+ if (!client) return null;
339
+
340
+ if (!this.#everIncoming) {
341
+ this.#everIncoming = (client.torrents ?? []).some((torrent) =>
342
+ (torrent.wires ?? []).some((wire) =>
343
+ String(wire.type ?? '').endsWith('Incoming'),
344
+ ),
345
+ );
346
+ }
347
+
348
+ const listening = Boolean(client.listening);
349
+ return {
350
+ state: !listening ? 'offline' : this.#everIncoming ? 'open' : 'unproven',
351
+ listening,
352
+ port: client.torrentPort ?? null,
353
+ peersConnected: (client.torrents ?? []).reduce(
354
+ (sum, torrent) => sum + (torrent.numPeers ?? 0),
355
+ 0,
356
+ ),
357
+ };
358
+ }
359
+
322
360
  async list() {
323
361
  if (!this.#client) return [];
324
362
  return this.#client.torrents.map((torrent) => this.#normalise(torrent));
@@ -49,6 +49,19 @@
49
49
  h2 { font-size: 0.95rem; margin: 0 0 0.6rem; font-weight: 600; }
50
50
  .status { color: var(--muted); font-size: 0.85rem; }
51
51
  .status b { color: var(--fg); font-weight: 600; }
52
+ /* A dot rather than an image: it inherits the theme, needs no asset,
53
+ and stays legible at the size a header allows. */
54
+ .reach {
55
+ display: inline-flex; align-items: center; gap: 0.35rem;
56
+ font-size: 0.8rem; color: var(--muted); cursor: default;
57
+ }
58
+ .reach::before {
59
+ content: ""; width: 0.6rem; height: 0.6rem; border-radius: 50%;
60
+ background: var(--muted);
61
+ }
62
+ .reach.open::before { background: var(--ok); }
63
+ .reach.unproven::before { background: var(--warn); }
64
+ .reach.offline::before { background: var(--bad); }
52
65
  nav { margin-left: auto; display: flex; gap: 0.35rem; }
53
66
  nav button.on { background: var(--accent); color: #fff; border-color: var(--accent); }
54
67
  main { padding: 1.25rem; }
@@ -305,6 +318,7 @@
305
318
  <header>
306
319
  <h1>pmtiles-swarm</h1>
307
320
  <div class="status" id="status">connectingโ€ฆ</div>
321
+ <span id="reach" class="reach" hidden></span>
308
322
  <button id="speed-toggle" class="speed" hidden title="Switch between the normal and alternative speed limits"></button>
309
323
  <nav>
310
324
  <button id="tab-archives" class="on">Archives</button>
@@ -735,11 +749,14 @@
735
749
  const pct = (n) => `${Math.round((n ?? 0) * 100)}%`;
736
750
 
737
751
  /**
738
- * When an archive was added, as something short enough for a column.
752
+ * When an archive was added: the same date and time on every row.
739
753
  *
740
- * A date on its own for anything older than today, and a time for
741
- * today: the question a list answers is "which of these is recent", and
742
- * a full timestamp on every row is harder to scan than either.
754
+ * It used to shorten today's rows to a time and older ones to a date,
755
+ * on the theory that a column is easier to scan that way. It is not:
756
+ * a column of `8/13/2026` with an `08:14 AM` in the middle of it reads
757
+ * as two different quantities, and the eye has to stop and work out
758
+ * which. Seconds are dropped rather than the date, since nothing here
759
+ * is sorted finely enough for them to matter; the title keeps them.
743
760
  * @param {string} value - ISO timestamp.
744
761
  * @returns {string} - Markup for the cell.
745
762
  */
@@ -747,12 +764,15 @@
747
764
  if (!value) return '<span class="sub">โ€”</span>';
748
765
  const when = new Date(value);
749
766
  if (Number.isNaN(when.getTime())) return '<span class="sub">โ€”</span>';
750
- const today = new Date().toDateString() === when.toDateString();
751
- return `<span title="${escapeHtml(when.toLocaleString())}">${
752
- today
753
- ? when.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
754
- : when.toLocaleDateString()
755
- }</span>`;
767
+ return `<span title="${escapeHtml(when.toLocaleString())}">${escapeHtml(
768
+ when.toLocaleString([], {
769
+ year: 'numeric',
770
+ month: 'numeric',
771
+ day: 'numeric',
772
+ hour: '2-digit',
773
+ minute: '2-digit',
774
+ }),
775
+ )}</span>`;
756
776
  };
757
777
 
758
778
  // The same three questions the public page asks, against an admin row.
@@ -987,6 +1007,50 @@
987
1007
  }
988
1008
  };
989
1009
 
1010
+ /**
1011
+ * The connection indicator: can the swarm reach us, or only we it?
1012
+ *
1013
+ * A node nothing can connect to still downloads and still uploads, so
1014
+ * none of its own traffic reveals the problem -- it simply gets fewer
1015
+ * peers, slower starts, and nobody fetching from it unless introduced
1016
+ * first. That is the whole reason for showing it at all.
1017
+ *
1018
+ * The middle state says "no incoming yet" rather than "firewalled",
1019
+ * because those are the same observation on a node no peer has tried.
1020
+ * Calling it firewalled would put a warning on a healthy node that is
1021
+ * merely new or quiet, which is worse than saying less.
1022
+ *
1023
+ * @param {object} report - status.reachability.
1024
+ * @returns {void}
1025
+ */
1026
+ function renderReach(report) {
1027
+ const host = $('reach');
1028
+ if (!report || !report.state || report.state === 'unknown') {
1029
+ host.hidden = true;
1030
+ return;
1031
+ }
1032
+ host.hidden = false;
1033
+ host.className = `reach ${report.state}`;
1034
+
1035
+ const label = {
1036
+ open: 'reachable',
1037
+ unproven: 'no incoming yet',
1038
+ offline: 'not listening',
1039
+ }[report.state];
1040
+ host.textContent = label;
1041
+
1042
+ const detail = (report.engines ?? [report]).map((one) => {
1043
+ const which = one.engine ? `${one.engine}: ` : '';
1044
+ const port = one.port ? ` on port ${one.port}` : '';
1045
+ if (one.state === 'offline') return `${which}not listening`;
1046
+ if (one.state === 'open') {
1047
+ return `${which}${one.incomingConnections ?? 0} peer(s) have connected in${port}`;
1048
+ }
1049
+ return `${which}listening${port}, but nothing has connected in yet โ€” it may be firewalled, or simply untried`;
1050
+ });
1051
+ host.title = detail.join('\n');
1052
+ }
1053
+
990
1054
  async function refresh() {
991
1055
  try {
992
1056
  const [status, list, speed, adds] = await Promise.all([
@@ -1002,6 +1066,7 @@
1002
1066
  // nothing renames it. The engine decides, not the setting alone.
1003
1067
  incompleteMarker = status.incompleteMarker ?? null;
1004
1068
  const engine = status.engine;
1069
+ renderReach(status.reachability);
1005
1070
  $('status').innerHTML =
1006
1071
  `engine <b>${engine.name}</b> ${engine.ok ? 'ready' : 'unavailable'}` +
1007
1072
  ` ยท <b>${status.archives}</b> archives`;
@@ -1504,7 +1569,7 @@
1504
1569
  <button id="copy-magnet">Copy magnet</button>
1505
1570
  <button id="copy-hash">Copy infohash</button>
1506
1571
  ${servable ? `<button id="copy-tilejson">Copy TileJSON URL</button>
1507
- <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>
1572
+ <button id="copy-tilejson-swarm" title="The same URL with the .torrent and 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 them from it, and a browser can fetch the metainfo over HTTP instead of waiting on a peer for it.">Copy TileJSON URL + swarm</button>
1508
1573
  <a href="${tileJson}" target="_blank" rel="noreferrer"><button type="button">Open TileJSON</button></a>
1509
1574
  <a href="${base}/archives/${entry.infoHash}/preview" target="_blank" rel="noreferrer"><button type="button">${
1510
1575
  summary.format === 'pbf' ? 'Inspect' : 'Preview'
@@ -1776,20 +1841,30 @@
1776
1841
  $('copy-magnet').onclick = () => copy(entry.magnet ?? '', 'Magnet link');
1777
1842
  if (servable) {
1778
1843
  $('copy-tilejson').onclick = () => copy(tileJson, 'TileJSON URL');
1779
- // The magnet rides in the fragment, which is client-side only and
1844
+ // The handles ride in the fragment, which is client-side only and
1780
1845
  // never sent in the request. So the same string serves both: an
1781
1846
  // ordinary client fetches the TileJSON and ignores the fragment, and
1782
- // a torrent-aware one reads the magnet before making any call at all
1783
- // โ€” which is what lets it start when this server is down.
1847
+ // a torrent-aware one has somewhere to join before making any call at
1848
+ // all โ€” which is what lets it start when this server is down.
1784
1849
  //
1785
- // A magnet is legal in a fragment unencoded (RFC 3986 allows ?, &, =
1786
- // and : there), and leaving it readable matters for something people
1787
- // paste into a style file by hand.
1788
- $('copy-tilejson-swarm').onclick = () =>
1850
+ // The .torrent URL is there because a browser cannot obtain piece
1851
+ // hashes any other way: they come only from a peer over BEP 9, so a
1852
+ // magnet alone leaves a page waiting on a WebRTC handshake before it
1853
+ // can read a byte. Same fragment, same string, both handles.
1854
+ //
1855
+ // A lone magnet keeps the bare unencoded form โ€” legal in a fragment
1856
+ // (RFC 3986 allows ?, &, = and : there) and readable, which matters
1857
+ // for something people paste into a style file by hand.
1858
+ $('copy-tilejson-swarm').onclick = () => {
1859
+ const parts = [`torrent=${encodeURIComponent(torrentUrl)}`];
1860
+ if (entry.magnet) parts.push(`magnet=${encodeURIComponent(entry.magnet)}`);
1789
1861
  copy(
1790
- entry.magnet ? `${tileJson}#${entry.magnet}` : tileJson,
1791
- entry.magnet ? 'TileJSON URL with magnet' : 'TileJSON URL (no magnet on this archive)',
1862
+ `${tileJson}#${parts.join('&')}`,
1863
+ entry.magnet
1864
+ ? 'TileJSON URL with torrent and magnet'
1865
+ : 'TileJSON URL with torrent (no magnet on this archive)',
1792
1866
  );
1867
+ };
1793
1868
  }
1794
1869
  $('copy-hash').onclick = () => copy(entry.infoHash, 'Infohash');
1795
1870
 
@@ -2513,12 +2588,15 @@
2513
2588
  url.length > 96 ? `${url.slice(0, 96)}โ€ฆ` : url,
2514
2589
  )}</code>${
2515
2590
  label === 'For a style'
2516
- ? `<div class="sub">the magnet rides in the fragment, which is
2517
- never sent to the server โ€” an ordinary client fetches the
2518
- TileJSON and ignores it, a swarm-aware one reads it before
2519
- making any call${
2520
- url.includes('xs=urn:btpk')
2521
- ? ' and follows the category rather than this build'
2591
+ ? `<div class="sub">the .torrent URL and the magnet ride in the
2592
+ fragment, which is never sent to the server โ€” an ordinary
2593
+ client fetches the TileJSON and ignores them, a swarm-aware
2594
+ one reads them before making any call${
2595
+ // Matched unanchored because the magnet is
2596
+ // percent-encoded in the fragment now that it
2597
+ // shares one with the .torrent URL.
2598
+ url.includes('btpk')
2599
+ ? ', and both follow the category rather than this build'
2522
2600
  : ''
2523
2601
  }</div>`
2524
2602
  : ''
@@ -401,9 +401,9 @@
401
401
  card.append(links);
402
402
 
403
403
  const urls = el('div', 'urls');
404
- // The one worth copying: the TileJSON URL with the magnet in its
405
- // fragment, which a plain client fetches over HTTP and a swarm-aware
406
- // one joins directly.
404
+ // The one worth copying: the TileJSON URL with the .torrent URL and
405
+ // the magnet in its fragment, which a plain client fetches over HTTP
406
+ // and a swarm-aware one joins directly.
407
407
  if (ends.styleUrl) {
408
408
  urls.append(
409
409
  urlRow('style URL', new URL(ends.styleUrl, location.href).href),