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,140 @@
1
+ # Seeding engines
2
+
3
+ pmtiles-swarm does not implement BitTorrent. Seeding is delegated to an engine behind a
4
+ small interface ([`src/engines/types.js`](../src/engines/types.js)), so the part that is
5
+ genuinely ours — the catalog, torrent creation, feeds, watch folders — stays independent
6
+ of it.
7
+
8
+ Three exist. Pick by what the node is for.
9
+
10
+ | | libtorrent | qBittorrent | WebTorrent |
11
+ | --- | --- | --- | --- |
12
+ | Runs as | sidecar process | your existing instance | in-process |
13
+ | Extra install | `python3-libtorrent` | qBittorrent | none |
14
+ | BitTorrent v2 | yes | yes | **no** |
15
+ | Creates hybrid v1+v2 | yes | via its own UI | no |
16
+ | Piece-level control | yes | **no** | yes |
17
+ | Proper cache mode | yes | approximated | yes |
18
+ | Serves browser peers | no | no | **yes** |
19
+ | Bulk seeding at TB scale | yes | yes | weaker |
20
+
21
+ ## libtorrent (recommended)
22
+
23
+ The most capable: BitTorrent v2 and hybrid torrents, resume data so a restart does not
24
+ re-hash the store, piece-level control, and seeding that holds at multi-terabyte scale.
25
+
26
+ ```json
27
+ {
28
+ "engine": "libtorrent",
29
+ "libtorrent": {
30
+ "savePath": "./data/torrents-data",
31
+ "resumeDir": "./data/resume",
32
+ "listen": "0.0.0.0:6881",
33
+ "python": "python3"
34
+ }
35
+ }
36
+ ```
37
+
38
+ Install libtorrent's Python bindings:
39
+
40
+ ```sh
41
+ apt install python3-libtorrent # Debian/Ubuntu
42
+ brew install libtorrent-rasterbar # macOS
43
+ pip install libtorrent # anywhere with wheels, incl. Windows
44
+ ```
45
+
46
+ ### Why a sidecar and not a native binding
47
+
48
+ Node has no maintained libtorrent binding. The npm packages (`libtorrent`,
49
+ `node-libtorrent`, `libtorrent-rasterbar`) are abandoned 2022 stubs, and the one live
50
+ fork ships no prebuilt binaries and exposes none of `set_piece_deadline`, `read_piece`,
51
+ v2, or resume data — which are the four reasons to want libtorrent at all.
52
+
53
+ A binding would also need libtorrent headers, Boost and a compiler on every machine,
54
+ where the sidecar needs one distro package. The protocol is line-delimited JSON over
55
+ stdin/stdout, so if a proper N-API addon with prebuilt binaries appears later, only the
56
+ far side of the pipe changes — [`src/engines/libtorrent.js`](../src/engines/libtorrent.js)
57
+ stays as it is.
58
+
59
+ ## qBittorrent
60
+
61
+ Drives an instance you already run, over its WebUI API. Choose it when you have an
62
+ existing library you do not want to disturb: `POST /api/adopt` imports everything it
63
+ already seeds without re-hashing a byte.
64
+
65
+ ```json
66
+ {
67
+ "engine": "qbittorrent",
68
+ "qbittorrent": {
69
+ "url": "http://127.0.0.1:8080",
70
+ "username": "admin",
71
+ "password": "…"
72
+ }
73
+ }
74
+ ```
75
+
76
+ Omit `username` if the WebUI bypasses authentication for your subnet.
77
+
78
+ **Its limitation is cache mode.** That needs piece-level selection, and the WebUI exposes
79
+ only per-file priorities — which for a single-file archive is all or nothing. Cache mode
80
+ therefore adds the torrent stopped and leaves on-demand reads to a client that can do
81
+ them, such as a tileserver-gl instance using `pmtiles-torrent`.
82
+
83
+ No qBittorrent code is used here; this is an HTTP client for its documented API. See
84
+ [NOTICE.md](../NOTICE.md) for why that distinction matters.
85
+
86
+ ## WebTorrent
87
+
88
+ Self-contained, no external dependency, and the **only** engine that can serve browser
89
+ peers — browsers speak WebRTC, conventional clients speak TCP/uTP, and they cannot see
90
+ each other directly. A WebTorrent node is what bridges the two halves of a swarm.
91
+
92
+ ```json
93
+ {
94
+ "engine": "webtorrent",
95
+ "webtorrent": { "savePath": "./data/torrents-data" }
96
+ }
97
+ ```
98
+
99
+ It is BitTorrent v1 only (no BEP 52) and a weaker bulk seeder than libtorrent. For a
100
+ multi-terabyte library, prefer libtorrent and run WebTorrent alongside as the browser
101
+ bridge.
102
+
103
+ ### A required dependency pin
104
+
105
+ `package.json` pins `uint8-util` to `2.2.5` through `overrides`. Version 2.3.0 rewrote
106
+ `arr2hex` as `Buffer.from(data.buffer, …)`, which throws on the hex-string infohash that
107
+ webtorrent's `Torrent._onTorrentId` passes it — breaking **every magnet add**. webtorrent
108
+ declares `^2.2.5`, so a minor bump silently breaks it. Remove the pin once webtorrent
109
+ fixes the call site.
110
+
111
+ ## Cache mode across engines
112
+
113
+ Joining a torrent defaults to `cache`, not `mirror`: committing a disk to a copy of
114
+ something that may be hundreds of gigabytes should be a decision, not a side effect.
115
+
116
+ | Mode | Disk | Purpose |
117
+ | --- | --- | --- |
118
+ | `mirror` | the whole archive | Full seeder, adds redundancy |
119
+ | `cache` | only what is read | Serving tiles from a huge archive on a small disk |
120
+
121
+ Implementing cache mode correctly is subtler than it looks. Two bugs found while testing
122
+ the libtorrent engine, both of which would have silently broken it:
123
+
124
+ - It must set **file priorities to 0**, not libtorrent's `upload_mode` flag. Upload mode
125
+ refuses to download anything at all, which defeats the purpose — `read_piece` raises an
126
+ individual piece back to priority 7 to fetch it on demand, and priority 0 permits that
127
+ where upload mode does not.
128
+ - A torrent with nothing wanted looks idle to libtorrent's auto-manager, which **pauses
129
+ it** — and a paused torrent stops seeding, so the node would have joined the swarm and
130
+ contributed nothing. Cache-mode torrents are added with auto-management off.
131
+
132
+ A correctly configured cache-mode node reports state `cache`, holds zero bytes until
133
+ something reads from it, and still serves whatever pieces it has picked up.
134
+
135
+ ## Writing another engine
136
+
137
+ Implement `connect`, `add`, `remove`, `list`, `get`, `destroy`, and optionally `peers`.
138
+ The interface is deliberately small; see
139
+ [`src/engines/types.js`](../src/engines/types.js) for the contract and
140
+ [`src/engines/webtorrent.js`](../src/engines/webtorrent.js) for the shortest example.
@@ -0,0 +1,387 @@
1
+ # Publishing archives
2
+
3
+ How an archive gets into the swarm, and the decisions that matter when it does.
4
+
5
+ ## The four ways in
6
+
7
+ | Input | What happens | Hashes? |
8
+ | --- | --- | --- |
9
+ | Local `.pmtiles` path | A torrent is created; the data stays where it is | Yes |
10
+ | Remote `.pmtiles` URL | Streamed past the hasher; origin becomes a web seed | Yes |
11
+ | Magnet or `.torrent` | Joined — nothing is created | No |
12
+ | `POST /api/adopt` | Everything the engine already seeds is imported | No |
13
+
14
+ Publishers create; everyone else joins. `adopt` is the migration path for an existing
15
+ library — it re-hashes nothing, so bringing across 50 torrents is instant.
16
+
17
+ ```bash
18
+ # Create from a local archive
19
+ curl -X POST localhost:8090/api/torrents -H 'content-type: application/json' \
20
+ -d '{"path": "/mnt/maps/planet.pmtiles", "category": "basemaps"}'
21
+
22
+ # Join an existing torrent
23
+ curl -X POST localhost:8090/api/torrents -H 'content-type: application/json' \
24
+ -d '{"magnet": "magnet:?xt=urn:btih:5e1c..."}'
25
+
26
+ # Upload a .torrent directly
27
+ curl -X POST localhost:8090/api/torrents \
28
+ -H 'content-type: application/x-bittorrent' --data-binary @planet.pmtiles.torrent
29
+
30
+ # Import what your torrent client already holds
31
+ curl -X POST localhost:8090/api/adopt
32
+ ```
33
+
34
+ ## Web seeds
35
+
36
+ **A web seed is an HTTP URL baked into the torrent that serves the same bytes** (BEP 19,
37
+ stored as `url-list`). Peers fetch from the swarm when peers exist and fall back to HTTP
38
+ when they do not.
39
+
40
+ For map distribution this solves the problem that otherwise makes a new torrent useless:
41
+ a freshly published archive has zero seeders, so without a web seed nobody can get it
42
+ until somebody already has it. With one, it works from the moment it is published and
43
+ simply gets cheaper as peers appear.
44
+
45
+ Web seeds are added automatically wherever the source implies one:
46
+
47
+ - **Adding from a URL** — the origin is registered by construction, since it serves the
48
+ exact bytes being hashed.
49
+ - **Watch folders** — set `webSeedBase` on the folder and each imported archive gets
50
+ `<webSeedBase>/<filename>`.
51
+ - **Explicitly** — pass `webSeeds` when adding.
52
+
53
+ They land in both the `.torrent` and the magnet:
54
+
55
+ ```
56
+ urlList in .torrent : ["https://maps.example.org/files/planet.pmtiles"]
57
+ magnet : magnet:?xt=urn:btih:333d...&dn=planet.pmtiles&ws=https%3A%2F%2F...
58
+ ```
59
+
60
+ If your archives are already on a web server or in S3, always pass the URL. It costs
61
+ nothing and turns cold start from a dead end into an HTTP fallback.
62
+
63
+ ## Adding from a URL: keep or discard
64
+
65
+ Piece hashes are computed over content, so **there is no way to create a torrent from a
66
+ remote archive without reading every byte of it**. What you choose is whether those bytes
67
+ are kept.
68
+
69
+ ```bash
70
+ # Keep (default): downloads to savePath, node becomes a seeder
71
+ curl -X POST localhost:8090/api/torrents -H 'content-type: application/json' \
72
+ -d '{"url": "https://maps.example.org/files/planet.pmtiles"}'
73
+
74
+ # Discard: hashes in a single pass, keeps nothing
75
+ curl -X POST localhost:8090/api/torrents -H 'content-type: application/json' \
76
+ -d '{"url": "https://maps.example.org/files/planet.pmtiles", "retain": false}'
77
+ ```
78
+
79
+ | | Disk | Bandwidth | Result |
80
+ | --- | --- | --- | --- |
81
+ | **keep** (default) | full archive | full archive, once | A real seeder immediately |
82
+ | **discard** | none | full archive, once | A torrent this node cannot seed |
83
+
84
+ Discard is legitimate when you already host the archive and only want to publish a
85
+ torrent for it — the web seed carries the swarm until a peer completes a copy. But it is
86
+ a poor default, because a torrent nobody seeds is HTTP with extra steps.
87
+
88
+ Either way, expect this to take as long as transferring the archive once. Progress is
89
+ logged as `[fetch] <url> NN%`.
90
+
91
+ ## Piece size
92
+
93
+ Read amplification is `pieceLength ÷ bytesWanted`. Creation tools size pieces for
94
+ whole-file downloads, so large archives commonly end up at 16 MiB — where a cold 4 KB
95
+ vector tile costs a 16 MiB download.
96
+
97
+ The default here is **4 MiB**, trading a larger hash list for a quarter of the
98
+ amplification. Below about 1 MiB the hash list itself — which peers must transfer via
99
+ BEP 9 before any tile can be served — starts to dominate.
100
+
101
+ It is overridable at three levels, so the default never boxes you in:
102
+
103
+ ```json
104
+ // globally
105
+ { "pieceLength": 4194304 }
106
+
107
+ // per scheduled source
108
+ { "sources": [{ "name": "…", "pieceLength": 16777216 }] }
109
+ ```
110
+
111
+ ```bash
112
+ # per request
113
+ curl -X POST localhost:8090/api/torrents -H 'content-type: application/json' \
114
+ -d '{"path": "/mnt/maps/planet.pmtiles", "pieceLength": 16777216}'
115
+ ```
116
+
117
+ This only matters if the archive will be read *randomly*, as a tile server does. An
118
+ archive that will only ever be downloaded whole is fine with the 16 MiB other tools
119
+ default to, and pays a smaller hash list for it.
120
+
121
+ ### Piece size and network equipment
122
+
123
+ Larger pieces are sometimes assumed to be gentler on routers. They mostly are not:
124
+ **peers request data in 16 KiB blocks regardless of piece size**, so packet volume for the
125
+ same bytes transferred is identical at 4 MiB and 16 MiB. What smaller pieces genuinely
126
+ increase is the hash list, per-piece bookkeeping in the client, and `HAVE` message
127
+ frequency — one per completed piece per peer, at nine bytes each.
128
+
129
+ What actually strains consumer hardware is the number of **simultaneous connections**,
130
+ because each is a NAT table entry and cheap routers exhaust those long before bandwidth
131
+ becomes the limit. That is a different setting:
132
+
133
+ ```json
134
+ { "maxConnections": 100 }
135
+ ```
136
+
137
+ Lower it — 50, or 30 — if the network misbehaves while seeding. It applies to both the
138
+ libtorrent and WebTorrent engines. Reach for that before compromising on piece size.
139
+
140
+ ## BitTorrent v2
141
+
142
+ The `libtorrent` engine creates **hybrid v1+v2 torrents** by default. v2 (BEP 52) adds
143
+ per-file merkle trees with 16 KiB leaf blocks, so a peer can verify a small block without
144
+ holding the entire hash list — which matters precisely for the random-access reads a tile
145
+ server does. The v1 half keeps every existing client working.
146
+
147
+ Neither `mktorrent` nor `create-torrent` can produce these; only libtorrent can. Verified
148
+ against libtorrent 2.0.13, the same archive yields three distinct torrents:
149
+
150
+ | Format | `.torrent` size |
151
+ | --- | --- |
152
+ | `hybrid` (default) | 415 B — carries both hash sets |
153
+ | `v1` | 274 B |
154
+ | `v2` | 371 B |
155
+
156
+ ## Watch folders
157
+
158
+ A new `.pmtiles` appearing in a watched folder is imported automatically.
159
+
160
+ ```json
161
+ {
162
+ "watch": [
163
+ {
164
+ "path": "/mnt/maps/generated",
165
+ "category": "basemaps",
166
+ "webSeedBase": "https://maps.example.org/files",
167
+ "stabilitySeconds": 30
168
+ }
169
+ ]
170
+ }
171
+ ```
172
+
173
+ `stabilitySeconds` is the important one. A map build writes its output over minutes or
174
+ hours, and hashing a half-written archive produces a torrent for bytes that no longer
175
+ exist. Nothing is imported until the file has stopped changing for that long. Raise it if
176
+ your archives arrive over a network copy that can stall mid-file.
177
+
178
+ ## Scheduled upstreams
179
+
180
+ Some upstreams publish a **new URL per build** rather than overwriting one. Protomaps does
181
+ this: `https://build.protomaps.com/20260806.pmtiles`, a new date every day, with older
182
+ builds left exactly as they were.
183
+
184
+ Watching for change never fires on that — nothing changes. What is needed is to work out
185
+ today's URL and see whether it exists yet:
186
+
187
+ ```json
188
+ {
189
+ "sourceCheckIntervalHours": 6,
190
+ "sources": [
191
+ {
192
+ "name": "planetiler-protomaps",
193
+ "url": "https://build.protomaps.com/{YYYYMMDD}.pmtiles",
194
+ "filename": "planetiler-protomaps-{YYYYMMDD}.pmtiles",
195
+ "offsetDays": -1,
196
+ "lookbackDays": 3,
197
+ "savePath": "/mnt/hd-16TB/store/generated/protomaps",
198
+ "latestLink": "planetiler-protomaps-latest.pmtiles",
199
+ "category": "planet",
200
+ "comment": "Planetiler protomaps data export",
201
+ "pieceLength": 4194304
202
+ }
203
+ ]
204
+ }
205
+ ```
206
+
207
+ `url`
208
+ Template with `{YYYYMMDD}`, `{YYYY-MM-DD}`, `{YYYY}`, `{MM}` or `{DD}`.
209
+
210
+ `filename`
211
+ What to call it locally. Upstreams often publish under a bare date; this renames it to
212
+ something self-describing without touching the URL.
213
+
214
+ `offsetDays`
215
+ Most builds land the day after the date they are named for; `-1` looks for yesterday.
216
+
217
+ `lookbackDays`
218
+ Also check the preceding days. Without it, a poll missed while the daemon was down
219
+ loses that build permanently.
220
+
221
+ `latestLink`
222
+ A symlink pointing at the newest build. The dated file stays the real one, so it
223
+ remains seedable under its own torrent while consumers can reference a fixed path.
224
+
225
+ Each build becomes its own archive with its own torrent and its own lifetime, which is
226
+ what you want — old builds stay seedable for as long as anyone still wants them.
227
+
228
+ The origin URL is registered as a web seed automatically, and every candidate URL is
229
+ checked with a HEAD, so a build that has not been published yet costs one request.
230
+
231
+ ## When the source changes underneath you
232
+
233
+ A torrent describes a fixed set of bytes. If the file it was built from is later
234
+ replaced — a nightly build overwriting `planet-latest.pmtiles` — the torrent does not
235
+ become invalid, but three things go wrong:
236
+
237
+ 1. The catalog advertises content the source no longer has.
238
+ 2. **Any web seed pointing at that source now serves bytes that fail hash
239
+ verification.** Peers waste bandwidth on it and eventually ban it.
240
+ 3. If you were seeding from that file, the local copy no longer matches the torrent
241
+ either, so your node stops being a useful seeder.
242
+
243
+ Checking for this is cheap — one HEAD request per HTTP source, one `stat` per local file,
244
+ comparing ETag, Last-Modified and length. Nothing re-reads the archive.
245
+
246
+ ```bash
247
+ # Check one archive
248
+ curl -X POST localhost:8090/api/torrents/<infohash>/check
249
+
250
+ # Check everything with a source worth watching
251
+ curl -X POST localhost:8090/api/check-origins
252
+ ```
253
+
254
+ Or run it periodically:
255
+
256
+ ```json
257
+ { "originCheckIntervalSeconds": 3600 }
258
+ ```
259
+
260
+ It defaults to off, because a node that only joins other people's torrents has nothing to
261
+ check. A changed source marks the entry `stale` with the reason and logs a warning.
262
+
263
+ To publish the new content:
264
+
265
+ ```bash
266
+ curl -X POST localhost:8090/api/torrents/<infohash>/rebuild
267
+ ```
268
+
269
+ ### Rebuilding automatically
270
+
271
+ Rebuilds can also happen on their own, but it is opt-in and guarded, because an
272
+ unattended rebuild re-hashes the archive — and for a remote source re-downloads it.
273
+ Hours of transfer started by nobody is not a good default.
274
+
275
+ ```json
276
+ {
277
+ "originCheckIntervalSeconds": 3600,
278
+ "autoRebuild": {
279
+ "enabled": true,
280
+ "sources": ["file"],
281
+ "maxBytes": 53687091200,
282
+ "stabilitySeconds": 300
283
+ }
284
+ }
285
+ ```
286
+
287
+ `sources`
288
+ Which source types may be rebuilt unattended. Defaults to `["file"]` only: a local
289
+ rebuild costs a disk read, where adding `"http"` means re-downloading the entire
290
+ archive every time the origin changes.
291
+
292
+ `maxBytes`
293
+ Skip anything larger. Default 50 GiB; `0` disables the cap. This is the guard that
294
+ stops a planet archive from quietly consuming a day of I/O.
295
+
296
+ `stabilitySeconds`
297
+ The source must be unchanged for this long first. A build still writing its output
298
+ would otherwise be hashed mid-write — the same hazard watch folders guard against. If
299
+ the source moves again during the wait, the rebuild is deferred to the next check.
300
+
301
+ Rebuilds are serialised, so a sweep that finds five changed archives does not start five
302
+ concurrent multi-hour hashes.
303
+
304
+ ### Guarding against false positives
305
+
306
+ A modification time that moves while size and ETag stay put is weak evidence — a touch, a
307
+ restored backup, or a re-upload of identical bytes all do it. Before calling that a
308
+ change, the archive's own PMTiles header is re-read and compared against the stored
309
+ summary: tile count, zoom range, format and bounds. That costs a few kilobytes even
310
+ against a multi-terabyte archive, and identical values mean the bytes are almost certainly
311
+ the same.
312
+
313
+ This matters most with auto-rebuild on, where the cost of a false positive is a needless
314
+ re-hash rather than a spurious warning.
315
+
316
+ ### What it looks like
317
+
318
+ ```
319
+ [origin] planet.pmtiles no longer matches its source (last-modified … -> …; size 468 -> 466).
320
+ The torrent is still valid, but its web seed will now fail hash verification for peers.
321
+ [rebuild] planet.pmtiles: waiting 2s for the source to settle
322
+ [rebuild] planet.pmtiles: rebuilding from /mnt/maps/planet.pmtiles
323
+ [rebuild] planet.pmtiles: b8ee8216… -> 7afd1ea1…
324
+ ```
325
+
326
+ The old entry stays in the catalog marked `stale` and `superseded`, with `supersededBy`
327
+ naming its replacement, so anything still seeding it keeps working while subscribers move
328
+ across via the feed.
329
+
330
+ This mints a **new infohash** — content-addressing means there is no other possibility.
331
+ The old entry is kept and marked `superseded`, with `supersededBy` naming the replacement,
332
+ so anything still seeding the old torrent keeps working while subscribers move across via
333
+ the feed. A byte-identical rebuild produces the same infohash and changes nothing.
334
+
335
+ ### A fixed URL that gets rewritten
336
+
337
+ The other upstream shape: one permanent URL whose contents are replaced. Mapterhorn's
338
+ planet raster-DEM does this — `https://download.mapterhorn.com/planet.pmtiles`, 657 GiB,
339
+ rewritten when rebuilt. This is exactly what origin checking is for:
340
+
341
+ ```json
342
+ {
343
+ "originCheckIntervalSeconds": 21600,
344
+ "autoRebuild": { "enabled": true, "sources": ["http"], "maxBytes": 0 }
345
+ }
346
+ ```
347
+
348
+ It serves a strong S3 ETag and a `Last-Modified`, so a change is detected in one HEAD
349
+ request. Note two things before enabling auto-rebuild for it:
350
+
351
+ - **`maxBytes` must be raised or zeroed.** At 657 GiB it is far over the 50 GiB default,
352
+ so a rebuild would otherwise be skipped — which is the default behaving correctly.
353
+ - **A rebuild transfers 657 GiB.** Hashing is over content, so there is no cheaper way,
354
+ and it happens again on every upstream rebuild. Consider `"retain": false` so the
355
+ transfer is a single streaming pass with no disk cost, leaning on the web seed — the
356
+ origin is behind a CDN and serves `Accept-Ranges: bytes`, so it makes a good one.
357
+
358
+ ## Do not overwrite archives in place
359
+
360
+ If you are seeding an archive, replacing it in place breaks the torrent — the file no
361
+ longer matches the piece hashes, and your seeder silently stops being able to serve it.
362
+
363
+ Names like `planet-latest.pmtiles` invite exactly this. Prefer dated or versioned
364
+ filenames:
365
+
366
+ ```
367
+ planetiler-openmaptiles-260615.pmtiles ← seedable forever
368
+ planetiler-openmaptiles-260713.pmtiles ← new build, new torrent
369
+ ```
370
+
371
+ and, if you want a stable public URL, point `latest` at the newest as a **symlink or HTTP
372
+ redirect** rather than a file that gets rewritten. Each build then becomes its own torrent
373
+ with its own lifetime, old ones stay seedable for as long as anyone wants them, and the
374
+ feed (or a BEP 46 record) is what tells subscribers which is current.
375
+
376
+ ## Rebuilt archives
377
+
378
+ A rebuilt archive is a **different torrent** — the infohash is a hash of the content, so
379
+ there is no such thing as updating one in place. Two ways to carry subscribers across a
380
+ rebuild, which fail differently, so publishing both is cheap insurance:
381
+
382
+ - **RSS** — easy to consume, understood by existing clients, needs a server that stays up.
383
+ - **BEP 46** — an ed25519-signed DHT record naming the current infohash, addressed by
384
+ public key rather than infohash (`magnet:?xs=urn:btpk:…`). No server required, but the
385
+ record expires and must be republished.
386
+
387
+ See [subscribing.md](subscribing.md).