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.
package/src/tiles.js ADDED
@@ -0,0 +1,284 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import zlib from 'node:zlib';
4
+ import { PMTiles, SharedPromiseCache } from 'pmtiles';
5
+ import { TorrentSource } from 'pmtiles-torrent';
6
+ import { NodeFileSource } from './file-source.js';
7
+ import { LibtorrentReadEngine } from './read-engine.js';
8
+
9
+ /**
10
+ * Serves tiles out of the archives this node distributes.
11
+ *
12
+ * The point of doing this here rather than in a separate tile server is that
13
+ * the archive is already in the swarm. A node in cache mode holds almost none
14
+ * of a 72 GiB archive but can still answer for any tile in it, pulling the few
15
+ * pieces that tile lives in and keeping them for the next request. A node
16
+ * mirroring the archive reads its local copy directly and never involves the
17
+ * swarm at all. Same URL either way.
18
+ */
19
+
20
+ /** Formats that are already compressed; gzipping them again wastes CPU. */
21
+ const PRECOMPRESSED = new Set(['png', 'jpeg', 'webp', 'avif']);
22
+
23
+ /** Request extensions accepted for each archive format. */
24
+ const EXTENSIONS = {
25
+ pbf: ['pbf', 'mvt'],
26
+ png: ['png'],
27
+ jpeg: ['jpg', 'jpeg'],
28
+ webp: ['webp'],
29
+ avif: ['avif'],
30
+ mlt: ['mlt'],
31
+ };
32
+
33
+ /**
34
+ * An archive that could not be opened for reading, with the reason.
35
+ */
36
+ export class TileReadError extends Error {
37
+ /**
38
+ * @param {string} message - What went wrong.
39
+ * @param {number} status - HTTP status this maps onto.
40
+ */
41
+ constructor(message, status = 500) {
42
+ super(message);
43
+ this.name = 'TileReadError';
44
+ this.status = status;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Opens archives on demand and reads tiles out of them.
50
+ */
51
+ export class TileStore {
52
+ #catalog;
53
+ #engine;
54
+ #config;
55
+ #open = new Map();
56
+ // One header and directory cache across every archive. Entries are keyed by
57
+ // the source's key, so sharing it bounds total memory rather than letting
58
+ // each archive keep its own hundred entries.
59
+ #directoryCache;
60
+
61
+ /**
62
+ * @param {object} deps - Catalog, seeding engine and config.
63
+ */
64
+ constructor({ catalog, engine, config }) {
65
+ this.#catalog = catalog;
66
+ this.#engine = engine;
67
+ this.#config = config;
68
+ this.#directoryCache = new SharedPromiseCache(
69
+ config.tiles?.directoryCacheEntries ?? 200,
70
+ );
71
+ }
72
+
73
+ /**
74
+ * The extensions that map onto an archive's tile format.
75
+ * @param {string} format - Format name from the probe.
76
+ * @returns {string[]} - Accepted extensions.
77
+ */
78
+ static extensionsFor(format) {
79
+ return EXTENSIONS[format] ?? [];
80
+ }
81
+
82
+ /**
83
+ * Reads one tile.
84
+ * @param {string} infoHash - Which archive.
85
+ * @param {number} z - Zoom.
86
+ * @param {number} x - Column.
87
+ * @param {number} y - Row.
88
+ * @param {object} [options] - Abort signal.
89
+ * @returns {Promise<{data: Buffer, encoding?: string} | null>} - The tile, or null when absent.
90
+ */
91
+ async getTile(infoHash, z, x, y, options = {}) {
92
+ const entry = this.#catalog.get(infoHash);
93
+ if (!entry) throw new TileReadError('unknown archive', 404);
94
+
95
+ const handle = await this.#acquire(entry);
96
+ const tile = await handle.archive.getZxy(z, x, y, options.signal);
97
+ // A missing tile is not an error: sparse coverage is normal, and the
98
+ // caller turns this into a 204.
99
+ if (!tile?.data) return null;
100
+
101
+ const data = Buffer.from(tile.data);
102
+ const format = entry.pmtiles?.format;
103
+ if (!format || PRECOMPRESSED.has(format)) return { data };
104
+
105
+ // PMTiles decompresses tile data on the way out, so vector tiles arrive
106
+ // here as raw protobuf. Compressing them again is worth it — they are text
107
+ // heavy and typically a third of the size gzipped.
108
+ const gzipped = await new Promise((resolve, reject) =>
109
+ zlib.gzip(data, (error, out) => (error ? reject(error) : resolve(out))),
110
+ );
111
+ return { data: gzipped, encoding: 'gzip' };
112
+ }
113
+
114
+ /**
115
+ * Reports how an archive is currently being read, for diagnostics.
116
+ * @param {string} infoHash - Which archive.
117
+ * @returns {object | null} - Mode and stats, or null when not open.
118
+ */
119
+ status(infoHash) {
120
+ const handle = this.#open.get(infoHash);
121
+ if (!handle) return null;
122
+ return {
123
+ mode: handle.mode,
124
+ openedAt: handle.openedAt,
125
+ stats: handle.source?.stats,
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Closes every open archive.
131
+ * @returns {Promise<void>} - Resolves once closed.
132
+ */
133
+ async close() {
134
+ const handles = [...this.#open.values()];
135
+ this.#open.clear();
136
+ for (const handle of handles) await this.#release(handle);
137
+ }
138
+
139
+ /**
140
+ * Gets an open archive, opening it if needed and evicting the least recently
141
+ * used one when over budget.
142
+ * @param {object} entry - Catalog entry.
143
+ * @returns {Promise<object>} - The open handle.
144
+ */
145
+ async #acquire(entry) {
146
+ const existing = this.#open.get(entry.infoHash);
147
+ if (existing) {
148
+ // Re-inserting moves it to the end, so the first key is always the least
149
+ // recently used.
150
+ this.#open.delete(entry.infoHash);
151
+ this.#open.set(entry.infoHash, existing);
152
+ return existing;
153
+ }
154
+
155
+ const handle = await this.#openArchive(entry);
156
+ this.#open.set(entry.infoHash, handle);
157
+
158
+ const limit = this.#config.tiles?.maxOpenArchives ?? 16;
159
+ while (this.#open.size > limit) {
160
+ const [oldest, victim] = this.#open.entries().next().value;
161
+ this.#open.delete(oldest);
162
+ await this.#release(victim);
163
+ }
164
+ return handle;
165
+ }
166
+
167
+ /**
168
+ * Opens an archive, choosing between the local file and the swarm.
169
+ * @param {object} entry - Catalog entry.
170
+ * @returns {Promise<object>} - The open handle.
171
+ */
172
+ async #openArchive(entry) {
173
+ const local = await this.#completeLocalPath(entry);
174
+ if (local) {
175
+ const source = new NodeFileSource(local);
176
+ return {
177
+ mode: 'local',
178
+ source,
179
+ archive: new PMTiles(source, this.#directoryCache),
180
+ openedAt: new Date().toISOString(),
181
+ close: () => source.close(),
182
+ };
183
+ }
184
+
185
+ const engine = await this.#readEngine(entry);
186
+ const source = new TorrentSource(engine, {
187
+ cacheBytes: this.#config.tiles?.pieceCacheBytes,
188
+ hydrateIdleMs: this.#config.tiles?.hydrateIdleMs,
189
+ });
190
+ return {
191
+ mode: 'swarm',
192
+ source,
193
+ archive: new PMTiles(source, this.#directoryCache),
194
+ openedAt: new Date().toISOString(),
195
+ // destroy() takes the engine down with it, which for both bridges means
196
+ // dropping this reader without disturbing what the node is seeding.
197
+ close: () => source.destroy(),
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Returns the archive's path when this node holds a complete copy.
203
+ *
204
+ * Size alone cannot answer this: both engines preallocate the full file, so a
205
+ * torrent one piece in already looks the right size on disk. The engine's own
206
+ * progress is the only trustworthy signal.
207
+ * @param {object} entry - Catalog entry.
208
+ * @returns {Promise<string | null>} - Path, or null if incomplete.
209
+ */
210
+ async #completeLocalPath(entry) {
211
+ if (!entry.savePath) return null;
212
+ const status = await this.#engine.get(entry.infoHash).catch(() => null);
213
+ if (status && status.progress < 1) return null;
214
+
215
+ // No status at all means the engine does not know this torrent — the file
216
+ // may still be a plain local archive that was added and never seeded.
217
+ const file = path.join(entry.savePath, entry.name);
218
+ try {
219
+ const stat = await fs.stat(file);
220
+ if (!stat.isFile()) return null;
221
+ if (status === null && stat.size !== entry.size) return null;
222
+ return file;
223
+ } catch {
224
+ return null;
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Builds the read engine for an archive this node does not fully hold.
230
+ * @param {object} entry - Catalog entry.
231
+ * @returns {Promise<object>} - A pmtiles-torrent TorrentEngine.
232
+ */
233
+ async #readEngine(entry) {
234
+ switch (this.#engine.name) {
235
+ case 'libtorrent':
236
+ return new LibtorrentReadEngine(this.#engine, entry.infoHash, {
237
+ pieceTimeoutMs: this.#config.tiles?.pieceTimeoutMs,
238
+ });
239
+
240
+ case 'webtorrent': {
241
+ const { WebTorrentEngine } = await import('pmtiles-torrent/webtorrent');
242
+ const client = this.#engine.client;
243
+ if (!client) {
244
+ throw new TileReadError(
245
+ 'the webtorrent engine is not connected yet',
246
+ 503,
247
+ );
248
+ }
249
+ // Sharing the seeding client is the whole point: one peer pool, one
250
+ // port, one DHT node, and the pieces this fetches count towards what
251
+ // the node seeds back.
252
+ return new WebTorrentEngine(entry.torrentPath ?? entry.magnet, {
253
+ client,
254
+ path: entry.savePath,
255
+ readyTimeoutMs: this.#config.tiles?.readyTimeoutMs,
256
+ });
257
+ }
258
+
259
+ default:
260
+ // qBittorrent's WebUI has per-file priorities but nothing per piece and
261
+ // no way to read one back, so there is no honest way to serve a tile
262
+ // from an archive it holds only part of.
263
+ throw new TileReadError(
264
+ `the ${this.#engine.name} engine cannot read pieces on demand, and this ` +
265
+ 'node does not hold a complete copy of the archive. Mirror it, or ' +
266
+ 'run the libtorrent or webtorrent engine.',
267
+ 501,
268
+ );
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Closes one handle, swallowing errors so eviction cannot fail a request.
274
+ * @param {object} handle - The handle to close.
275
+ * @returns {Promise<void>} - Resolves once closed.
276
+ */
277
+ async #release(handle) {
278
+ try {
279
+ await handle.close();
280
+ } catch {
281
+ // Nothing useful to do: the handle is being dropped either way.
282
+ }
283
+ }
284
+ }
@@ -0,0 +1,250 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { Readable } from 'node:stream';
4
+
5
+ /**
6
+ * Turning a PMTiles archive into a torrent.
7
+ *
8
+ * Two things here are specific to distributing maps rather than generic files.
9
+ *
10
+ * Piece length: creation tools size pieces for whole-file downloads, which for
11
+ * a multi-hundred-gigabyte archive means 16 MiB or more. That is a poor fit for
12
+ * a tile server, where a cold tile costs a whole piece; 4 MiB is the default
13
+ * here, trading a larger hash list for a quarter of the read amplification.
14
+ *
15
+ * Web seeds: a brand-new archive has no peers, which normally makes it useless
16
+ * until someone finishes downloading it. BEP 19 lets the torrent name an HTTP
17
+ * or S3 URL as a fallback source, so it works from the moment it is published
18
+ * and simply gets cheaper as peers appear. If the archive already lives on a
19
+ * web server, always pass its URL.
20
+ */
21
+
22
+ /**
23
+ * Options for creating a torrent.
24
+ * @typedef {object} CreateTorrentOptions
25
+ * @property {string} [name] - Torrent name. Defaults to the filename.
26
+ * @property {number} [pieceLength] - Piece size in bytes; must be a power of two.
27
+ * @property {string[]} [trackers] - Announce URLs.
28
+ * @property {string[]} [webSeeds] - BEP 19 url-list entries.
29
+ * @property {string} [comment] - Free-text comment.
30
+ * @property {boolean} [private] - Mark the torrent private (no DHT/PEX).
31
+ */
32
+
33
+ /**
34
+ * The result of creating a torrent.
35
+ * @typedef {object} CreatedTorrent
36
+ * @property {Uint8Array} torrentFile - Raw .torrent bytes.
37
+ * @property {string} infoHash - Hex v1 infohash.
38
+ * @property {string} magnet - Magnet URI.
39
+ * @property {string} name - Torrent name.
40
+ * @property {number} size - Total bytes.
41
+ * @property {number} pieceLength - Piece size used.
42
+ * @property {number} pieceCount - Number of pieces.
43
+ */
44
+
45
+ /**
46
+ * Creates a torrent from a local archive.
47
+ * @param {string} filePath - Path to the .pmtiles file.
48
+ * @param {CreateTorrentOptions} [options] - Creation options.
49
+ * @returns {Promise<CreatedTorrent>} - The created torrent.
50
+ */
51
+ export async function createTorrentFromFile(filePath, options = {}) {
52
+ const stat = await fs.stat(filePath);
53
+ if (!stat.isFile() || stat.size === 0) {
54
+ throw new Error(`not a usable file: ${filePath}`);
55
+ }
56
+ return buildTorrent(filePath, path.basename(filePath), stat.size, options);
57
+ }
58
+
59
+ /**
60
+ * Creates a torrent from a remote archive.
61
+ *
62
+ * Piece hashes are computed over content, so there is no way to do this without
63
+ * reading every byte. What differs is whether those bytes are kept:
64
+ *
65
+ * retain (default) — they are written to `retainPath` as they arrive, so the
66
+ * node holds a complete copy and becomes a real seeder the moment the
67
+ * torrent is published. Costs the archive's full size in disk.
68
+ *
69
+ * discard — they are streamed past the hasher and dropped. Costs bandwidth
70
+ * and time but no disk, and leaves the node unable to seed what it just
71
+ * published. That is only viable because the origin URL is registered as a
72
+ * web seed, so peers fetch over HTTP until someone mirrors it. Reasonable
73
+ * for publishing something you already host; a poor default, because a
74
+ * torrent nobody seeds is just HTTP with extra steps.
75
+ *
76
+ * Either way the origin becomes a web seed, since by definition it serves the
77
+ * exact bytes being hashed.
78
+ * @param {string} url - HTTP(S) URL of the archive.
79
+ * @param {CreateTorrentOptions & {retainPath?: string, onProgress?: Function}} [options] - Creation options. Omit retainPath to discard.
80
+ * @returns {Promise<CreatedTorrent & {retainedAt?: string}>} - The created torrent.
81
+ */
82
+ export async function createTorrentFromUrl(url, options = {}) {
83
+ const name = options.name ?? path.basename(new URL(url).pathname);
84
+ // The origin is, by construction, a valid web seed for these exact bytes.
85
+ const webSeeds = [...new Set([...(options.webSeeds ?? []), url])];
86
+
87
+ if (options.retainPath) {
88
+ // Download first, then hash from disk. Two passes over local storage, but
89
+ // only one trip over the network — which is the expensive part — and it
90
+ // leaves a seedable copy behind.
91
+ const target = path.join(options.retainPath, name);
92
+ await downloadTo(url, target, options.onProgress);
93
+ const created = await createTorrentFromFile(target, {
94
+ ...options,
95
+ name,
96
+ webSeeds,
97
+ });
98
+ return { ...created, retainedAt: target };
99
+ }
100
+
101
+ const response = await fetch(url);
102
+ if (!response.ok || !response.body) {
103
+ throw new Error(
104
+ `could not read ${url}: ${response.status} ${response.statusText}`,
105
+ );
106
+ }
107
+ const size = Number(response.headers.get('content-length') ?? 0);
108
+ const stream = Readable.fromWeb(response.body);
109
+ return buildTorrent(stream, name, size, { ...options, webSeeds });
110
+ }
111
+
112
+ /**
113
+ * Streams a URL to a file, reporting progress.
114
+ * @param {string} url - Source URL.
115
+ * @param {string} target - Destination path.
116
+ * @param {Function} [onProgress] - Called with {received, total}.
117
+ * @returns {Promise<number>} - Bytes written.
118
+ */
119
+ async function downloadTo(url, target, onProgress) {
120
+ const { createWriteStream } = await import('node:fs');
121
+ const { pipeline } = await import('node:stream/promises');
122
+
123
+ const response = await fetch(url);
124
+ if (!response.ok || !response.body) {
125
+ throw new Error(
126
+ `could not read ${url}: ${response.status} ${response.statusText}`,
127
+ );
128
+ }
129
+ const total = Number(response.headers.get('content-length') ?? 0);
130
+
131
+ await fs.mkdir(path.dirname(target), { recursive: true });
132
+
133
+ let received = 0;
134
+ let lastReport = 0;
135
+ const source = Readable.fromWeb(response.body);
136
+ source.on('data', (chunk) => {
137
+ received += chunk.length;
138
+ // Report at most once a second; a multi-hour download should not produce
139
+ // millions of log lines.
140
+ const now = Date.now();
141
+ if (onProgress && now - lastReport > 1000) {
142
+ lastReport = now;
143
+ onProgress({ received, total });
144
+ }
145
+ });
146
+
147
+ await pipeline(source, createWriteStream(target));
148
+ onProgress?.({ received, total, done: true });
149
+ return received;
150
+ }
151
+
152
+ /**
153
+ * Runs create-torrent and normalises its output.
154
+ * @param {string | import('node:stream').Readable} input - File path or stream.
155
+ * @param {string} name - Torrent name.
156
+ * @param {number} size - Total bytes, for reporting.
157
+ * @param {CreateTorrentOptions} options - Creation options.
158
+ * @returns {Promise<CreatedTorrent>} - The created torrent.
159
+ */
160
+ async function buildTorrent(input, name, size, options) {
161
+ const [{ default: createTorrent }, { default: parseTorrent }] =
162
+ await Promise.all([import('create-torrent'), import('parse-torrent')]);
163
+
164
+ const pieceLength = options.pieceLength ?? 4 * 1024 * 1024;
165
+ if ((pieceLength & (pieceLength - 1)) !== 0) {
166
+ throw new Error(`pieceLength must be a power of two, got ${pieceLength}`);
167
+ }
168
+
169
+ // create-torrent wants a stream to carry a name and length.
170
+ if (typeof input !== 'string') {
171
+ input.name = name;
172
+ if (size) input.length = size;
173
+ }
174
+
175
+ const torrentFile = await new Promise((resolve, reject) => {
176
+ createTorrent(
177
+ input,
178
+ {
179
+ name: options.name ?? name,
180
+ pieceLength,
181
+ announceList: toAnnounceList(options.trackers ?? []),
182
+ urlList: options.webSeeds ?? [],
183
+ comment: options.comment,
184
+ private: options.private ?? false,
185
+ createdBy: 'pmtiles-swarm',
186
+ },
187
+ (error, buffer) => (error ? reject(error) : resolve(buffer)),
188
+ );
189
+ });
190
+
191
+ const parsed = await parseTorrent(torrentFile);
192
+ return {
193
+ torrentFile: new Uint8Array(torrentFile),
194
+ infoHash: parsed.infoHash,
195
+ magnet: buildMagnet(parsed, options),
196
+ name: parsed.name,
197
+ size: parsed.length ?? size,
198
+ pieceLength: parsed.pieceLength,
199
+ pieceCount: parsed.pieces?.length ?? 0,
200
+ };
201
+ }
202
+
203
+ /**
204
+ * Normalises trackers into BEP 12 announce tiers.
205
+ *
206
+ * A tier is a group of trackers tried together before falling back to the next
207
+ * one, which is how you pair the UDP and HTTP endpoints of the same tracker
208
+ * without announcing to both. Config accepts either form:
209
+ *
210
+ * "udp://a:1337/announce" one tracker, its own tier
211
+ * ["udp://b:6969/announce", "http://b:6969/…"] one tier, two endpoints
212
+ *
213
+ * This mirrors mktorrent, where `-a x` is a tier and `-a x,y` groups them.
214
+ * @param {Array<string | string[]>} trackers - Configured trackers.
215
+ * @returns {string[][]} - Announce tiers.
216
+ */
217
+ function toAnnounceList(trackers) {
218
+ return trackers
219
+ .map((tier) => (Array.isArray(tier) ? tier : [tier]))
220
+ .filter((tier) => tier.length > 0);
221
+ }
222
+
223
+ /**
224
+ * Flattens announce tiers for magnet `tr=` parameters, which have no notion of
225
+ * tiers.
226
+ * @param {Array<string | string[]>} trackers - Configured trackers.
227
+ * @returns {string[]} - A flat list.
228
+ */
229
+ function flattenTrackers(trackers) {
230
+ return trackers.flatMap((tier) => (Array.isArray(tier) ? tier : [tier]));
231
+ }
232
+
233
+ /**
234
+ * Builds a magnet URI carrying trackers and web seeds, so a magnet alone is
235
+ * enough to fetch the archive even with no peers.
236
+ * @param {object} parsed - A parse-torrent result.
237
+ * @param {CreateTorrentOptions} options - Creation options.
238
+ * @returns {string} - The magnet URI.
239
+ */
240
+ function buildMagnet(parsed, options) {
241
+ const parts = [`magnet:?xt=urn:btih:${parsed.infoHash}`];
242
+ if (parsed.name) parts.push(`dn=${encodeURIComponent(parsed.name)}`);
243
+ for (const tracker of flattenTrackers(options.trackers ?? [])) {
244
+ parts.push(`tr=${encodeURIComponent(tracker)}`);
245
+ }
246
+ for (const seed of options.webSeeds ?? []) {
247
+ parts.push(`ws=${encodeURIComponent(seed)}`);
248
+ }
249
+ return parts.join('&');
250
+ }