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/CHANGELOG.md +53 -0
- package/LICENSE +28 -0
- package/NOTICE.md +47 -0
- package/README.md +172 -0
- package/docs/architecture-diagram.md +287 -0
- package/docs/engines.md +140 -0
- package/docs/publishing.md +387 -0
- package/docs/serving-tiles.md +218 -0
- package/docs/subscribing.md +187 -0
- package/package.json +56 -0
- package/src/api.js +472 -0
- package/src/catalog.js +159 -0
- package/src/config.js +231 -0
- package/src/engines/libtorrent.js +384 -0
- package/src/engines/qbittorrent.js +320 -0
- package/src/engines/types.js +59 -0
- package/src/engines/webtorrent.js +264 -0
- package/src/feed.js +226 -0
- package/src/file-source.js +67 -0
- package/src/index.js +177 -0
- package/src/library.js +567 -0
- package/src/mutable.js +197 -0
- package/src/origin.js +205 -0
- package/src/pmtiles-probe.js +94 -0
- package/src/read-engine.js +175 -0
- package/src/sources.js +245 -0
- package/src/subscriptions.js +158 -0
- package/src/tilejson.js +112 -0
- package/src/tiles.js +284 -0
- package/src/torrent-create.js +250 -0
- package/src/warm.js +251 -0
- package/src/watch.js +98 -0
- package/src/web/index.html +254 -0
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
|
+
}
|