pmtiles-swarm 0.2.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.
@@ -0,0 +1,218 @@
1
+ # Serving tiles
2
+
3
+ Every archive in the catalog is also a tile endpoint. A map can point straight at
4
+ this server — no separate tile server, no unpacking the archive.
5
+
6
+ ```
7
+ GET /archives/{infohash}/tiles.json TileJSON 3.0.0
8
+ GET /archives/{infohash}/{z}/{x}/{y}.{ext} one tile
9
+ GET /archives/{infohash}/archive.torrent the .torrent
10
+ ```
11
+
12
+ `{ext}` must match what the archive holds: `pbf` or `mvt` for vector, `png`,
13
+ `jpg`, `webp`, `avif` for raster. Asking for the wrong one is a 400 rather than
14
+ a silently wrong content type.
15
+
16
+ Drop the TileJSON URL into any client that speaks TileJSON:
17
+
18
+ ```js
19
+ map.addSource('basemap', {
20
+ type: 'vector',
21
+ url: 'https://swarm.example.org/archives/913d671f…/tiles.json',
22
+ });
23
+ ```
24
+
25
+ ## Where the bytes come from
26
+
27
+ This is the part that makes serving tiles from a torrent client worth doing at
28
+ all. The endpoint behaves the same either way, but underneath there are two very
29
+ different paths:
30
+
31
+ | This node | Reads from | Cost |
32
+ | --- | --- | --- |
33
+ | Holds a complete copy | The local file, directly | Disk, no swarm involvement |
34
+ | In cache mode | The swarm, one piece at a time | Only the pieces tiles are actually in |
35
+
36
+ A cache-mode node holds almost none of a 700 GiB planet archive but can still
37
+ answer for any tile in it. The first request for a cold area pulls the pieces
38
+ that tile lives in, and those pieces stay — both in the piece cache and in the
39
+ engine's store, where they are seeded back to the swarm. Reading an archive makes
40
+ you a better peer for it.
41
+
42
+ Which path is used is decided per archive, from the engine's own progress rather
43
+ than the file size on disk: both engines preallocate the full file, so a torrent
44
+ one piece in already looks complete by size.
45
+
46
+ `GET /api/torrents/{infohash}` reports which path an open archive is using.
47
+
48
+ ### Engine support
49
+
50
+ Cache-mode reads need piece-level control, which not every engine has:
51
+
52
+ | Engine | Complete copy | Cache mode |
53
+ | --- | --- | --- |
54
+ | libtorrent | yes | yes — piece deadlines and per-piece priorities |
55
+ | webtorrent | yes | yes — shares the seeding client |
56
+ | qBittorrent | yes | **no** |
57
+
58
+ qBittorrent's WebUI has per-file priorities but nothing per piece and no way to
59
+ read one back, so there is no honest way to serve a tile from an archive it holds
60
+ only part of. It returns 501 with an explanation rather than a generic failure.
61
+ That is rarely a problem in practice, since qBittorrent is normally the bulk
62
+ seeder and a bulk seeder holds complete copies.
63
+
64
+ Both peer-to-peer engines reuse the client already seeding the archive rather
65
+ than starting a second one. One peer pool, one port, one DHT node — and for
66
+ libtorrent, one sidecar process.
67
+
68
+ ## The `torrent` block
69
+
70
+ TileJSON documents from this server carry a non-standard `torrent` member:
71
+
72
+ ```json
73
+ {
74
+ "tilejson": "3.0.0",
75
+ "tiles": ["https://swarm.example.org/archives/913d…/{z}/{x}/{y}.pbf"],
76
+ "minzoom": 0,
77
+ "maxzoom": 14,
78
+ "vector_layers": [{ "id": "water" }],
79
+ "torrent": {
80
+ "infohash": "913d671f3a28c5b8d605e28cf6bf01e293d36e86",
81
+ "magnet": "magnet:?xt=urn:btih:913d671f…",
82
+ "torrent": "https://swarm.example.org/archives/913d…/archive.torrent",
83
+ "name": "planet.pmtiles",
84
+ "size": 77242531840,
85
+ "webseeds": ["https://maps.example.org/planet.pmtiles"]
86
+ }
87
+ }
88
+ ```
89
+
90
+ This is progressive enhancement, and the reason it is shaped this way:
91
+
92
+ - **A plain client ignores it.** TileJSON permits unknown members and MapLibre's
93
+ style-spec permits arbitrary source properties, so maplibre-gl-js, Leaflet and
94
+ anything else fetch tiles over HTTP exactly as they would from any tile server.
95
+ - **A torrent-aware client uses it.** It joins the swarm and reads tiles from
96
+ pieces, falling back to the HTTP URLs whenever the swarm cannot answer.
97
+
98
+ One URL serves both, so a style does not have to know which kind of client will
99
+ load it. That also means a torrent-aware client gets a working map immediately
100
+ over HTTP while the swarm is still finding peers, rather than staring at an empty
101
+ canvas for the 90 to 240 seconds a cold magnet can take to resolve metadata.
102
+
103
+ For an archive published as a mutable torrent, the block also carries
104
+ `mutable.publicKey`, so a client that understands BEP 46 can follow updates
105
+ rather than pinning to the version the document was generated from. See
106
+ [publishing](publishing.md).
107
+
108
+ ## Caching
109
+
110
+ Tiles are served `Cache-Control: public, max-age=31536000, immutable`.
111
+
112
+ That is safe rather than optimistic. An infohash is a content hash: the bytes
113
+ under `/archives/{infohash}/…` cannot change, because different bytes would be a
114
+ different infohash. When a mutable archive is updated the infohash changes, so
115
+ the URLs change with it and caches never need invalidating — the old ones simply
116
+ stop being referenced.
117
+
118
+ The TileJSON itself is not cached that way, since it is the document that points
119
+ at the current infohash.
120
+
121
+ ## Configuration
122
+
123
+ ```json
124
+ {
125
+ "tiles": {
126
+ "maxOpenArchives": 16,
127
+ "directoryCacheEntries": 200,
128
+ "pieceCacheBytes": null,
129
+ "hydrateIdleMs": null,
130
+ "pieceTimeoutMs": 120000,
131
+ "readyTimeoutMs": 60000
132
+ }
133
+ }
134
+ ```
135
+
136
+ ## Warming a region
137
+
138
+ A cache-mode node is slow exactly once per region: the first request pulls the
139
+ pieces those tiles live in, and everything after is served from what it now
140
+ holds. Warming moves that cost off the request path, which matters most just
141
+ before adding a node to a load-balanced pool.
142
+
143
+ ```sh
144
+ curl -X POST http://node:8090/api/torrents/$INFOHASH/warm \
145
+ -H 'content-type: application/json' \
146
+ -d '{"bounds": [5.9, 45.8, 10.5, 47.8], "minZoom": 0, "maxZoom": 12}'
147
+ ```
148
+
149
+ Everything is optional. Without `bounds` it uses the archive's own; without a
150
+ zoom range it warms from the archive's minimum up a handful of levels, because
151
+ tile counts quadruple per level and warming to z14 globally is never what was
152
+ meant. The zoom range is clamped to what the archive actually holds, so asking
153
+ for more than exists costs nothing.
154
+
155
+ ```
156
+ GET /api/torrents/{infohash}/warm progress
157
+ DELETE /api/torrents/{infohash}/warm cancel
158
+ ```
159
+
160
+ Progress reports `total`, `done`, `hits`, `misses` and `errors`. Misses are
161
+ normal — a bounding box over a sparse archive covers tiles that were never
162
+ generated. A job that fails every tile without a single success gives up early
163
+ rather than grinding through the region to prove the archive is unreadable.
164
+
165
+ `maxTiles` caps a job (5000 by default) and `concurrency` sets how many tiles are
166
+ in flight (4 by default). Raising concurrency helps when the bottleneck is swarm
167
+ latency rather than bandwidth.
168
+
169
+ **Warming a mirror node does nothing useful** — it already holds everything and
170
+ reads its local file. The endpoint still works; it just finishes almost
171
+ immediately.
172
+
173
+ **Warming is cheaper on the second node.** Every node in the serving tier is a
174
+ peer in the same swarm, so a node warming a region a sibling already holds
175
+ fetches it from that sibling rather than from the original seed. Local service
176
+ discovery is on by default, so nodes on the same subnet find each other with no
177
+ configuration.
178
+
179
+ ### Absolute URLs behind a proxy
180
+
181
+ TileJSON contains absolute tile URLs, so the server has to know how it is reached:
182
+
183
+ ```json
184
+ {
185
+ "publicUrl": "https://maps.example.org",
186
+ "trustProxy": "loopback, 10.0.0.0/8"
187
+ }
188
+ ```
189
+
190
+ `publicUrl` pins one canonical URL. Leave it unset and set `trustProxy` instead
191
+ to derive the URL per request from `X-Forwarded-Proto` and `X-Forwarded-Host`,
192
+ which lets one node serve `https://maps.example.org` and `http://maps.internal`
193
+ correctly at the same time.
194
+
195
+ Set at least one of them behind a TLS-terminating proxy. Otherwise the TileJSON
196
+ advertises `http://` tile URLs, and a browser that loaded the map over `https`
197
+ blocks them all as mixed content.
198
+
199
+ ### Tuning
200
+
201
+ `maxOpenArchives` bounds how many archives are held open at once; the least
202
+ recently used is closed when the limit is passed, and reopened transparently if
203
+ asked for again. Each open archive costs a file descriptor or a torrent reader
204
+ plus its piece cache.
205
+
206
+ Leave `pieceCacheBytes` unset unless you have a reason. It is then sized from the
207
+ torrent's piece length, which is the safer default — a fixed byte budget is a
208
+ trap with 16 MiB pieces, since 64 MiB holds only four of them.
209
+
210
+ ## What this is not
211
+
212
+ This is a tile endpoint for archives this node distributes, not a general tile
213
+ server. It does not render raster tiles from vector data, compose styles, serve
214
+ fonts or sprites, or reproject. For any of that, point
215
+ [tileserver-gl](https://github.com/maptiler/tileserver-gl) at the archive — it
216
+ can read PMTiles from a torrent directly, using the same
217
+ [pmtiles-torrent](https://github.com/TechIdiots-LLC/pmtiles-torrent) package this
218
+ does.
@@ -0,0 +1,187 @@
1
+ # Subscribing and distribution
2
+
3
+ How a second node follows a first, and what it costs.
4
+
5
+ ## A two-node setup
6
+
7
+ ### The publisher
8
+
9
+ Watches a build directory, creates torrents, publishes a feed.
10
+
11
+ ```json
12
+ {
13
+ "port": 8090,
14
+ "engine": "libtorrent",
15
+ "publicUrl": "https://maps.example.org",
16
+ "libtorrent": { "savePath": "/mnt/maps/generated" },
17
+ "watch": [
18
+ {
19
+ "path": "/mnt/maps/generated",
20
+ "category": "basemaps",
21
+ "webSeedBase": "https://maps.example.org/files"
22
+ }
23
+ ]
24
+ }
25
+ ```
26
+
27
+ `publicUrl` matters: feed items carry absolute links, and a subscriber that cannot
28
+ resolve them cannot fetch the `.torrent`. `webSeedBase` matters just as much — it makes
29
+ each archive usable before it has any peers at all.
30
+
31
+ Its feed is then at `https://maps.example.org/feed.xml`, with per-category feeds at
32
+ `/feed/basemaps.xml`.
33
+
34
+ ### The subscriber
35
+
36
+ ```json
37
+ {
38
+ "port": 8090,
39
+ "engine": "libtorrent",
40
+ "libtorrent": { "savePath": "/var/lib/maps" },
41
+ "subscriptions": [
42
+ { "url": "https://maps.example.org/feed.xml", "mode": "cache" }
43
+ ],
44
+ "subscriptionIntervalSeconds": 900
45
+ }
46
+ ```
47
+
48
+ Poll immediately rather than waiting for the interval:
49
+
50
+ ```sh
51
+ curl -X POST localhost:8090/api/subscriptions/refresh
52
+ ```
53
+
54
+ ## mirror or cache
55
+
56
+ The choice that decides what a subscriber costs.
57
+
58
+ | Mode | Disk | The node becomes |
59
+ | --- | --- | --- |
60
+ | `mirror` | the whole archive | A full seeder — redundancy for the swarm |
61
+ | `cache` | only what is read | A tile server that pays for what people look at |
62
+
63
+ **Cache mode is the interesting one.** The node joins the swarm and downloads nothing.
64
+ A tile server reads byte ranges from it on demand through `pmtiles-torrent`, so disk use
65
+ tracks what users actually view rather than what exists — which is what makes a 700 GiB
66
+ planet archive serveable from a small VPS. The node still seeds whatever pieces it picked
67
+ up along the way, so a cache-mode subscriber is a contributing swarm member, not a
68
+ freeloader.
69
+
70
+ Mirror mode is what you want on at least one or two nodes per archive, so the swarm does
71
+ not depend on a single origin.
72
+
73
+ ### Filtering
74
+
75
+ One feed can serve subscribers with different appetites:
76
+
77
+ ```json
78
+ {
79
+ "subscriptions": [
80
+ { "url": "https://maps.example.org/feed.xml", "mode": "mirror", "filter": "europe" },
81
+ { "url": "https://maps.example.org/feed.xml", "mode": "cache", "category": "planet" }
82
+ ]
83
+ }
84
+ ```
85
+
86
+ `filter` is a case-insensitive regular expression matched against the item title.
87
+
88
+ ## Subscribing with plain qBittorrent
89
+
90
+ The feed is ordinary RSS 2.0 with `application/x-bittorrent` enclosures, which is exactly
91
+ what qBittorrent's built-in RSS auto-downloader consumes. **An operator can follow a
92
+ pmtiles-swarm feed today with no new software** — RSS tab, add the URL, set an
93
+ auto-download rule.
94
+
95
+ They lose cache mode, since that needs piece-level control, but for a node that wants a
96
+ full mirror it works out of the box.
97
+
98
+ ## How many items a feed carries
99
+
100
+ ```json
101
+ { "feedMaxItems": 50 }
102
+ ```
103
+
104
+ Newest first; `0` means no limit. A consumer can also ask for a different number with
105
+ `?limit=`, so one publisher can serve subscribers with different poll intervals from the
106
+ same catalog:
107
+
108
+ ```
109
+ /feed.xml the configured default
110
+ /feed.xml?limit=1 newest build only
111
+ /feed.xml?limit=0 everything
112
+ ```
113
+
114
+ **Choose it against how often subscribers poll, not how tidy the feed looks.** A feed
115
+ holding a single item is only safe if everyone polls more often than you publish. If a
116
+ consumer is down overnight and you publish daily, that build drops off the feed before it
117
+ is seen — and nothing indicates it was missed. With a daily build and subscribers polling
118
+ every 15 minutes, 50 items is roughly seven weeks of slack.
119
+
120
+ The same applies to `lookbackDays` on scheduled sources, for the same reason and in the
121
+ other direction.
122
+
123
+ ## What the feed carries
124
+
125
+ Beyond the standard fields, items carry a namespaced description of the map:
126
+
127
+ ```xml
128
+ <item>
129
+ <title>planetiler-openmaptiles-latest.pmtiles</title>
130
+ <enclosure url="https://maps.example.org/api/torrents/5e1c…/file"
131
+ length="77230486744" type="application/x-bittorrent"/>
132
+ <pmtiles:infohash>5e1c143c400d15aaacfb1c748d4ab6d1b46c5df5</pmtiles:infohash>
133
+ <pmtiles:magnet>magnet:?xt=urn:btih:5e1c…</pmtiles:magnet>
134
+ <pmtiles:format>pbf</pmtiles:format>
135
+ <pmtiles:minzoom>0</pmtiles:minzoom>
136
+ <pmtiles:maxzoom>14</pmtiles:maxzoom>
137
+ <pmtiles:bounds>-180,-85.05,180,85.05</pmtiles:bounds>
138
+ </item>
139
+ ```
140
+
141
+ This is the part a generic torrent feed cannot offer. A subscriber can decide whether it
142
+ wants a 72 GiB download from the feed alone, rather than fetching metadata to find out.
143
+ Generic clients ignore the namespace and still work.
144
+
145
+ ## Updatable torrents (BEP 46)
146
+
147
+ A rebuilt archive is a different torrent, because the infohash is a hash of its content.
148
+ BEP 46 adds a level of indirection: an ed25519-signed record in the DHT whose value names
149
+ the *current* infohash, addressed by public key. Subscribers follow the key and are
150
+ carried across rebuilds.
151
+
152
+ ```js
153
+ import { generatePublisherKey, mutableMagnet, publishInfoHash } from 'pmtiles-swarm/mutable';
154
+
155
+ const key = generatePublisherKey(); // back this up — it is the archive's identity
156
+ const magnet = mutableMagnet(key.publicKey, { name: 'planet.pmtiles' });
157
+ // magnet:?xs=urn:btpk:200d26e8… — note: no infohash
158
+
159
+ await publishInfoHash(dht, key, currentInfoHash);
160
+ ```
161
+
162
+ The public key is the permanent address; losing the private key means subscribers can
163
+ never be moved forward again.
164
+
165
+ RSS and BEP 46 fail in opposite ways, which is the argument for publishing both:
166
+
167
+ | | Needs | Fails when |
168
+ | --- | --- | --- |
169
+ | RSS | a server that stays up | the server goes away |
170
+ | BEP 46 | periodic republishing | the record expires (hours, not days) |
171
+
172
+ **Status:** the crypto and magnet handling are tested — 32-byte keys, 64-byte signatures
173
+ verifying, roundtrip stable. Publishing and resolving against a live DHT, and interop
174
+ with libtorrent's exact value encoding, are **not yet verified**.
175
+
176
+ ## Making the archives serveable
177
+
178
+ A subscriber holding archives is only useful if something serves tiles from them. Point
179
+ tileserver-gl at the same torrents:
180
+
181
+ ```sh
182
+ export PMTILES_TORRENT_PATH=/var/lib/maps
183
+ tileserver-gl --file /var/lib/maps/torrents/5e1c….torrent
184
+ ```
185
+
186
+ Both processes then share the data directory: pmtiles-swarm manages membership and the
187
+ catalog, tileserver-gl reads ranges on demand and seeds what it fetches.
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "pmtiles-swarm",
3
+ "version": "0.2.0",
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
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "bin": {
8
+ "pmtiles-swarm": "src/index.js"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "docs",
13
+ "NOTICE.md",
14
+ "CHANGELOG.md"
15
+ ],
16
+ "scripts": {
17
+ "start": "node src/index.js",
18
+ "test": "node --test test/*.test.js"
19
+ },
20
+ "keywords": [
21
+ "pmtiles",
22
+ "bittorrent",
23
+ "torrent",
24
+ "map",
25
+ "tiles",
26
+ "rss",
27
+ "p2p",
28
+ "distribution"
29
+ ],
30
+ "license": "BSD-3-Clause",
31
+ "dependencies": {
32
+ "chokidar": "^5.0.0",
33
+ "create-torrent": "^6.1.0",
34
+ "express": "^5.2.1",
35
+ "parse-torrent": "^11.0.24",
36
+ "pmtiles": "^4.4.1",
37
+ "pmtiles-torrent": "^0.1.1"
38
+ },
39
+ "optionalDependencies": {
40
+ "webtorrent": "^3.0.21"
41
+ },
42
+ "engines": {
43
+ "node": "^22.13.0 || 24"
44
+ },
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/TechIdiots-LLC/pmtiles-swarm.git"
48
+ },
49
+ "bugs": {
50
+ "url": "https://github.com/TechIdiots-LLC/pmtiles-swarm/issues"
51
+ },
52
+ "homepage": "https://github.com/TechIdiots-LLC/pmtiles-swarm#readme",
53
+ "publishConfig": {
54
+ "access": "public"
55
+ }
56
+ }