pmtiles-swarm 0.11.0 → 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 CHANGED
@@ -7,6 +7,45 @@
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
+
10
49
  ## 0.11.0
11
50
  ### ✨ Features and improvements
12
51
  - **The magnet can travel in the TileJSON URL's fragment**, and the console will build that string
@@ -226,6 +226,54 @@ nothing publishes those records yet.
226
226
  For an immutable `/archives/<infohash>/tiles.json` URL the question does not
227
227
  arise: both halves name the same fixed archive.
228
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
+
229
277
  ## The `torrent` block
230
278
 
231
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.11.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/config.js CHANGED
@@ -397,6 +397,35 @@ 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
+ },
400
429
  /**
401
430
  * What this node has served, at `GET /api/stats`.
402
431
  *
package/src/index.js CHANGED
@@ -109,6 +109,7 @@ async function main() {
109
109
  Usage:
110
110
  pmtiles-swarm [--config FILE] start the node
111
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
112
113
 
113
114
  --config, -c path to a JSON config file
114
115
  --port, -p override the listen port
@@ -135,6 +136,20 @@ PMTILES_SWARM_PUBLIC_URL
135
136
  return;
136
137
  }
137
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
+
138
153
  if (positionals.length > 0) {
139
154
  console.error(`unknown command: ${positionals[0]}`);
140
155
  console.error('try: pmtiles-swarm status');
@@ -245,6 +260,42 @@ PMTILES_SWARM_PUBLIC_URL
245
260
  ? null
246
261
  : new TileStats({ recent: config.tileStats?.recent });
247
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
+
248
299
  const tiles = new TileStore({ catalog, engine, config });
249
300
  library.attachTiles(tiles);
250
301
  const warm = new WarmRunner(tiles);
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
- const hex = Buffer.from(publicKey).toString('hex');
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
 
@@ -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
+ }
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;