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