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 +62 -0
- package/README.md +6 -4
- package/docs/architecture-diagram.md +10 -7
- package/docs/configuration.md +46 -2
- package/docs/engines.md +32 -1
- package/docs/publishing.md +6 -1
- package/docs/running-as-a-service.md +9 -0
- package/docs/serving-tiles.md +58 -20
- package/package.json +1 -1
- package/src/api.js +62 -7
- package/src/engines/composite.js +23 -0
- package/src/engines/libtorrent.js +19 -0
- package/src/engines/types.js +1 -0
- package/src/engines/webtorrent.js +38 -0
- package/src/web/index.html +104 -26
- package/src/web/public.html +3 -3
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
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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_
|
|
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
|
|
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
|
|
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
|
|
269
|
-
|
|
270
|
-
|
|
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
|
---
|
package/docs/configuration.md
CHANGED
|
@@ -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` |
|
|
590
|
-
| `fetchRetrySeconds` | `
|
|
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.
|
package/docs/publishing.md
CHANGED
|
@@ -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":
|
|
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
|
package/docs/serving-tiles.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 |
|
|
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 +
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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 `
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
3. **The
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
1872
|
-
// style should carry. A fragment is never sent in
|
|
1873
|
-
// an ordinary client fetches the TileJSON and
|
|
1874
|
-
// a swarm-aware one has
|
|
1875
|
-
// and can therefore still start when this
|
|
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
|
package/src/engines/composite.js
CHANGED
|
@@ -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
|
package/src/engines/types.js
CHANGED
|
@@ -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));
|
package/src/web/index.html
CHANGED
|
@@ -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
|
|
752
|
+
* When an archive was added: the same date and time on every row.
|
|
739
753
|
*
|
|
740
|
-
*
|
|
741
|
-
*
|
|
742
|
-
* a
|
|
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
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
:
|
|
755
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
1786
|
-
//
|
|
1787
|
-
//
|
|
1788
|
-
|
|
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
|
-
|
|
1791
|
-
entry.magnet
|
|
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
|
|
2517
|
-
never sent to the server โ an ordinary
|
|
2518
|
-
TileJSON and ignores
|
|
2519
|
-
making any call${
|
|
2520
|
-
|
|
2521
|
-
|
|
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
|
: ''
|
package/src/web/public.html
CHANGED
|
@@ -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
|
|
405
|
-
// fragment, which a plain client fetches over HTTP
|
|
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),
|