pmtiles-swarm 0.59.0 → 0.61.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 +93 -0
- package/docs/configuration.md +1 -1
- package/docs/haproxy.md +74 -3
- package/docs/internals.md +1 -1
- package/docs/serving-tiles.md +2 -2
- package/docs/tilejson.md +2 -2
- package/package.json +1 -1
- package/src/api.js +212 -105
- package/src/incomplete.js +9 -0
- package/src/library.js +54 -0
- package/src/tilejson.js +27 -3
- package/src/web/index.html +82 -17
- package/src/web/public.html +53 -4
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,99 @@
|
|
|
7
7
|
### 🐞 Bug fixes
|
|
8
8
|
- _...Add new stuff here..._
|
|
9
9
|
|
|
10
|
+
## 0.61.0
|
|
11
|
+
### ✨ Features and improvements
|
|
12
|
+
- **"Style URL" is now "source URL", because that is what it is.** It goes in a style's
|
|
13
|
+
`sources` block and is not itself a style, so the old name told a reader to put it in the
|
|
14
|
+
wrong place. The field on `/api/categories` and `/latest/` is `sourceUrl`; `styleUrl` is
|
|
15
|
+
still sent alongside it and is deprecated, so nothing reading the old name breaks on the
|
|
16
|
+
correction.
|
|
17
|
+
|
|
18
|
+
- **A copy button copies, rather than opening a box to copy from.** `navigator.clipboard`
|
|
19
|
+
needs a secure context and a console reached by IP over plain HTTP on a LAN is not one —
|
|
20
|
+
which is how most of them are reached — so the console fell back to `window.prompt` every
|
|
21
|
+
time. It now falls back to the selection API, which predates the clipboard API and carries
|
|
22
|
+
no such requirement, and confirms on the button itself the way the public page does. The
|
|
23
|
+
public page gained the same fallback: it was failing outright wherever the console was
|
|
24
|
+
prompting, which is the same nodes.
|
|
25
|
+
|
|
26
|
+
- **The XYZ template is offered on the public catalogue page too**, beside the TileJSON it
|
|
27
|
+
already had. 0.60.0 added it to the console only, which is the wrong way round: the console
|
|
28
|
+
is for the operator, and the person who needs a tile URL to paste into a Leaflet layer or a
|
|
29
|
+
GIS client is usually looking at the public page. Both draw from the same builder, so the
|
|
30
|
+
field was already in the public payload and only the button was missing.
|
|
31
|
+
|
|
32
|
+
### 🐞 Bug fixes
|
|
33
|
+
- _...Add new stuff here..._
|
|
34
|
+
|
|
35
|
+
## 0.60.0
|
|
36
|
+
### ✨ Features and improvements
|
|
37
|
+
- **A tile URL that survives a rebuild.** Every archive is addressed by infohash, which is
|
|
38
|
+
what makes a tile cacheable for a year and what makes it useless in an application: the URL
|
|
39
|
+
changes with every build, so anything that wrote one down is pinned to a build that
|
|
40
|
+
eventually stops existing. A category is the only stable handle this system has, and now it
|
|
41
|
+
has a tile endpoint of its own — `/latest/<category>/{z}/{x}/{y}.<ext>` — which resolves to
|
|
42
|
+
whichever build is current on every request.
|
|
43
|
+
|
|
44
|
+
The category TileJSON advertises it as `tiles`, so a style written once keeps working across
|
|
45
|
+
a rebuild without being re-fetched for the URLs alone. The immutable template is still
|
|
46
|
+
published beside it as `latest.tiles`, because it is still the better URL for anything that
|
|
47
|
+
can re-read the document: it is content-addressed, so it caches for a year and never
|
|
48
|
+
revalidates. Offering only one of the two would be choosing for the consumer, and the right
|
|
49
|
+
answer differs by consumer.
|
|
50
|
+
|
|
51
|
+
The console offers the template as a copyable **XYZ** button next to the TileJSON one, which
|
|
52
|
+
is the form a Leaflet layer, an OpenLayers source or a GIS client actually wants.
|
|
53
|
+
|
|
54
|
+
Deliberately not a redirect to the immutable URL, though every other `/latest/` route is one.
|
|
55
|
+
A redirect costs a round trip and a map asks for hundreds of tiles: what is a negligible
|
|
56
|
+
indirection for a `.torrent` is the difference between a map that feels immediate and one
|
|
57
|
+
that does not. It is cached as the moving target it is — `max-age=300, must-revalidate`,
|
|
58
|
+
tagged with the build it resolved to, so a revalidation is a 304 while that build stands and
|
|
59
|
+
a miss the moment it moves.
|
|
60
|
+
|
|
61
|
+
- **A benchmark for "why does this feel slower than the other one", in `tools/tile-bench.mjs`.**
|
|
62
|
+
It reads two TileJSON documents, picks tiles inside the zoom range and bounds both can serve,
|
|
63
|
+
and requests the same set from each — one server at a time, because run together they compete
|
|
64
|
+
for the same link and each measures the other's load as its own latency.
|
|
65
|
+
|
|
66
|
+
It reports percentiles rather than an average, since what makes a map feel slow is the tail
|
|
67
|
+
and a mean built from nineteen fast requests does not move for the twentieth. Time to first
|
|
68
|
+
byte is separated from the total, which is the difference between a slow lookup and a slow
|
|
69
|
+
link — they want opposite fixes.
|
|
70
|
+
|
|
71
|
+
It also detects a pool of unequal nodes, which is a common cause and an invisible one: half
|
|
72
|
+
the tiles arrive quickly and half do not, and balanced evenly the mean looks tolerable
|
|
73
|
+
throughout. Two distinct groups are reported as two, with a histogram, and `--header` tallies
|
|
74
|
+
a response header naming which backend answered. `--a-origin`/`--b-origin` send the tile
|
|
75
|
+
requests somewhere other than the document was read from, which is the only way to measure
|
|
76
|
+
one node directly: a node with `publicUrl` set answers with that name however it was asked.
|
|
77
|
+
|
|
78
|
+
- **`docs/haproxy.md` covers a pool whose nodes are not the same speed.** Round robin assumes
|
|
79
|
+
the pool is interchangeable, and a tile server on an NVMe disk and one on a spinning disk are
|
|
80
|
+
not — an archive read is a seek into a large file, which is what a spinning disk is worst at.
|
|
81
|
+
`backup`, `weight` and least-connections are compared, along with what each does and does not
|
|
82
|
+
fix.
|
|
83
|
+
|
|
84
|
+
### 🐞 Bug fixes
|
|
85
|
+
- **An archive at 100% and seeding could serve no tiles until the node was restarted.** Which
|
|
86
|
+
source an archive is read through is decided once, when a reader opens it, and every other
|
|
87
|
+
thing that can change that answer already invalidates the reader: a pause, a resume, a mode
|
|
88
|
+
change, a move, a finished download. A finished *check* did not — and it is the easiest of
|
|
89
|
+
them to reach, because during `checking_files` libtorrent reports `progress` as the fraction
|
|
90
|
+
hashed so far, which is indistinguishable from a download sitting at the same figure.
|
|
91
|
+
|
|
92
|
+
So a tile read arriving mid-check opened against the swarm, correctly, and kept that handle
|
|
93
|
+
afterwards. The archive then read from a swarm whose only member is this node, while the
|
|
94
|
+
whole file sat on the disk beside it, and nothing evicted the handle short of a restart. The
|
|
95
|
+
completion sweep looked straight past it: an entry already recorded complete never reaches
|
|
96
|
+
the code that would have noticed.
|
|
97
|
+
|
|
98
|
+
The sweep now drops a reader that is going to the swarm for an archive the disk says is
|
|
99
|
+
whole. The disk is checked before the handle is dropped rather than after, or a reader that
|
|
100
|
+
would only re-open against the swarm anyway would be invalidated on every sweep, for ever.
|
|
101
|
+
Cache mode is left alone: it reads from the swarm because that is what it is for.
|
|
102
|
+
|
|
10
103
|
## 0.59.0
|
|
11
104
|
### ✨ Features and improvements
|
|
12
105
|
- **Requires pmtiles-torrent 0.10.2**, which is what actually ends the re-checking: a
|
package/docs/configuration.md
CHANGED
|
@@ -407,7 +407,7 @@ on. This is that address.
|
|
|
407
407
|
Narrower than `publicUrl` on purpose: `publicUrl` overrides every URL the node
|
|
408
408
|
emits and so gives up the multi-domain behaviour, while this overrides only the
|
|
409
409
|
ones that have to be permanent. Everything else — TileJSON, tile templates,
|
|
410
|
-
`.torrent` links,
|
|
410
|
+
`.torrent` links, source URLs, the feeds — goes on naming whichever host the
|
|
411
411
|
request arrived as.
|
|
412
412
|
|
|
413
413
|
```json
|
package/docs/haproxy.md
CHANGED
|
@@ -114,6 +114,70 @@ gives archive affinity, at the price of concentrating one archive on one node.
|
|
|
114
114
|
For nodes holding complete copies there is no cold read to avoid, so this is a
|
|
115
115
|
cost with no benefit.
|
|
116
116
|
|
|
117
|
+
### When the nodes are not the same speed
|
|
118
|
+
|
|
119
|
+
Round robin assumes the pool is interchangeable, and a tile server on an NVMe
|
|
120
|
+
disk and one on a spinning disk are not. Every archive read is a seek into a
|
|
121
|
+
large file — the PMTiles directory, then the leaf, then the tile — and that is
|
|
122
|
+
precisely the access pattern a spinning disk is worst at. The two can easily
|
|
123
|
+
differ by an order of magnitude.
|
|
124
|
+
|
|
125
|
+
What that does to a pool is worse than the average suggests. Balanced evenly,
|
|
126
|
+
half the tiles arrive quickly and half do not, and a map is judged by the slow
|
|
127
|
+
half: panning stutters wherever the slow node answered. The mean looks
|
|
128
|
+
tolerable throughout, which is why this is easy to miss and easy to blame on
|
|
129
|
+
the software.
|
|
130
|
+
|
|
131
|
+
Three ways out, and they answer different questions.
|
|
132
|
+
|
|
133
|
+
**`backup`** keeps the slow node out of service entirely until the fast one
|
|
134
|
+
fails:
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
backend pmtiles-swarm
|
|
138
|
+
option httpchk GET /health HTTP/1.1
|
|
139
|
+
http-check expect status 200
|
|
140
|
+
server node1 172.16.1.49:8090 check inter 5s
|
|
141
|
+
server node2 172.16.1.41:8090 check inter 5s backup
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Every request goes to `node1` while it is healthy, and the pool falls to
|
|
145
|
+
`node2` when it is not. Latency is then the fast node's latency, and the slow
|
|
146
|
+
one is redundancy rather than capacity. This is the right answer when the fast
|
|
147
|
+
node can carry the load alone, which for tile serving it usually can.
|
|
148
|
+
|
|
149
|
+
**`weight`** keeps both in service and sends proportionally less to the slow
|
|
150
|
+
one:
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
server node1 172.16.1.49:8090 check inter 5s weight 200
|
|
154
|
+
server node2 172.16.1.41:8090 check inter 5s weight 20
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Worth it only when you actually need the second node's throughput. It does not
|
|
158
|
+
remove the slow answers, it makes them rarer — a tenth of requests at ten times
|
|
159
|
+
the latency is still a tenth of requests a person notices.
|
|
160
|
+
|
|
161
|
+
**Least Connections** is the better of the two ways to keep both in service,
|
|
162
|
+
and it needs no numbers chosen by hand. A slow node holds each connection
|
|
163
|
+
longer, so it accumulates open ones and stops being picked until it catches up:
|
|
164
|
+
the pool tunes itself to what the disks are doing today rather than to a weight
|
|
165
|
+
somebody guessed last year. It shares `weight`'s limitation — the slow answers
|
|
166
|
+
become proportionally rarer, not absent — and it pairs with `backup` without
|
|
167
|
+
conflict, since a backup server is held out of the pool whatever the algorithm.
|
|
168
|
+
|
|
169
|
+
`tools/tile-bench.mjs` shows which situation you are in. A pool of unequal
|
|
170
|
+
nodes answers in two distinct groups rather than one spread, and the tool says
|
|
171
|
+
so explicitly; `--header` tallies a response header naming the backend, if
|
|
172
|
+
HAProxy is set to add one:
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
http-response set-header X-Served-By %s
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Run it before and after the change. What should move is the tail: `total p90`
|
|
179
|
+
and `total p99` collapse towards `p50` once one node is answering everything.
|
|
180
|
+
|
|
117
181
|
### HTTP/2
|
|
118
182
|
|
|
119
183
|
Enable it on the frontend and leave _HTTP/2 without TLS_ unchecked: the client
|
|
@@ -219,9 +283,16 @@ says so, which is worse than having no check at all.
|
|
|
219
283
|
|
|
220
284
|
**Cache tiles by infohash aggressively.** `/archives/<infohash>/…` is immutable
|
|
221
285
|
by construction — an infohash names those bytes and no others — and is served
|
|
222
|
-
with `max-age=31536000, immutable`.
|
|
223
|
-
|
|
224
|
-
|
|
286
|
+
with `max-age=31536000, immutable`.
|
|
287
|
+
|
|
288
|
+
Everything under `/latest/<category>/` is the opposite, because it moves on
|
|
289
|
+
every build. The documents — `tiles.json`, the feeds — are served with
|
|
290
|
+
`max-age=60, must-revalidate`; the tiles with `max-age=300`. Both carry an ETag
|
|
291
|
+
naming the build they resolved to, so a revalidation is a 304 while the build
|
|
292
|
+
stands and a miss the moment it moves. Let Cloudflare revalidate rather than
|
|
293
|
+
overriding either with a page rule: the whole point of the category tile URL is
|
|
294
|
+
that it is safe to write into an application, and it is only safe while the
|
|
295
|
+
edge notices a rebuild.
|
|
225
296
|
|
|
226
297
|
**Do not strip or rewrite the ETag on either.** It is the infohash, which is
|
|
227
298
|
how a PMTiles reader notices that the archive moved underneath a read already
|
package/docs/internals.md
CHANGED
|
@@ -725,7 +725,7 @@ own and asks for nothing guarded.
|
|
|
725
725
|
|
|
726
726
|
Three things joined the public list to make it work, and each is a read of
|
|
727
727
|
something already published: `/api/categories`, which groups the same archives
|
|
728
|
-
and carries the
|
|
728
|
+
and carries the source URL for each; the per-archive `/preview`; and `/vendor/`,
|
|
729
729
|
which is the MapLibre bundle the preview renders with. The preview used to be
|
|
730
730
|
excluded on the grounds that it is console furniture and would not render
|
|
731
731
|
without `/vendor` anyway — both true, and both answered by publishing the pair
|
package/docs/serving-tiles.md
CHANGED
|
@@ -45,7 +45,7 @@ See [internals.md](internals.md#serving-an-mbtiles-archive).
|
|
|
45
45
|
|
|
46
46
|
`GET /latest/` lists every category this node publishes, with the endpoints
|
|
47
47
|
that resolve to each one's newest build — the TileJSON, the `.torrent`, the
|
|
48
|
-
magnet, the per-category feed, and the
|
|
48
|
+
magnet, the per-category feed, and the source URL with the magnet in its
|
|
49
49
|
fragment.
|
|
50
50
|
|
|
51
51
|
Public, and deliberately so. Everything else under `/latest/` is — the
|
|
@@ -274,7 +274,7 @@ everywhere:
|
|
|
274
274
|
| torrent-aware | joins **before any network call**, and still can if the fetch fails |
|
|
275
275
|
|
|
276
276
|
The console's **Copy TileJSON URL + swarm** button produces exactly this, and so
|
|
277
|
-
does the `
|
|
277
|
+
does the `sourceUrl` on every row of `/api/categories` and `/latest/`.
|
|
278
278
|
|
|
279
279
|
### Two handles, and why both
|
|
280
280
|
|
package/docs/tilejson.md
CHANGED
|
@@ -110,7 +110,7 @@ minor one. **A browser has no DHT** — WebTorrent's `browser` field maps
|
|
|
110
110
|
UDP or TCP sockets at all. Given only a public key, a browser would have to fetch
|
|
111
111
|
this TileJSON before it could join anything. Since this magnet is routinely
|
|
112
112
|
carried in the _fragment of the TileJSON URL itself_ — see
|
|
113
|
-
[`
|
|
113
|
+
[`sourceUrl`](#where-this-magnet-shows-up) — that would make the fragment useless
|
|
114
114
|
to the one client most likely to be reading it.
|
|
115
115
|
|
|
116
116
|
The infohash does go stale on the next rebuild. That is acceptable and expected:
|
|
@@ -122,7 +122,7 @@ going to follow the series anyway. It is a starting point, not a subscription.
|
|
|
122
122
|
Three places, all built from the same function, so they agree:
|
|
123
123
|
|
|
124
124
|
- `torrent.mutable.magnet` in this document.
|
|
125
|
-
- The fragment on `
|
|
125
|
+
- The fragment on `sourceUrl`, from `GET /api/categories` — a
|
|
126
126
|
`…/latest/<category>/tiles.json#magnet:?…`. One string that a plain client
|
|
127
127
|
fetches over HTTP and a swarm client joins directly, with no extra round trip
|
|
128
128
|
for either.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmtiles-swarm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.61.0",
|
|
4
4
|
"description": "BitTorrent distribution for PMTiles map archives: create torrents, watch folders, publish and subscribe to RSS feeds, and seed through qBittorrent or an embedded client",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/api.js
CHANGED
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
expandTemplate,
|
|
33
33
|
} from './sources.js';
|
|
34
34
|
import { limitFor, remaining } from './seeding.js';
|
|
35
|
-
import { buildTileJson, extensionMatches } from './tilejson.js';
|
|
35
|
+
import { buildTileJson, extensionMatches, tileExtension } from './tilejson.js';
|
|
36
36
|
import { SUMMARY_VERSION } from './pmtiles-probe.js';
|
|
37
37
|
import { TileReadError } from './tiles.js';
|
|
38
38
|
|
|
@@ -158,12 +158,17 @@ function withSwarmHandles(url, { torrent, magnet }) {
|
|
|
158
158
|
|
|
159
159
|
/**
|
|
160
160
|
* A TileJSON URL carrying the ways into the swarm in its fragment.
|
|
161
|
+
*
|
|
162
|
+
* A *source* URL, not a style one — it goes in a style's `sources` block, and
|
|
163
|
+
* a style is the document that would contain it. It was called the other thing
|
|
164
|
+
* for a while, which is a name that tells a reader to put it in the wrong
|
|
165
|
+
* place.
|
|
161
166
|
* @param {string} category - Which category.
|
|
162
167
|
* @param {object} newest - Its newest entry.
|
|
163
168
|
* @param {string} base - Public base URL.
|
|
164
|
-
* @returns {string} - The URL a
|
|
169
|
+
* @returns {string} - The URL a source should point at.
|
|
165
170
|
*/
|
|
166
|
-
function
|
|
171
|
+
function sourceUrlFor(category, newest, base) {
|
|
167
172
|
const url = `${base}/latest/${category}/tiles.json`;
|
|
168
173
|
const magnet = newest?.mutable?.publicKey
|
|
169
174
|
? mutableMagnet(newest.mutable.publicKey, {
|
|
@@ -2158,6 +2163,14 @@ export function createApp({
|
|
|
2158
2163
|
servable,
|
|
2159
2164
|
endpoints: {
|
|
2160
2165
|
tileJson: servable ? `${base}/latest/${category}/tiles.json` : null,
|
|
2166
|
+
// The XYZ template itself, which is what most things outside this
|
|
2167
|
+
// system actually want: a leaflet layer, an OpenLayers source, a
|
|
2168
|
+
// GIS client, anything that takes a URL with braces in it rather
|
|
2169
|
+
// than a TileJSON document. It follows the category, so it can be
|
|
2170
|
+
// written into an application and survive a rebuild.
|
|
2171
|
+
xyz: servable
|
|
2172
|
+
? `${base}/latest/${category}/{z}/{x}/{y}.${tileExtension(newest.pmtiles?.format)}`
|
|
2173
|
+
: null,
|
|
2161
2174
|
// The same URL with the ways into the swarm in the fragment,
|
|
2162
2175
|
// which is what a style should carry. A fragment is never sent in
|
|
2163
2176
|
// a request, so an ordinary client fetches the TileJSON and
|
|
@@ -2170,7 +2183,13 @@ export function createApp({
|
|
|
2170
2183
|
// and resolves the current build over the DHT. Otherwise the
|
|
2171
2184
|
// newest build's own magnet, which pins that build but still
|
|
2172
2185
|
// beats a blank map when the fallback is needed at all.
|
|
2173
|
-
|
|
2186
|
+
// What goes in a style's `sources` block. It is a TileJSON URL
|
|
2187
|
+
// with the ways into the swarm in its fragment, so it is a source
|
|
2188
|
+
// by every reading: what it addresses, and where it is written.
|
|
2189
|
+
sourceUrl: servable ? sourceUrlFor(category, newest, base) : null,
|
|
2190
|
+
// The name this had until 0.61.0, kept so a consumer that reads it
|
|
2191
|
+
// is not broken by the correction. Deprecated: read `sourceUrl`.
|
|
2192
|
+
styleUrl: servable ? sourceUrlFor(category, newest, base) : null,
|
|
2174
2193
|
// Points at the category, not at a build. The page reads the
|
|
2175
2194
|
// TileJSON beside it, so it renders whatever is current — which
|
|
2176
2195
|
// makes it the same URL a style holds, demonstrating itself rather
|
|
@@ -2253,8 +2272,23 @@ export function createApp({
|
|
|
2253
2272
|
// makes that re-read cheap — a client already holding the current build
|
|
2254
2273
|
// gets a 304 and no body, which Express does on its own once an ETag is
|
|
2255
2274
|
// set before the response goes out.
|
|
2275
|
+
// Both templates are built, because both are wanted and they are wanted
|
|
2276
|
+
// for opposite reasons.
|
|
2277
|
+
//
|
|
2278
|
+
// `tiles` points at the category, so a style written once keeps working
|
|
2279
|
+
// across a rebuild. That is what the endpoint is for, and an infohash
|
|
2280
|
+
// template cannot do it: it changes every build, so anything holding it
|
|
2281
|
+
// is pinned to a build that eventually stops existing.
|
|
2282
|
+
//
|
|
2283
|
+
// The immutable template is still published, under `latest.tiles`, and
|
|
2284
|
+
// is still the better URL for anything that can re-read this document —
|
|
2285
|
+
// it is content-addressed, so it caches for a year and never
|
|
2286
|
+
// revalidates. Offering only one of the two would be choosing for the
|
|
2287
|
+
// consumer, and the right answer differs by consumer.
|
|
2288
|
+
const pinned = buildTileJson(entry, baseUrl(req));
|
|
2289
|
+
const stableRoot = `${baseUrl(req)}/latest/${encodeURIComponent(req.params.category)}`;
|
|
2256
2290
|
const doc = {
|
|
2257
|
-
...buildTileJson(entry, baseUrl(req)),
|
|
2291
|
+
...buildTileJson(entry, baseUrl(req), { tilesRoot: stableRoot }),
|
|
2258
2292
|
// Names what it resolved to, so a consumer can tell one build from the
|
|
2259
2293
|
// next without diffing the tile URLs.
|
|
2260
2294
|
latest: {
|
|
@@ -2262,6 +2296,7 @@ export function createApp({
|
|
|
2262
2296
|
infohash: entry.infoHash,
|
|
2263
2297
|
name: entry.name,
|
|
2264
2298
|
createdAt: entry.createdAt,
|
|
2299
|
+
tiles: pinned.tiles,
|
|
2265
2300
|
},
|
|
2266
2301
|
};
|
|
2267
2302
|
tagAsLatest(res, doc);
|
|
@@ -2833,119 +2868,191 @@ export function createApp({
|
|
|
2833
2868
|
}),
|
|
2834
2869
|
);
|
|
2835
2870
|
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2871
|
+
/**
|
|
2872
|
+
* Serves one tile, from an archive the caller has already resolved.
|
|
2873
|
+
*
|
|
2874
|
+
* Shared by the two ways of naming an archive, which differ in one respect
|
|
2875
|
+
* and want the same behaviour in every other. `/archives/<infohash>/…` pins
|
|
2876
|
+
* content, so the answer can be cached for a year and never revalidated.
|
|
2877
|
+
* `/latest/<category>/…` resolves to whichever build is current, so it
|
|
2878
|
+
* cannot: the same URL returns different bytes after a rebuild, and a client
|
|
2879
|
+
* holding it for a year would hold a build that no longer exists.
|
|
2880
|
+
*
|
|
2881
|
+
* The difference is expressed entirely in the caching headers rather than in
|
|
2882
|
+
* two implementations, because everything else — the coordinate checks, the
|
|
2883
|
+
* summary backfill, the abort on a cancelled request, the 204-against-404
|
|
2884
|
+
* decision for a sparse archive — has to be identical or one of the two is
|
|
2885
|
+
* quietly a different endpoint.
|
|
2886
|
+
* @param {object} found - The catalog entry to read from.
|
|
2887
|
+
* @param {import('express').Request} req - The request.
|
|
2888
|
+
* @param {import('express').Response} res - The response.
|
|
2889
|
+
* @param {object} [options] - `immutable` false for a resolved category.
|
|
2890
|
+
* @returns {Promise<void>} - Resolves once answered.
|
|
2891
|
+
*/
|
|
2892
|
+
const serveTile = async (found, req, res, options = {}) => {
|
|
2893
|
+
const { ext } = req.params;
|
|
2894
|
+
let entry = found;
|
|
2895
|
+
const infoHash = entry.infoHash;
|
|
2896
|
+
// MBTiles passes through here and is turned away by the store instead,
|
|
2897
|
+
// which is the only layer that knows whether the download has finished.
|
|
2898
|
+
if (entry.kind && entry.kind !== 'pmtiles' && entry.kind !== 'mbtiles') {
|
|
2899
|
+
return res.status(415).json({
|
|
2900
|
+
error: `this is a ${entry.kind} archive, and only PMTiles and MBTiles can be served as tiles`,
|
|
2901
|
+
});
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
const z = Number(req.params.z);
|
|
2905
|
+
const x = Number(req.params.x);
|
|
2906
|
+
const y = Number(req.params.y);
|
|
2907
|
+
if (![z, x, y].every(Number.isInteger)) {
|
|
2908
|
+
return res.status(400).json({ error: 'z, x and y must be integers' });
|
|
2909
|
+
}
|
|
2910
|
+
const limit = 2 ** z;
|
|
2911
|
+
if (z < 0 || z > 26 || x < 0 || y < 0 || x >= limit || y >= limit) {
|
|
2912
|
+
return res.status(400).json({ error: 'tile coordinates out of range' });
|
|
2913
|
+
}
|
|
2914
|
+
// An MBTiles archive never went through the PMTiles prober, so it
|
|
2915
|
+
// reaches here with no summary and nothing to check the extension
|
|
2916
|
+
// against. Read one now: it is a handful of rows out of a local SQLite
|
|
2917
|
+
// file, kept in the catalog, so this is paid once per archive rather
|
|
2918
|
+
// than per tile. A failure is left to the read below to report, which
|
|
2919
|
+
// already knows how to say "not complete yet".
|
|
2920
|
+
if (!entry.pmtiles?.format) {
|
|
2921
|
+
const summary = await tiles.summarize(entry.infoHash).catch(() => null);
|
|
2922
|
+
if (summary) {
|
|
2923
|
+
entry = await catalog.put({
|
|
2924
|
+
infoHash: entry.infoHash,
|
|
2925
|
+
pmtiles: summary,
|
|
2926
|
+
summarySource: 'header',
|
|
2847
2927
|
});
|
|
2848
2928
|
}
|
|
2929
|
+
}
|
|
2849
2930
|
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
}
|
|
2856
|
-
const limit = 2 ** z;
|
|
2857
|
-
if (z < 0 || z > 26 || x < 0 || y < 0 || x >= limit || y >= limit) {
|
|
2858
|
-
return res.status(400).json({ error: 'tile coordinates out of range' });
|
|
2859
|
-
}
|
|
2860
|
-
// An MBTiles archive never went through the PMTiles prober, so it
|
|
2861
|
-
// reaches here with no summary and nothing to check the extension
|
|
2862
|
-
// against. Read one now: it is a handful of rows out of a local SQLite
|
|
2863
|
-
// file, kept in the catalog, so this is paid once per archive rather
|
|
2864
|
-
// than per tile. A failure is left to the read below to report, which
|
|
2865
|
-
// already knows how to say "not complete yet".
|
|
2866
|
-
if (!entry.pmtiles?.format) {
|
|
2867
|
-
const summary = await tiles.summarize(entry.infoHash).catch(() => null);
|
|
2868
|
-
if (summary) {
|
|
2869
|
-
entry = await catalog.put({
|
|
2870
|
-
infoHash: entry.infoHash,
|
|
2871
|
-
pmtiles: summary,
|
|
2872
|
-
summarySource: 'header',
|
|
2873
|
-
});
|
|
2874
|
-
}
|
|
2875
|
-
}
|
|
2931
|
+
if (!extensionMatches(entry, ext)) {
|
|
2932
|
+
return res.status(400).json({
|
|
2933
|
+
error: `this archive holds ${entry.pmtiles?.format ?? 'unknown'} tiles`,
|
|
2934
|
+
});
|
|
2935
|
+
}
|
|
2876
2936
|
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2937
|
+
const controller = new AbortController();
|
|
2938
|
+
// A panning map abandons requests constantly. Without this the swarm
|
|
2939
|
+
// keeps fetching pieces for tiles nobody is waiting for any more.
|
|
2940
|
+
res.on('close', () => {
|
|
2941
|
+
if (!res.writableEnded) controller.abort();
|
|
2942
|
+
});
|
|
2943
|
+
|
|
2944
|
+
// Counted on the way out rather than at each return: this handler ends
|
|
2945
|
+
// in six different places (200, 204, 404, 415, 400, a read error), and
|
|
2946
|
+
// one hook catches all of them without any of them having to remember.
|
|
2947
|
+
// Abandoned requests are not counted -- a panning map cancels constantly
|
|
2948
|
+
// and those were never served.
|
|
2949
|
+
if (stats) {
|
|
2950
|
+
const startedAt = process.hrtime.bigint();
|
|
2951
|
+
res.on('finish', () => {
|
|
2952
|
+
stats.record({
|
|
2953
|
+
infoHash,
|
|
2954
|
+
name: entry.name,
|
|
2955
|
+
z,
|
|
2956
|
+
x,
|
|
2957
|
+
y,
|
|
2958
|
+
status: res.statusCode,
|
|
2959
|
+
bytes: Number(res.getHeader('content-length')) || 0,
|
|
2960
|
+
ms: Number(process.hrtime.bigint() - startedAt) / 1e6,
|
|
2961
|
+
// Whatever this process can see. Behind a proxy that sends no
|
|
2962
|
+
// X-Forwarded-For this is the proxy's address, which is itself
|
|
2963
|
+
// the answer to "did this arrive directly or through HAProxy".
|
|
2964
|
+
ip: req.ip,
|
|
2880
2965
|
});
|
|
2881
|
-
}
|
|
2966
|
+
});
|
|
2967
|
+
}
|
|
2882
2968
|
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
if (!res.writableEnded) controller.abort();
|
|
2969
|
+
let tile;
|
|
2970
|
+
try {
|
|
2971
|
+
tile = await tiles.getTile(infoHash, z, x, y, {
|
|
2972
|
+
signal: controller.signal,
|
|
2888
2973
|
});
|
|
2974
|
+
} catch (error) {
|
|
2975
|
+
if (error.name === 'AbortError') return;
|
|
2976
|
+
if (error instanceof TileReadError) {
|
|
2977
|
+
return res.status(error.status).json({ error: error.message });
|
|
2978
|
+
}
|
|
2979
|
+
throw error;
|
|
2980
|
+
}
|
|
2889
2981
|
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2982
|
+
res.setHeader('access-control-allow-origin', '*');
|
|
2983
|
+
// The tag is the resolved infohash either way, which is what makes the
|
|
2984
|
+
// category URL cheap to hold: when the build has not moved a
|
|
2985
|
+
// revalidation is a 304 with no body, and when it has, the tag changes
|
|
2986
|
+
// on its own without anything having to remember to invalidate.
|
|
2987
|
+
res.setHeader('etag', `"${infoHash}-${z}-${x}-${y}"`);
|
|
2988
|
+
res.setHeader(
|
|
2989
|
+
'cache-control',
|
|
2990
|
+
options.immutable === false
|
|
2991
|
+
? // A category resolves to whatever build is current, so the same
|
|
2992
|
+
// URL returns different bytes after a rebuild. Five minutes bounds
|
|
2993
|
+
// how long a client can be looking at the previous build, and
|
|
2994
|
+
// must-revalidate is what stops a cache serving it beyond that.
|
|
2995
|
+
'public, max-age=300, must-revalidate'
|
|
2996
|
+
: // An infohash pins content, so a tile under one can never change.
|
|
2997
|
+
// When a mutable archive is updated the infohash changes and so
|
|
2998
|
+
// does this URL, which makes cache invalidation automatic.
|
|
2999
|
+
'public, max-age=31536000, immutable',
|
|
3000
|
+
);
|
|
3001
|
+
|
|
3002
|
+
// A missing tile is normal, and which status says so matters.
|
|
3003
|
+
//
|
|
3004
|
+
// 404 tells MapLibre the tile is absent, so it overzooms the parent —
|
|
3005
|
+
// which is the only way a sparse raster-dem renders terrain at all.
|
|
3006
|
+
// 204 tells it the tile is empty but present, so it draws nothing and
|
|
3007
|
+
// does not fall back.
|
|
3008
|
+
//
|
|
3009
|
+
// Vector wants 204 (an empty tile means no features here); raster wants
|
|
3010
|
+
// 404. Same rule and same name as tileserver-gl's `sparse`.
|
|
3011
|
+
if (!tile) return res.status(isSparse(entry) ? 404 : 204).end();
|
|
3012
|
+
|
|
3013
|
+
res.type(entry.pmtiles?.contentType ?? 'application/octet-stream');
|
|
3014
|
+
if (tile.encoding) res.setHeader('content-encoding', tile.encoding);
|
|
3015
|
+
res.send(tile.data);
|
|
3016
|
+
};
|
|
3017
|
+
|
|
3018
|
+
app.get(
|
|
3019
|
+
'/archives/:infoHash/:z/:x/:y.:ext',
|
|
3020
|
+
route(async (req, res) => {
|
|
3021
|
+
const entry = catalog.get(req.params.infoHash);
|
|
3022
|
+
if (!entry) return res.status(404).json({ error: 'unknown archive' });
|
|
3023
|
+
if (entry.kind && entry.kind !== 'pmtiles' && entry.kind !== 'mbtiles') {
|
|
3024
|
+
return res.status(415).json({
|
|
3025
|
+
error: `this is a ${entry.kind} archive, and only PMTiles and MBTiles can be served as tiles`,
|
|
2912
3026
|
});
|
|
2913
3027
|
}
|
|
3028
|
+
return serveTile(entry, req, res);
|
|
3029
|
+
}),
|
|
3030
|
+
);
|
|
2914
3031
|
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
3032
|
+
// The one tile URL that survives a rebuild.
|
|
3033
|
+
//
|
|
3034
|
+
// Every archive is addressed by infohash, which is what makes a tile
|
|
3035
|
+
// cacheable for a year and what makes it useless in an application: the URL
|
|
3036
|
+
// changes with every build. A category is the stable handle, so it needs a
|
|
3037
|
+
// tile endpoint of its own — and this is the URL the category TileJSON
|
|
3038
|
+
// advertises, so a style pointed at `/latest/<category>/tiles.json` keeps
|
|
3039
|
+
// working across rebuilds without being re-fetched for the URLs alone.
|
|
3040
|
+
//
|
|
3041
|
+
// Not a redirect to the immutable URL, though every other `/latest/` route
|
|
3042
|
+
// is one. A redirect costs a round trip, and a map asks for hundreds of
|
|
3043
|
+
// tiles: what is a negligible indirection for a `.torrent` is the difference
|
|
3044
|
+
// between a map that feels immediate and one that does not.
|
|
3045
|
+
app.get(
|
|
3046
|
+
'/latest/:category/:z/:x/:y.:ext',
|
|
3047
|
+
route(async (req, res) => {
|
|
3048
|
+
const entry = newestIn(req.params.category, req);
|
|
3049
|
+
if (!entry) return res.status(404).json({ error: 'no such category' });
|
|
3050
|
+
if (entry.kind && entry.kind !== 'pmtiles' && entry.kind !== 'mbtiles') {
|
|
3051
|
+
return res.status(415).json({
|
|
3052
|
+
error: `the newest archive in this category is a ${entry.kind} archive, and only PMTiles and MBTiles can be served as tiles`,
|
|
2919
3053
|
});
|
|
2920
|
-
} catch (error) {
|
|
2921
|
-
if (error.name === 'AbortError') return;
|
|
2922
|
-
if (error instanceof TileReadError) {
|
|
2923
|
-
return res.status(error.status).json({ error: error.message });
|
|
2924
|
-
}
|
|
2925
|
-
throw error;
|
|
2926
3054
|
}
|
|
2927
|
-
|
|
2928
|
-
res.setHeader('access-control-allow-origin', '*');
|
|
2929
|
-
// An infohash pins content, so a tile under one can never change. When a
|
|
2930
|
-
// mutable archive is updated the infohash changes and so does this URL,
|
|
2931
|
-
// which makes cache invalidation automatic.
|
|
2932
|
-
res.setHeader('cache-control', 'public, max-age=31536000, immutable');
|
|
2933
|
-
res.setHeader('etag', `"${infoHash}-${z}-${x}-${y}"`);
|
|
2934
|
-
|
|
2935
|
-
// A missing tile is normal, and which status says so matters.
|
|
2936
|
-
//
|
|
2937
|
-
// 404 tells MapLibre the tile is absent, so it overzooms the parent —
|
|
2938
|
-
// which is the only way a sparse raster-dem renders terrain at all.
|
|
2939
|
-
// 204 tells it the tile is empty but present, so it draws nothing and
|
|
2940
|
-
// does not fall back.
|
|
2941
|
-
//
|
|
2942
|
-
// Vector wants 204 (an empty tile means no features here); raster wants
|
|
2943
|
-
// 404. Same rule and same name as tileserver-gl's `sparse`.
|
|
2944
|
-
if (!tile) return res.status(isSparse(entry) ? 404 : 204).end();
|
|
2945
|
-
|
|
2946
|
-
res.type(entry.pmtiles?.contentType ?? 'application/octet-stream');
|
|
2947
|
-
if (tile.encoding) res.setHeader('content-encoding', tile.encoding);
|
|
2948
|
-
res.send(tile.data);
|
|
3055
|
+
return serveTile(entry, req, res, { immutable: false });
|
|
2949
3056
|
}),
|
|
2950
3057
|
);
|
|
2951
3058
|
|
package/src/incomplete.js
CHANGED
|
@@ -191,6 +191,15 @@ export class CompletionWatcher {
|
|
|
191
191
|
await this.#library.captureMetadata?.(entry.infoHash).catch(() => null);
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
+
// Before the `complete` gate below, deliberately. An archive already
|
|
195
|
+
// recorded complete is the case this covers: it was re-checking when a
|
|
196
|
+
// tile reader opened it, so the reader went to the swarm — and when the
|
|
197
|
+
// check finished nothing noticed, because finalize only runs for a
|
|
198
|
+
// download. See Library.refreshReader.
|
|
199
|
+
if (entry.status?.progress >= 1) {
|
|
200
|
+
await this.#library.refreshReader?.(entry.infoHash).catch(() => null);
|
|
201
|
+
}
|
|
202
|
+
|
|
194
203
|
if (entry.complete) continue;
|
|
195
204
|
|
|
196
205
|
// The engine's account wins whenever it has one: a client allocates the
|
package/src/library.js
CHANGED
|
@@ -2778,6 +2778,60 @@ export class Library {
|
|
|
2778
2778
|
return { cleared: before };
|
|
2779
2779
|
}
|
|
2780
2780
|
|
|
2781
|
+
/**
|
|
2782
|
+
* Drops a reader still going to the swarm for an archive that is now whole.
|
|
2783
|
+
*
|
|
2784
|
+
* Which source an archive is read through is decided once, when it is
|
|
2785
|
+
* opened, and every other thing that can change the answer — a pause, a
|
|
2786
|
+
* resume, a mode change, a move, a finished download — invalidates the
|
|
2787
|
+
* reader itself. A finished *check* is the one that does not, and it is the
|
|
2788
|
+
* easiest of them to hit: during `checking_files` libtorrent reports
|
|
2789
|
+
* `progress` as the fraction hashed so far, which is indistinguishable from
|
|
2790
|
+
* a download at the same figure. So a tile read arriving mid-check opens
|
|
2791
|
+
* against the swarm, correctly, and then keeps that handle after the check
|
|
2792
|
+
* finishes and the whole file is sitting on disk.
|
|
2793
|
+
*
|
|
2794
|
+
* What that looks like is an archive at 100% and seeding whose tiles will
|
|
2795
|
+
* not load, because the swarm it is being read from has one member: this
|
|
2796
|
+
* node. It survives until something evicts the handle, which in practice
|
|
2797
|
+
* means a restart.
|
|
2798
|
+
*
|
|
2799
|
+
* The disk is checked before the handle is dropped, and that is not
|
|
2800
|
+
* belt-and-braces: without it an archive that re-opens against the swarm
|
|
2801
|
+
* anyway would be invalidated again on every sweep, for ever.
|
|
2802
|
+
* @param {string} infoHash - Which archive.
|
|
2803
|
+
* @returns {Promise<boolean>} - Whether a stale reader was dropped.
|
|
2804
|
+
*/
|
|
2805
|
+
async refreshReader(infoHash) {
|
|
2806
|
+
// Cheap and synchronous, and false for all but a handful of archives —
|
|
2807
|
+
// this runs for every entry on the completion timer.
|
|
2808
|
+
if (this.#tiles?.status(infoHash)?.mode !== 'swarm') return false;
|
|
2809
|
+
|
|
2810
|
+
const entry = this.#catalog.get(infoHash);
|
|
2811
|
+
if (!entry?.savePath || !entry?.name) return false;
|
|
2812
|
+
|
|
2813
|
+
// Cache mode reads from the swarm because that is what it is for, not
|
|
2814
|
+
// because anything went stale.
|
|
2815
|
+
if (entry.mode === 'cache') return false;
|
|
2816
|
+
|
|
2817
|
+
const whole = await alreadyComplete({
|
|
2818
|
+
savePath: entry.savePath,
|
|
2819
|
+
name: entry.name,
|
|
2820
|
+
size: entry.size,
|
|
2821
|
+
});
|
|
2822
|
+
if (!whole) return false;
|
|
2823
|
+
|
|
2824
|
+
const dropped = await this.#tiles.invalidate(infoHash).catch(() => false);
|
|
2825
|
+
if (dropped) {
|
|
2826
|
+
console.log(
|
|
2827
|
+
`[tiles] ${entry.name}: was being read from the swarm while a whole ` +
|
|
2828
|
+
'copy sat on disk, which is what a finished re-check leaves behind. ' +
|
|
2829
|
+
'Reopening it locally.',
|
|
2830
|
+
);
|
|
2831
|
+
}
|
|
2832
|
+
return dropped;
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2781
2835
|
/**
|
|
2782
2836
|
* Rewrites an archive's web seed list, in the .torrent and everywhere else.
|
|
2783
2837
|
*
|
package/src/tilejson.js
CHANGED
|
@@ -23,21 +23,45 @@ const URL_EXTENSION = {
|
|
|
23
23
|
mlt: 'mlt',
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* The extension a tile URL uses for one archive format.
|
|
28
|
+
*
|
|
29
|
+
* Exported because the console publishes the XYZ template beside the TileJSON,
|
|
30
|
+
* and a second copy of this map is a second thing to forget when a format is
|
|
31
|
+
* added — which would show up as a working document advertising a URL that
|
|
32
|
+
* answers 400.
|
|
33
|
+
* @param {string} [format] - The archive's tile format.
|
|
34
|
+
* @returns {string} - The extension, without a dot.
|
|
35
|
+
*/
|
|
36
|
+
export function tileExtension(format) {
|
|
37
|
+
return URL_EXTENSION[format] ?? 'bin';
|
|
38
|
+
}
|
|
39
|
+
|
|
26
40
|
/**
|
|
27
41
|
* Builds the TileJSON document for one catalog entry.
|
|
42
|
+
*
|
|
43
|
+
* `tilesRoot` is how a category document points its tiles at itself rather
|
|
44
|
+
* than at the build it happens to have resolved to. The infohash URL is the
|
|
45
|
+
* right default — it pins content, which is what lets a tile be cached for a
|
|
46
|
+
* year — but it is the wrong thing to write into an application, because it
|
|
47
|
+
* changes with every rebuild. A category has to be able to hand out a URL that
|
|
48
|
+
* does not.
|
|
28
49
|
* @param {object} entry - Catalog entry.
|
|
29
50
|
* @param {string} baseUrl - Public base URL, without a trailing slash.
|
|
51
|
+
* @param {object} [options] - Overrides.
|
|
52
|
+
* @param {string} [options.tilesRoot] - Root for the tile template.
|
|
30
53
|
* @returns {object} - A TileJSON 3.0.0 document.
|
|
31
54
|
*/
|
|
32
|
-
export function buildTileJson(entry, baseUrl) {
|
|
55
|
+
export function buildTileJson(entry, baseUrl, options = {}) {
|
|
33
56
|
const summary = entry.pmtiles ?? {};
|
|
34
|
-
const extension =
|
|
57
|
+
const extension = tileExtension(summary.format);
|
|
35
58
|
const root = `${baseUrl}/archives/${entry.infoHash}`;
|
|
59
|
+
const tilesRoot = options.tilesRoot ?? root;
|
|
36
60
|
|
|
37
61
|
const doc = {
|
|
38
62
|
tilejson: '3.0.0',
|
|
39
63
|
scheme: 'xyz',
|
|
40
|
-
tiles: [`${
|
|
64
|
+
tiles: [`${tilesRoot}/{z}/{x}/{y}.${extension}`],
|
|
41
65
|
name: summary.name ?? entry.name,
|
|
42
66
|
minzoom: summary.minZoom ?? 0,
|
|
43
67
|
maxzoom: summary.maxZoom ?? 14,
|
package/src/web/index.html
CHANGED
|
@@ -241,9 +241,11 @@
|
|
|
241
241
|
padding: 0 0.45rem;
|
|
242
242
|
}
|
|
243
243
|
button.copy:hover { color: var(--fg); border-color: var(--fg); }
|
|
244
|
+
/* The same green the public catalogue page confirms a copy with. */
|
|
245
|
+
button.copy.done { border-color: currentColor; color: #3fb950; }
|
|
244
246
|
/* Category endpoints. `th, td` is nowrap for the archive table, where a
|
|
245
247
|
wrapped number is worse than a wide one — but here the cell holds a
|
|
246
|
-
|
|
248
|
+
source URL and a paragraph explaining it, so nowrap pushed the table
|
|
247
249
|
wider than the window and took the Copy and Open buttons off the far
|
|
248
250
|
edge with it. Fixed layout keeps the two end columns where they are
|
|
249
251
|
and gives the middle whatever is left. */
|
|
@@ -999,17 +1001,60 @@
|
|
|
999
1001
|
return data;
|
|
1000
1002
|
}
|
|
1001
1003
|
|
|
1002
|
-
|
|
1004
|
+
/**
|
|
1005
|
+
* Puts text on the clipboard, in a console that is usually not a secure
|
|
1006
|
+
* context.
|
|
1007
|
+
*
|
|
1008
|
+
* `navigator.clipboard` requires one, and a console reached by IP over
|
|
1009
|
+
* plain HTTP on a LAN is not — which is how most of them are reached. So
|
|
1010
|
+
* the interesting path here is the fallback, not the modern call: the
|
|
1011
|
+
* selection API predates the clipboard API and carries no such
|
|
1012
|
+
* requirement, so it works exactly where the other one does not.
|
|
1013
|
+
*
|
|
1014
|
+
* What this replaces was `window.prompt`, which does put the text
|
|
1015
|
+
* somewhere it can be copied from and makes the reader do the copying.
|
|
1016
|
+
* A button that says "copy" should copy.
|
|
1017
|
+
*
|
|
1018
|
+
* Deliberately duplicated in public.html rather than shared. Both pages
|
|
1019
|
+
* are single files with their script inlined, and one browser shim is a
|
|
1020
|
+
* poor reason to give that up.
|
|
1021
|
+
* @param {string} text - What to put on the clipboard.
|
|
1022
|
+
* @returns {Promise<boolean>} - Whether it landed.
|
|
1023
|
+
*/
|
|
1024
|
+
const toClipboard = async (text) => {
|
|
1003
1025
|
try {
|
|
1004
1026
|
await navigator.clipboard.writeText(text);
|
|
1005
|
-
|
|
1027
|
+
return true;
|
|
1006
1028
|
} catch {
|
|
1007
|
-
//
|
|
1008
|
-
//
|
|
1009
|
-
|
|
1029
|
+
// Off-screen rather than hidden: an element with `display:none` or
|
|
1030
|
+
// `visibility:hidden` cannot hold a selection, so the copy would
|
|
1031
|
+
// silently do nothing.
|
|
1032
|
+
const field = document.createElement('textarea');
|
|
1033
|
+
field.value = text;
|
|
1034
|
+
field.setAttribute('readonly', '');
|
|
1035
|
+
field.style.position = 'fixed';
|
|
1036
|
+
field.style.top = '-1000px';
|
|
1037
|
+
document.body.append(field);
|
|
1038
|
+
field.select();
|
|
1039
|
+
// select() alone is not enough on iOS.
|
|
1040
|
+
field.setSelectionRange(0, field.value.length);
|
|
1041
|
+
try {
|
|
1042
|
+
return document.execCommand('copy');
|
|
1043
|
+
} catch {
|
|
1044
|
+
return false;
|
|
1045
|
+
} finally {
|
|
1046
|
+
field.remove();
|
|
1047
|
+
}
|
|
1010
1048
|
}
|
|
1011
1049
|
};
|
|
1012
1050
|
|
|
1051
|
+
const copy = async (text, what) => {
|
|
1052
|
+
if (await toClipboard(text)) return toast(`${what} copied`);
|
|
1053
|
+
// Both ways of reaching the clipboard refused. Showing the text is the
|
|
1054
|
+
// only thing left that is better than nothing.
|
|
1055
|
+
window.prompt(`Copy ${what}:`, text);
|
|
1056
|
+
};
|
|
1057
|
+
|
|
1013
1058
|
// ── Archives ──────────────────────────────────────────────────────────
|
|
1014
1059
|
let archives = [];
|
|
1015
1060
|
let selected = null;
|
|
@@ -3676,7 +3721,7 @@ Every piece is hashed against the ` +
|
|
|
3676
3721
|
// torrent client, a feed reader — and following one from here
|
|
3677
3722
|
// achieves nothing, so they copy.
|
|
3678
3723
|
//
|
|
3679
|
-
// The
|
|
3724
|
+
// The source URL is not printed. It is a TileJSON URL carrying a
|
|
3680
3725
|
// `.torrent` URL and a percent-encoded magnet in its fragment,
|
|
3681
3726
|
// several hundred characters of it, and it was truncated to 96
|
|
3682
3727
|
// here — long enough to fill the row and too short to be the
|
|
@@ -3715,19 +3760,22 @@ Every piece is hashed against the ` +
|
|
|
3715
3760
|
ends.tileJson
|
|
3716
3761
|
? `<div class="links">
|
|
3717
3762
|
${copyable(ends.tileJson, 'TileJSON')}
|
|
3763
|
+
${copyable(ends.xyz, 'XYZ')}
|
|
3718
3764
|
${link(ends.preview, 'preview')}
|
|
3719
3765
|
${link(ends.torrent, '.torrent', true)}
|
|
3720
3766
|
${copyable(ends.magnet, 'magnet', true)}
|
|
3721
3767
|
${copyable(ends.feed, 'RSS')}
|
|
3722
3768
|
${copyable(ends.latestFeed, 'RSS, newest only')}
|
|
3723
|
-
${copyable(ends.
|
|
3769
|
+
${copyable(ends.sourceUrl, 'source URL')}
|
|
3724
3770
|
</div>
|
|
3725
3771
|
<div class="sub" style="margin-top:0.5rem">
|
|
3726
|
-
The
|
|
3727
|
-
the magnet in its fragment, which is never sent to
|
|
3728
|
-
server — an ordinary client fetches the TileJSON
|
|
3729
|
-
ignores them, a swarm-aware one reads them first,
|
|
3730
|
-
both follow the category rather than this build.
|
|
3772
|
+
The source URL is the TileJSON with the .torrent URL
|
|
3773
|
+
and the magnet in its fragment, which is never sent to
|
|
3774
|
+
the server — an ordinary client fetches the TileJSON
|
|
3775
|
+
and ignores them, a swarm-aware one reads them first,
|
|
3776
|
+
and both follow the category rather than this build.
|
|
3777
|
+
It goes in a style's <code>sources</code> block; it is
|
|
3778
|
+
not itself a style.
|
|
3731
3779
|
</div>`
|
|
3732
3780
|
: '<div class="sub">not a PMTiles archive, so there is no tile endpoint</div>'
|
|
3733
3781
|
}
|
|
@@ -3736,15 +3784,32 @@ Every piece is hashed against the ` +
|
|
|
3736
3784
|
.join('');
|
|
3737
3785
|
|
|
3738
3786
|
for (const button of list.querySelectorAll('[data-copy-url]')) {
|
|
3787
|
+
// The URL either way, so there is something to select by hand if
|
|
3788
|
+
// both routes to the clipboard are shut.
|
|
3789
|
+
button.title = button.dataset.copyUrl;
|
|
3790
|
+
const label = button.textContent;
|
|
3791
|
+
// Said on the button rather than in a toast: these sit in a row of
|
|
3792
|
+
// seven, and which one was pressed is the thing worth confirming.
|
|
3793
|
+
// The public catalogue page does the same, and the two are the same
|
|
3794
|
+
// control in two places.
|
|
3795
|
+
const said = (text, ok) => {
|
|
3796
|
+
button.textContent = text;
|
|
3797
|
+
if (ok) button.classList.add('done');
|
|
3798
|
+
setTimeout(() => {
|
|
3799
|
+
button.textContent = label;
|
|
3800
|
+
button.classList.remove('done');
|
|
3801
|
+
}, 1200);
|
|
3802
|
+
};
|
|
3739
3803
|
button.onclick = async () => {
|
|
3740
3804
|
const url = button.dataset.copyUrl;
|
|
3741
|
-
if (!button.dataset.copyFetch) return copy(url, 'URL');
|
|
3742
3805
|
try {
|
|
3743
3806
|
// A plain fetch, not `api`: that one parses JSON, and this
|
|
3744
3807
|
// endpoint answers text/plain.
|
|
3745
|
-
const
|
|
3746
|
-
|
|
3747
|
-
|
|
3808
|
+
const value = button.dataset.copyFetch
|
|
3809
|
+
? (await (await fetch(url)).text()).trim()
|
|
3810
|
+
: url;
|
|
3811
|
+
const landed = await toClipboard(value);
|
|
3812
|
+
said(landed ? 'copied' : 'it is in the tooltip', landed);
|
|
3748
3813
|
} catch (error) {
|
|
3749
3814
|
toast(error.message);
|
|
3750
3815
|
}
|
package/src/web/public.html
CHANGED
|
@@ -290,6 +290,48 @@
|
|
|
290
290
|
|
|
291
291
|
// A row of "label: copyable URL". The URLs are the point of the page, so
|
|
292
292
|
// they are shown in full rather than hidden behind link text.
|
|
293
|
+
/**
|
|
294
|
+
* Puts text on the clipboard, including where there is no secure context.
|
|
295
|
+
*
|
|
296
|
+
* `navigator.clipboard` requires one. This page is usually reached over
|
|
297
|
+
* HTTPS and so usually has it — but a node answering by IP on a LAN does
|
|
298
|
+
* not, and there the button did nothing but say so. The selection API
|
|
299
|
+
* predates the clipboard API and carries no such requirement, so it
|
|
300
|
+
* works exactly where the other one fails.
|
|
301
|
+
*
|
|
302
|
+
* Deliberately duplicated in index.html rather than shared. Both pages
|
|
303
|
+
* are single files with their script inlined, and one browser shim is a
|
|
304
|
+
* poor reason to give that up.
|
|
305
|
+
* @param {string} text - What to put on the clipboard.
|
|
306
|
+
* @returns {Promise<boolean>} - Whether it landed.
|
|
307
|
+
*/
|
|
308
|
+
const toClipboard = async (text) => {
|
|
309
|
+
try {
|
|
310
|
+
await navigator.clipboard.writeText(text);
|
|
311
|
+
return true;
|
|
312
|
+
} catch {
|
|
313
|
+
// Off-screen rather than hidden: an element with `display:none` or
|
|
314
|
+
// `visibility:hidden` cannot hold a selection, so the copy would
|
|
315
|
+
// silently do nothing.
|
|
316
|
+
const field = document.createElement('textarea');
|
|
317
|
+
field.value = text;
|
|
318
|
+
field.setAttribute('readonly', '');
|
|
319
|
+
field.style.position = 'fixed';
|
|
320
|
+
field.style.top = '-1000px';
|
|
321
|
+
document.body.append(field);
|
|
322
|
+
field.select();
|
|
323
|
+
// select() alone is not enough on iOS.
|
|
324
|
+
field.setSelectionRange(0, field.value.length);
|
|
325
|
+
try {
|
|
326
|
+
return document.execCommand('copy');
|
|
327
|
+
} catch {
|
|
328
|
+
return false;
|
|
329
|
+
} finally {
|
|
330
|
+
field.remove();
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
|
|
293
335
|
const urlRow = (label, url) => {
|
|
294
336
|
const row = el('div');
|
|
295
337
|
row.append(el('span', null, label));
|
|
@@ -454,9 +496,11 @@
|
|
|
454
496
|
const value = fetched
|
|
455
497
|
? (await (await fetch(absolute)).text()).trim()
|
|
456
498
|
: absolute;
|
|
457
|
-
await
|
|
458
|
-
button.classList.
|
|
459
|
-
button.textContent =
|
|
499
|
+
const landed = await toClipboard(value);
|
|
500
|
+
button.classList.toggle('done', landed);
|
|
501
|
+
button.textContent = landed
|
|
502
|
+
? 'copied'
|
|
503
|
+
: 'copy failed — it is in the tooltip';
|
|
460
504
|
setTimeout(() => {
|
|
461
505
|
button.classList.remove('done');
|
|
462
506
|
button.textContent = text;
|
|
@@ -470,6 +514,11 @@
|
|
|
470
514
|
|
|
471
515
|
const ends = entry.endpoints;
|
|
472
516
|
if (ends.tileJson) copy(ends.tileJson, 'TileJSON');
|
|
517
|
+
// The XYZ template beside the document that carries it. Anything
|
|
518
|
+
// that takes a URL with braces in it rather than a TileJSON — a
|
|
519
|
+
// Leaflet layer, an OpenLayers source, a GIS client — wants this
|
|
520
|
+
// one, and it follows the category, so it survives a rebuild.
|
|
521
|
+
if (ends.xyz) copy(ends.xyz, 'XYZ');
|
|
473
522
|
// The newest build's preview, which is what "show me this
|
|
474
523
|
// category" means.
|
|
475
524
|
if (ends.preview) add(ends.preview, 'preview');
|
|
@@ -486,7 +535,7 @@
|
|
|
486
535
|
// URL carrying the .torrent URL and a percent-encoded magnet in its
|
|
487
536
|
// fragment. Printed in full it was most of the card — several
|
|
488
537
|
// hundred characters of it — and it is never read, only pasted.
|
|
489
|
-
if (ends.
|
|
538
|
+
if (ends.sourceUrl) copy(ends.sourceUrl, 'source URL');
|
|
490
539
|
card.append(links);
|
|
491
540
|
|
|
492
541
|
host.append(card);
|