pmtiles-swarm 0.13.0 → 0.14.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 +54 -0
- package/docs/architecture-diagram.md +31 -3
- package/docs/security.md +50 -0
- package/docs/serving-tiles.md +37 -5
- package/package.json +1 -1
- package/src/api.js +32 -0
- package/src/engines/composite.js +45 -1
- package/src/library.js +40 -8
- package/src/web/index.html +16 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,60 @@
|
|
|
7
7
|
### 🐞 Bug fixes
|
|
8
8
|
- _...Add new stuff here..._
|
|
9
9
|
|
|
10
|
+
## 0.14.0
|
|
11
|
+
### ✨ Features and improvements
|
|
12
|
+
- **The Categories page hands you the URL a style should actually use.** A new **For a style** row
|
|
13
|
+
gives the category's TileJSON URL with a magnet in its fragment, which is the string worth
|
|
14
|
+
copying — the plain TileJSON row is still there for anything that only speaks HTTP.
|
|
15
|
+
|
|
16
|
+
Where a publisher is announcing the category over the DHT, the fragment carries the **mutable**
|
|
17
|
+
magnet (`xs=urn:btpk:…&s=<category>`), which is the form a category needs: it names the category
|
|
18
|
+
rather than a build, so it does not go stale on the next one while the URL keeps following it.
|
|
19
|
+
Without a publisher it falls back to the newest build's own magnet, which pins that build but
|
|
20
|
+
still beats a blank map on the only occasion it is read at all.
|
|
21
|
+
|
|
22
|
+
Also on `GET /api/categories` as `endpoints.styleUrl`, and `null` for a category whose newest
|
|
23
|
+
archive is not PMTiles — the same rule the tile endpoints already follow.
|
|
24
|
+
|
|
25
|
+
The magnet was already available on an individual archive, but a category URL is what a style
|
|
26
|
+
points at, and that is the page where it was missing.
|
|
27
|
+
|
|
28
|
+
### 🐞 Bug fixes
|
|
29
|
+
- **CI and the release workflow install with `--ignore-scripts`.** The 0.14.0 release failed in
|
|
30
|
+
`npm ci` because `node-datachannel` — WebTorrent's WebRTC binary, four levels down the tree —
|
|
31
|
+
found no prebuilt binary for the runner and crashed trying to build from source. The same
|
|
32
|
+
lockfile had published minutes earlier, so it was the download rather than an incompatibility.
|
|
33
|
+
|
|
34
|
+
Nothing in CI needs that binary: the suite passes without it, and `npm publish` ships source
|
|
35
|
+
rather than `node_modules`, so install scripts have no bearing on what is published. A
|
|
36
|
+
third-party binary download should not be able to block a release. Whether WebRTC works on a
|
|
37
|
+
given machine is still checked where it matters, after installing on the host.
|
|
38
|
+
|
|
39
|
+
## 0.13.1
|
|
40
|
+
### 🐞 Bug fixes
|
|
41
|
+
- **A node with a secondary engine no longer takes a quarter of an hour to start listening.**
|
|
42
|
+
Handing an archive to a second seeding client makes it hash every byte before it will serve any
|
|
43
|
+
— minutes for tens of gigabytes — and that hand-over was **awaited** inside `add()`. On startup,
|
|
44
|
+
where the library is restored one archive at a time, the cost landed end to end before the node
|
|
45
|
+
would bind its port. A seventeen-archive library sat silent for about fifteen minutes.
|
|
46
|
+
|
|
47
|
+
The hand-over is now queued and the caller carries on. Nothing depended on waiting for it: the
|
|
48
|
+
periodic sweep that already exists to catch archives finishing later is the same mechanism, and
|
|
49
|
+
a failed hand-over already un-marks itself so that sweep retries it.
|
|
50
|
+
|
|
51
|
+
Queued through one chain rather than fired off freely, so hand-overs still run one at a time —
|
|
52
|
+
seventeen archives hashing at once on a spinning disk is slower than seventeen in turn, and much
|
|
53
|
+
harder to reason about.
|
|
54
|
+
- **Restoring a large library reports progress.** It said nothing at all until it had finished, so
|
|
55
|
+
a node that was working and a node that was stuck looked identical for as long as it took — long
|
|
56
|
+
enough, on a real library, to go looking for a debugger:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
[restore] 6 of 17 after 15s
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
On a timer rather than per archive, so a small library stays quiet.
|
|
63
|
+
|
|
10
64
|
## 0.13.0
|
|
11
65
|
### ✨ Features and improvements
|
|
12
66
|
- **The publisher's DHT socket is bound explicitly, on a configurable port.** It was left to bind
|
|
@@ -224,7 +224,7 @@ New data means a new infohash, which is what makes cache invalidation free.
|
|
|
224
224
|
%%{init: {"flowchart": {"rankSpacing": 60}}}%%
|
|
225
225
|
graph LR
|
|
226
226
|
NEW["new planet.pmtiles<br/><small>weekly build</small>"] --> HASH["primary re-hashes<br/>→ new infohash"]
|
|
227
|
-
HASH --> BEP["BEP 46 mutable entry<br/><small>same public key, seq
|
|
227
|
+
HASH --> BEP["BEP 46 mutable entry<br/><small>same public key, new seq</small>"]
|
|
228
228
|
HASH --> FEED["new RSS item"]
|
|
229
229
|
|
|
230
230
|
BEP --> FOLLOW["clients following the key<br/>see the new version"]
|
|
@@ -239,8 +239,36 @@ graph LR
|
|
|
239
239
|
**Key points:** tile URLs are content-addressed, so they never need invalidating.
|
|
240
240
|
The old infohash stays valid and servable for as long as anyone still holds it —
|
|
241
241
|
useful for clients pinned to a known-good build — while new requests move to the
|
|
242
|
-
new one as soon as they re-read `tiles.json`.
|
|
243
|
-
|
|
242
|
+
new one as soon as they re-read `tiles.json`.
|
|
243
|
+
|
|
244
|
+
**There are exactly two mutable things**, and they say the same thing by
|
|
245
|
+
different means: the `/latest/<category>/` documents, which need this server, and
|
|
246
|
+
the BEP 46 record, which does not. A style pointing at the public key resolves
|
|
247
|
+
the current build over the DHT with nothing of ours running at all. Only the node
|
|
248
|
+
that builds signs those records — see
|
|
249
|
+
[security.md](security.md#the-publisher-key-is-not-a-credential), because the key
|
|
250
|
+
behaves unlike every other secret in the system.
|
|
251
|
+
|
|
252
|
+
The sequence number is derived from the clock rather than incremented, so a
|
|
253
|
+
publisher that is rebuilt or restored from backup carries on without needing to
|
|
254
|
+
remember where it had got to.
|
|
255
|
+
|
|
256
|
+
### Bootstrapping without the server
|
|
257
|
+
|
|
258
|
+
A torrent-aware client still has to *learn* the magnet from somewhere, and until
|
|
259
|
+
it does, the swarm — the part that depends on no server — is unreachable
|
|
260
|
+
precisely when the server is down. The fix is that the magnet travels in the
|
|
261
|
+
**fragment** of the TileJSON URL a style already carries:
|
|
262
|
+
|
|
263
|
+
```
|
|
264
|
+
https://swarm.example.org/latest/openmaptiles/tiles.json#magnet:?xs=urn:btpk:…&s=openmaptiles&ws=…
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
A fragment is never sent in an HTTP request, so ordinary clients fetch the
|
|
268
|
+
TileJSON and ignore it while a swarm-aware one reads the magnet before making any
|
|
269
|
+
call. With a BEP 46 key in it (`xs=urn:btpk:`) rather than an infohash, that
|
|
270
|
+
string does not go stale on the next build either. See
|
|
271
|
+
[serving-tiles.md](serving-tiles.md#a-fragment-that-survives-a-rebuild).
|
|
244
272
|
|
|
245
273
|
---
|
|
246
274
|
|
package/docs/security.md
CHANGED
|
@@ -188,6 +188,56 @@ Three ways past it, in order of preference:
|
|
|
188
188
|
2. Bind to `127.0.0.1` and reach it through a reverse proxy that authenticates.
|
|
189
189
|
3. Set `allowUnauthenticated: true`, if the network really is trusted.
|
|
190
190
|
|
|
191
|
+
## The publisher key is not a credential
|
|
192
|
+
|
|
193
|
+
Everything above guards *this node*: who may read the API, who may change
|
|
194
|
+
settings, who may add an archive. The publisher key is a different kind of
|
|
195
|
+
secret, and the difference is worth stating plainly because the habits do not
|
|
196
|
+
carry over.
|
|
197
|
+
|
|
198
|
+
`mutable.keyPath` holds an ed25519 private key used to sign BEP 46 records — the
|
|
199
|
+
DHT entries that say which archive is the current build of a category. It is a
|
|
200
|
+
**signing key**, closer to a code-signing certificate than to an API token.
|
|
201
|
+
|
|
202
|
+
| | An access credential (`apiKey`, a token, `password`) | The publisher key |
|
|
203
|
+
| --- | --- | --- |
|
|
204
|
+
| What it grants | access to one node | authority over what your subscribers believe |
|
|
205
|
+
| If it leaks | that node is compromised | anyone can sign a record pointing your subscribers at any archive |
|
|
206
|
+
| If you lose it | mint another | every style pointing at that public key breaks, permanently |
|
|
207
|
+
| Rotation | revoke and reissue | there is none |
|
|
208
|
+
|
|
209
|
+
That third row is the one people are unprepared for. A public key **is** the
|
|
210
|
+
identity: there is no registry to update and no way to tell a subscriber that a
|
|
211
|
+
new key is also you. Losing the file ends that identity.
|
|
212
|
+
|
|
213
|
+
And the second row is why it does not belong on a serving tier. A leaked API key
|
|
214
|
+
lets someone read a node. A leaked publisher key lets someone tell every
|
|
215
|
+
subscriber that an archive they control is your latest planet build — signed,
|
|
216
|
+
verifying correctly, and indistinguishable from a real announcement.
|
|
217
|
+
|
|
218
|
+
**So it lives on exactly one machine.** Only the node that builds needs it;
|
|
219
|
+
serving nodes carry the public half on the catalog entry and hand it out in the
|
|
220
|
+
TileJSON, which is why a serving tier can be compromised without anyone being
|
|
221
|
+
able to publish. Running two publishers under one key is separately a mistake:
|
|
222
|
+
they fight over the sequence number.
|
|
223
|
+
|
|
224
|
+
Practically:
|
|
225
|
+
|
|
226
|
+
- `chmod 400`, owned by the service account. Nothing in the product writes it
|
|
227
|
+
back, so it does not need to be writable and should not be.
|
|
228
|
+
- Back it up **off the machine**, and treat the backup as seriously as the
|
|
229
|
+
original.
|
|
230
|
+
- Do not put it in an image, a config-sync set, or anywhere a standby will
|
|
231
|
+
receive it. Under HA sync the configuration replicates and the key does not,
|
|
232
|
+
which is the intended asymmetry rather than an oversight — the standby logs
|
|
233
|
+
`not publishing: ENOENT` and serves normally.
|
|
234
|
+
- It is unrelated to `auth`. Turning authentication off does not expose it, and
|
|
235
|
+
rotating the API key does not affect it.
|
|
236
|
+
|
|
237
|
+
Generate one with `pmtiles-swarm publisher-key`. Setup detail is in
|
|
238
|
+
[running-as-a-service.md](running-as-a-service.md#the-publisher-key); what it is
|
|
239
|
+
*for* is in [serving-tiles.md](serving-tiles.md#a-fragment-that-survives-a-rebuild).
|
|
240
|
+
|
|
191
241
|
## What this is not
|
|
192
242
|
|
|
193
243
|
**It is not a defence against someone on your network.** Sessions are bearer
|
package/docs/serving-tiles.md
CHANGED
|
@@ -291,7 +291,13 @@ TileJSON documents from this server carry a non-standard `torrent` member:
|
|
|
291
291
|
"torrent": "https://swarm.example.org/archives/913d…/archive.torrent",
|
|
292
292
|
"name": "planet.pmtiles",
|
|
293
293
|
"size": 77242531840,
|
|
294
|
-
"webseeds": ["https://maps.example.org/planet.pmtiles"]
|
|
294
|
+
"webseeds": ["https://maps.example.org/planet.pmtiles"],
|
|
295
|
+
"mutable": {
|
|
296
|
+
"publicKey": "7680dc95248eb807…",
|
|
297
|
+
"salt": "openmaptiles",
|
|
298
|
+
"seq": 1786108931,
|
|
299
|
+
"magnet": "magnet:?xs=urn:btpk:7680dc95248eb807…&s=openmaptiles&ws=…"
|
|
300
|
+
}
|
|
295
301
|
}
|
|
296
302
|
}
|
|
297
303
|
```
|
|
@@ -309,10 +315,29 @@ load it. That also means a torrent-aware client gets a working map immediately
|
|
|
309
315
|
over HTTP while the swarm is still finding peers, rather than staring at an empty
|
|
310
316
|
canvas for the 90 to 240 seconds a cold magnet can take to resolve metadata.
|
|
311
317
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
318
|
+
### The `mutable` sub-block
|
|
319
|
+
|
|
320
|
+
Present only when a publisher is announcing this archive's category over the
|
|
321
|
+
DHT. It is the difference between a document describing *this build* and one
|
|
322
|
+
describing *the current build*:
|
|
323
|
+
|
|
324
|
+
| | |
|
|
325
|
+
| --- | --- |
|
|
326
|
+
| `publicKey` | The identity to resolve against. Public — there is nothing secret in the block |
|
|
327
|
+
| `salt` | The category, so one key can address several |
|
|
328
|
+
| `seq` | The sequence of the record this document was generated beside |
|
|
329
|
+
| `magnet` | Assembled from the above, ready to paste into a style's URL fragment |
|
|
330
|
+
|
|
331
|
+
`magnet` is built rather than left to the consumer because every node can build
|
|
332
|
+
it — it contains only the public half — so a fleet behind a balancer hands out
|
|
333
|
+
one identical string and none of them can publish. A BEP 46 client resolves it
|
|
334
|
+
and follows updates rather than pinning to the build this document happened to
|
|
335
|
+
describe.
|
|
336
|
+
|
|
337
|
+
See [a fragment that survives a rebuild](#a-fragment-that-survives-a-rebuild)
|
|
338
|
+
above for how it reaches a style, and
|
|
339
|
+
[security.md](security.md#the-publisher-key-is-not-a-credential) for why the
|
|
340
|
+
private half lives on exactly one machine.
|
|
316
341
|
|
|
317
342
|
## Health checks
|
|
318
343
|
|
|
@@ -564,6 +589,13 @@ first tile can be seconds away.
|
|
|
564
589
|
**Raster archives get the raster.** There is nothing to inspect in an image, so
|
|
565
590
|
the panel says so and the map is for checking coverage.
|
|
566
591
|
|
|
592
|
+
**What it has actually served** is on the archive's detail, as `served`, and
|
|
593
|
+
across the node at `GET /api/stats` — requests, bytes, a breakdown by zoom and
|
|
594
|
+
status, and which client addresses asked. Worth reading beside `reading`: an
|
|
595
|
+
archive being read through the swarm while serving thousands of tiles is a
|
|
596
|
+
different situation from one doing neither. See the
|
|
597
|
+
[README](../README.md#seeing-what-a-node-is-actually-serving).
|
|
598
|
+
|
|
567
599
|
No symbol layers are drawn and no glyphs are configured, deliberately: an
|
|
568
600
|
archive carries tiles, not fonts, and a preview that needed a font server to
|
|
569
601
|
render would not be a preview of the archive.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmtiles-swarm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
isPublicSurface,
|
|
15
15
|
} from './auth.js';
|
|
16
16
|
import { normalizeCategories } from './catalog.js';
|
|
17
|
+
import { mutableMagnet } from './mutable.js';
|
|
17
18
|
import { guessKind } from './library.js';
|
|
18
19
|
import { QBittorrentEngine } from './engines/qbittorrent.js';
|
|
19
20
|
import { RESTART_REQUIRED, redactConfig, saveConfig } from './config.js';
|
|
@@ -60,6 +61,25 @@ function route(handler) {
|
|
|
60
61
|
* @param {object} deps.config - Resolved configuration.
|
|
61
62
|
* @returns {import('express').Express} - The configured app.
|
|
62
63
|
*/
|
|
64
|
+
/**
|
|
65
|
+
* A TileJSON URL carrying a magnet in its fragment.
|
|
66
|
+
* @param {string} category - Which category.
|
|
67
|
+
* @param {object} newest - Its newest entry.
|
|
68
|
+
* @param {string} base - Public base URL.
|
|
69
|
+
* @returns {string} - The URL a style should use.
|
|
70
|
+
*/
|
|
71
|
+
function styleUrlFor(category, newest, base) {
|
|
72
|
+
const url = `${base}/latest/${category}/tiles.json`;
|
|
73
|
+
const magnet = newest?.mutable?.publicKey
|
|
74
|
+
? mutableMagnet(newest.mutable.publicKey, {
|
|
75
|
+
salt: newest.mutable.salt ?? category,
|
|
76
|
+
name: newest.name,
|
|
77
|
+
webSeeds: newest.webSeeds,
|
|
78
|
+
})
|
|
79
|
+
: newest?.magnet;
|
|
80
|
+
return magnet ? `${url}#${magnet}` : url;
|
|
81
|
+
}
|
|
82
|
+
|
|
63
83
|
export function createApp({
|
|
64
84
|
library,
|
|
65
85
|
catalog,
|
|
@@ -1703,6 +1723,18 @@ export function createApp({
|
|
|
1703
1723
|
servable,
|
|
1704
1724
|
endpoints: {
|
|
1705
1725
|
tileJson: servable ? `${base}/latest/${category}/tiles.json` : null,
|
|
1726
|
+
// The same URL with a magnet in the fragment, which is what a
|
|
1727
|
+
// style should carry. A fragment is never sent in a request, so
|
|
1728
|
+
// an ordinary client fetches the TileJSON and ignores it, while
|
|
1729
|
+
// a swarm-aware one has the magnet before it makes any call --
|
|
1730
|
+
// and can therefore still start when this server cannot answer.
|
|
1731
|
+
//
|
|
1732
|
+
// The mutable magnet where there is one, because a category is
|
|
1733
|
+
// precisely where an infohash goes stale: it names the category
|
|
1734
|
+
// and resolves the current build over the DHT. Otherwise the
|
|
1735
|
+
// newest build's own magnet, which pins that build but still
|
|
1736
|
+
// beats a blank map when the fallback is needed at all.
|
|
1737
|
+
styleUrl: servable ? styleUrlFor(category, newest, base) : null,
|
|
1706
1738
|
torrent: `${base}/latest/${category}/archive.torrent`,
|
|
1707
1739
|
magnet: `${base}/latest/${category}/magnet`,
|
|
1708
1740
|
feed: `${base}/feed/${category}.xml`,
|
package/src/engines/composite.js
CHANGED
|
@@ -36,6 +36,8 @@ export class CompositeEngine {
|
|
|
36
36
|
#timer;
|
|
37
37
|
/** Infohashes handed to secondaries, so they are not handed over twice. */
|
|
38
38
|
#shared = new Set();
|
|
39
|
+
/** Serialises background hand-overs, so they never run all at once. */
|
|
40
|
+
#shareChain = Promise.resolve();
|
|
39
41
|
/** Set once a stop has begun, so late timers do nothing. */
|
|
40
42
|
#stopping = false;
|
|
41
43
|
|
|
@@ -133,12 +135,54 @@ export class CompositeEngine {
|
|
|
133
135
|
const infoHash = await this.#primary.add(request);
|
|
134
136
|
|
|
135
137
|
if (this.#shareable(request) && (await this.#primaryHasItAll(infoHash))) {
|
|
136
|
-
|
|
138
|
+
// Queued rather than awaited. Handing an archive to a seeding client it
|
|
139
|
+
// has not seen before makes it hash every byte first, which is minutes
|
|
140
|
+
// for tens of gigabytes -- so awaiting it here put that cost inside the
|
|
141
|
+
// caller. On startup, where the library is restored one archive at a
|
|
142
|
+
// time, that meant a node sat silent for a quarter of an hour before it
|
|
143
|
+
// would listen, doing work the periodic sweep exists to do anyway.
|
|
144
|
+
//
|
|
145
|
+
// Serialised through one chain rather than fired off freely: seventeen
|
|
146
|
+
// archives hashing at once on a spinning disk is slower than seventeen
|
|
147
|
+
// in turn, and far harder to reason about.
|
|
148
|
+
this.#queueShare(infoHash, request);
|
|
137
149
|
}
|
|
138
150
|
|
|
139
151
|
return infoHash;
|
|
140
152
|
}
|
|
141
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Resolves once every queued hand-over has finished.
|
|
156
|
+
*
|
|
157
|
+
* Nothing in normal operation waits for this — that is the point of the
|
|
158
|
+
* queue — but a caller that wants to observe the result, a test above all,
|
|
159
|
+
* needs somewhere to wait rather than a guess about microtask order.
|
|
160
|
+
* @returns {Promise<void>} - Resolves when the queue is empty.
|
|
161
|
+
*/
|
|
162
|
+
async whenShared() {
|
|
163
|
+
// Awaited twice: the first await settles what is queued now, and anything
|
|
164
|
+
// that queued more while it ran is picked up by the second.
|
|
165
|
+
await this.#shareChain;
|
|
166
|
+
await this.#shareChain;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Runs a share after any already queued, without making the caller wait.
|
|
171
|
+
* @param {string} infoHash - The archive.
|
|
172
|
+
* @param {object} request - The original add request.
|
|
173
|
+
* @returns {void}
|
|
174
|
+
*/
|
|
175
|
+
#queueShare(infoHash, request) {
|
|
176
|
+
this.#shareChain = this.#shareChain
|
|
177
|
+
.then(() => (this.#stopping ? undefined : this.#shareOne(infoHash, request)))
|
|
178
|
+
// #shareOne already swallows an engine refusing the archive; this is the
|
|
179
|
+
// last resort, so one failure cannot break the chain for everything
|
|
180
|
+
// queued behind it.
|
|
181
|
+
.catch((error) =>
|
|
182
|
+
console.warn(`[engine] could not hand over ${infoHash}: ${error.message}`),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
142
186
|
/**
|
|
143
187
|
* Whether the primary actually holds the whole archive.
|
|
144
188
|
*
|
package/src/library.js
CHANGED
|
@@ -1463,9 +1463,41 @@ export class Library {
|
|
|
1463
1463
|
*/
|
|
1464
1464
|
async restore() {
|
|
1465
1465
|
const entries = this.#catalog.list();
|
|
1466
|
-
|
|
1467
|
-
|
|
1466
|
+
const tally = { restored: 0, failed: 0 };
|
|
1467
|
+
|
|
1468
|
+
// Restoring a large library is slow, and until now it said nothing until
|
|
1469
|
+
// it had finished -- so a node that was working and a node that was stuck
|
|
1470
|
+
// looked identical for as long as it took, which on a real library was
|
|
1471
|
+
// long enough to reach for a debugger. Reported on a timer rather than per
|
|
1472
|
+
// archive, so a small library stays quiet and a large one stops being a
|
|
1473
|
+
// mystery.
|
|
1474
|
+
const startedAt = Date.now();
|
|
1475
|
+
const progress = setInterval(() => {
|
|
1476
|
+
const seconds = Math.round((Date.now() - startedAt) / 1000);
|
|
1477
|
+
console.log(
|
|
1478
|
+
`[restore] ${tally.restored + tally.failed} of ${entries.length} ` +
|
|
1479
|
+
`after ${seconds}s`,
|
|
1480
|
+
);
|
|
1481
|
+
}, 15_000);
|
|
1482
|
+
progress.unref?.();
|
|
1468
1483
|
|
|
1484
|
+
try {
|
|
1485
|
+
return await this.#restoreEach(entries, tally);
|
|
1486
|
+
} finally {
|
|
1487
|
+
clearInterval(progress);
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
/**
|
|
1492
|
+
* The restore loop itself.
|
|
1493
|
+
*
|
|
1494
|
+
* Counts into the caller's tally rather than its own, so the progress timer
|
|
1495
|
+
* above has something to read while this is still running.
|
|
1496
|
+
* @param {object[]} entries - Catalog entries, newest first.
|
|
1497
|
+
* @param {{restored: number, failed: number}} tally - Mutated as it goes.
|
|
1498
|
+
* @returns {Promise<{restored: number, failed: number}>} - That tally.
|
|
1499
|
+
*/
|
|
1500
|
+
async #restoreEach(entries, tally) {
|
|
1469
1501
|
for (const entry of entries) {
|
|
1470
1502
|
// An engine that cannot open its port will fail every one of these, each
|
|
1471
1503
|
// after its own timeout. Stopping at the first is the difference between
|
|
@@ -1473,13 +1505,13 @@ export class Library {
|
|
|
1473
1505
|
// minutes per archive.
|
|
1474
1506
|
if (this.#engine.fatalError) {
|
|
1475
1507
|
console.error(`[restore] stopping: ${this.#engine.fatalError.message}`);
|
|
1476
|
-
failed += entries.length - restored;
|
|
1508
|
+
tally.failed += entries.length - tally.restored;
|
|
1477
1509
|
break;
|
|
1478
1510
|
}
|
|
1479
1511
|
|
|
1480
1512
|
try {
|
|
1481
1513
|
if (!entry.torrentPath && !entry.magnet) {
|
|
1482
|
-
failed++;
|
|
1514
|
+
tally.failed++;
|
|
1483
1515
|
continue;
|
|
1484
1516
|
}
|
|
1485
1517
|
|
|
@@ -1495,7 +1527,7 @@ export class Library {
|
|
|
1495
1527
|
`usable (${error.code ?? error.message}), so it cannot start. ` +
|
|
1496
1528
|
'Move it with Set location, or make that path reachable again.',
|
|
1497
1529
|
);
|
|
1498
|
-
failed++;
|
|
1530
|
+
tally.failed++;
|
|
1499
1531
|
continue;
|
|
1500
1532
|
}
|
|
1501
1533
|
}
|
|
@@ -1505,14 +1537,14 @@ export class Library {
|
|
|
1505
1537
|
// own add and skip that, which is why an archive that could not find a
|
|
1506
1538
|
// peer stayed unable to find one across every restart.
|
|
1507
1539
|
await this.#readd(entry);
|
|
1508
|
-
restored++;
|
|
1540
|
+
tally.restored++;
|
|
1509
1541
|
} catch (error) {
|
|
1510
|
-
failed++;
|
|
1542
|
+
tally.failed++;
|
|
1511
1543
|
console.error(`[restore] ${entry.name}: ${error.message}`);
|
|
1512
1544
|
}
|
|
1513
1545
|
}
|
|
1514
1546
|
|
|
1515
|
-
return
|
|
1547
|
+
return tally;
|
|
1516
1548
|
}
|
|
1517
1549
|
|
|
1518
1550
|
/**
|
package/src/web/index.html
CHANGED
|
@@ -2356,6 +2356,7 @@
|
|
|
2356
2356
|
const newest = entry.newest;
|
|
2357
2357
|
const rows = [
|
|
2358
2358
|
['TileJSON', entry.endpoints.tileJson],
|
|
2359
|
+
['For a style', entry.endpoints.styleUrl],
|
|
2359
2360
|
['Torrent', entry.endpoints.torrent],
|
|
2360
2361
|
['Magnet', entry.endpoints.magnet],
|
|
2361
2362
|
['Feed', entry.endpoints.feed],
|
|
@@ -2384,7 +2385,20 @@
|
|
|
2384
2385
|
<td style="width:8rem">${label}</td>
|
|
2385
2386
|
<td>${
|
|
2386
2387
|
url
|
|
2387
|
-
? `<code>${escapeHtml(
|
|
2388
|
+
? `<code>${escapeHtml(
|
|
2389
|
+
url.length > 96 ? `${url.slice(0, 96)}…` : url,
|
|
2390
|
+
)}</code>${
|
|
2391
|
+
label === 'For a style'
|
|
2392
|
+
? `<div class="sub">the magnet rides in the fragment, which is
|
|
2393
|
+
never sent to the server — an ordinary client fetches the
|
|
2394
|
+
TileJSON and ignores it, a swarm-aware one reads it before
|
|
2395
|
+
making any call${
|
|
2396
|
+
url.includes('xs=urn:btpk')
|
|
2397
|
+
? ' and follows the category rather than this build'
|
|
2398
|
+
: ''
|
|
2399
|
+
}</div>`
|
|
2400
|
+
: ''
|
|
2401
|
+
}`
|
|
2388
2402
|
: '<span class="sub">not a PMTiles archive, so there is no tile endpoint</span>'
|
|
2389
2403
|
}</td>
|
|
2390
2404
|
<td style="width:11rem">${
|
|
@@ -2406,6 +2420,7 @@
|
|
|
2406
2420
|
const [index, row] = button.dataset.copy.split('-').map(Number);
|
|
2407
2421
|
const url = [
|
|
2408
2422
|
categories[index].endpoints.tileJson,
|
|
2423
|
+
categories[index].endpoints.styleUrl,
|
|
2409
2424
|
categories[index].endpoints.torrent,
|
|
2410
2425
|
categories[index].endpoints.magnet,
|
|
2411
2426
|
categories[index].endpoints.feed,
|