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/api.js ADDED
@@ -0,0 +1,472 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import express from 'express';
5
+ import { renderFeed } from './feed.js';
6
+ import { buildTileJson, extensionMatches } from './tilejson.js';
7
+ import { TileReadError } from './tiles.js';
8
+
9
+ const here = path.dirname(fileURLToPath(import.meta.url));
10
+
11
+ /**
12
+ * Wraps an async route so a rejection becomes a 500 rather than an unhandled
13
+ * rejection that takes the process down.
14
+ * @param {Function} handler - The route handler.
15
+ * @returns {Function} - A safe handler.
16
+ */
17
+ function route(handler) {
18
+ return (req, res, next) => Promise.resolve(handler(req, res)).catch(next);
19
+ }
20
+
21
+ /**
22
+ * Builds the HTTP application: JSON API, RSS feeds and the web UI.
23
+ * @param {object} deps - Collaborators.
24
+ * @param {import('./library.js').Library} deps.library - The library service.
25
+ * @param {import('./catalog.js').Catalog} deps.catalog - The catalog.
26
+ * @param {import('./engines/types.js').SeedEngine} deps.engine - The seeding engine.
27
+ * @param {import('./subscriptions.js').SubscriptionManager} deps.subscriptions - Feed follower.
28
+ * @param {import('./tiles.js').TileStore} deps.tiles - The tile reader.
29
+ * @param {import('./warm.js').WarmRunner} [deps.warm] - Region pre-fetcher.
30
+ * @param {object} deps.config - Resolved configuration.
31
+ * @returns {import('express').Express} - The configured app.
32
+ */
33
+ export function createApp({
34
+ library,
35
+ catalog,
36
+ engine,
37
+ subscriptions,
38
+ tiles,
39
+ warm,
40
+ config,
41
+ }) {
42
+ const app = express();
43
+ // Without this, a TLS-terminating proxy leaves req.protocol as "http", and
44
+ // the TileJSON advertises http:// tile URLs. A browser that loaded the map
45
+ // over https then blocks every one of them as mixed content, which looks
46
+ // like an empty map rather than like a configuration mistake.
47
+ if (config.trustProxy) app.set('trust proxy', config.trustProxy);
48
+ app.use(express.json({ limit: '1mb' }));
49
+ // .torrent uploads arrive as raw bytes.
50
+ app.use(
51
+ express.raw({ type: 'application/x-bittorrent', limit: '64mb' }),
52
+ );
53
+
54
+ /**
55
+ * The externally visible base URL, for absolute links in the feed and in
56
+ * TileJSON.
57
+ *
58
+ * Three behaviours, in order:
59
+ *
60
+ * `publicUrl` set one canonical URL, whatever the request said.
61
+ * `trustProxy` set derived per request from X-Forwarded-Proto and
62
+ * X-Forwarded-Host, so the same node can answer
63
+ * correctly on http and https at once.
64
+ * neither derived from the connection itself.
65
+ *
66
+ * Note `req.host` rather than `req.get('host')`: only the former follows
67
+ * X-Forwarded-Host, and the raw Host header behind a proxy is whatever the
68
+ * proxy dialled — usually an internal address, which would end up baked into
69
+ * every published tile URL.
70
+ * @param {import('express').Request} req - The request.
71
+ * @returns {string} - Base URL without a trailing slash.
72
+ */
73
+ const baseUrl = (req) =>
74
+ (config.publicUrl ?? `${req.protocol}://${req.host}`).replace(/\/$/, '');
75
+
76
+ app.get(
77
+ '/api/status',
78
+ route(async (_req, res) => {
79
+ let engineOk = true;
80
+ let engineError;
81
+ try {
82
+ await engine.list();
83
+ } catch (error) {
84
+ engineOk = false;
85
+ engineError = error.message;
86
+ }
87
+ res.json({
88
+ engine: { name: engine.name, ok: engineOk, error: engineError },
89
+ archives: catalog.list().length,
90
+ categories: catalog.categories(),
91
+ watching: config.watch.map((w) => w.path),
92
+ subscriptions: (config.subscriptions ?? []).map((s) => ({
93
+ url: s.url,
94
+ mode: s.mode ?? 'cache',
95
+ })),
96
+ });
97
+ }),
98
+ );
99
+
100
+ app.get(
101
+ '/api/torrents',
102
+ route(async (_req, res) => res.json(await library.listWithStatus())),
103
+ );
104
+
105
+ app.get(
106
+ '/api/torrents/:infoHash',
107
+ route(async (req, res) => {
108
+ const entry = catalog.get(req.params.infoHash);
109
+ if (!entry) return res.status(404).json({ error: 'not found' });
110
+ const status = await engine.get(entry.infoHash).catch(() => null);
111
+ // Null unless a tile has been requested: archives are opened lazily, and
112
+ // whether one reads its local file or the swarm is worth being able to
113
+ // see when a node is slower than expected.
114
+ const reading = tiles?.status(entry.infoHash) ?? null;
115
+ res.json({ ...entry, status, reading });
116
+ }),
117
+ );
118
+
119
+ // Pre-fetching a region, so a cache-mode node is useful the moment it enters
120
+ // rotation rather than paying for the first request to every area. A node
121
+ // holding a complete copy has nothing to warm; this reads its local file and
122
+ // finishes almost immediately.
123
+ app.post(
124
+ '/api/torrents/:infoHash/warm',
125
+ route(async (req, res) => {
126
+ const entry = catalog.get(req.params.infoHash);
127
+ if (!entry) return res.status(404).json({ error: 'not found' });
128
+ if (!warm) return res.status(501).json({ error: 'warming is disabled' });
129
+
130
+ const body = req.body ?? {};
131
+ try {
132
+ const job = warm.start(entry, {
133
+ bounds: body.bounds,
134
+ minZoom: body.minZoom,
135
+ maxZoom: body.maxZoom,
136
+ maxTiles: body.maxTiles,
137
+ concurrency: body.concurrency,
138
+ });
139
+ res.status(202).json(warm.get(job.infoHash));
140
+ } catch (error) {
141
+ if (error.status) {
142
+ return res.status(error.status).json({ error: error.message });
143
+ }
144
+ throw error;
145
+ }
146
+ }),
147
+ );
148
+
149
+ app.get(
150
+ '/api/torrents/:infoHash/warm',
151
+ route(async (req, res) => {
152
+ const job = warm?.get(req.params.infoHash);
153
+ if (!job) return res.status(404).json({ error: 'no warm for this archive' });
154
+ res.json(job);
155
+ }),
156
+ );
157
+
158
+ app.delete(
159
+ '/api/torrents/:infoHash/warm',
160
+ route(async (req, res) => {
161
+ const cancelled = warm?.cancel(req.params.infoHash);
162
+ if (!cancelled) {
163
+ return res.status(404).json({ error: 'no warm running' });
164
+ }
165
+ res.status(202).json(warm.get(req.params.infoHash));
166
+ }),
167
+ );
168
+
169
+ app.get(
170
+ '/api/torrents/:infoHash/peers',
171
+ route(async (req, res) => {
172
+ if (!engine.peers) {
173
+ return res
174
+ .status(501)
175
+ .json({ error: `${engine.name} does not report peer detail` });
176
+ }
177
+ res.json(await engine.peers(req.params.infoHash));
178
+ }),
179
+ );
180
+
181
+ // Serving the .torrent is what makes the RSS enclosure work, so this is the
182
+ // endpoint other nodes actually hit.
183
+ app.get(
184
+ '/api/torrents/:infoHash/file',
185
+ route(async (req, res) => {
186
+ const entry = catalog.get(req.params.infoHash);
187
+ if (!entry?.torrentPath) {
188
+ return res
189
+ .status(404)
190
+ .json({ error: 'no .torrent stored for this archive' });
191
+ }
192
+ const body = await fs.readFile(entry.torrentPath).catch(() => null);
193
+ if (!body) return res.status(404).json({ error: 'torrent file missing' });
194
+ res.type('application/x-bittorrent');
195
+ res.setHeader(
196
+ 'content-disposition',
197
+ `attachment; filename="${entry.name}.torrent"`,
198
+ );
199
+ res.send(body);
200
+ }),
201
+ );
202
+
203
+ app.get(
204
+ '/api/torrents/:infoHash/magnet',
205
+ route(async (req, res) => {
206
+ const entry = catalog.get(req.params.infoHash);
207
+ if (!entry) return res.status(404).json({ error: 'not found' });
208
+ res.type('text/plain').send(entry.magnet);
209
+ }),
210
+ );
211
+
212
+ /**
213
+ * Adds an archive. The body picks the path:
214
+ * {path} a local .pmtiles file, hashed into a new torrent
215
+ * {url} a remote .pmtiles, streamed past the hasher
216
+ * {magnet} an existing torrent, joined
217
+ * {torrentUrl} an existing .torrent fetched over HTTP, joined
218
+ * A raw application/x-bittorrent body uploads a .torrent directly.
219
+ */
220
+ app.post(
221
+ '/api/torrents',
222
+ route(async (req, res) => {
223
+ if (Buffer.isBuffer(req.body) && req.body.length > 0) {
224
+ const entry = await library.addExistingTorrent(
225
+ { torrentFile: req.body },
226
+ {
227
+ category: req.query.category,
228
+ savePath: req.query.savePath,
229
+ mode: req.query.mode,
230
+ },
231
+ );
232
+ return res.status(201).json(entry);
233
+ }
234
+
235
+ const body = req.body ?? {};
236
+ const options = {
237
+ category: body.category,
238
+ trackers: body.trackers,
239
+ webSeeds: body.webSeeds,
240
+ pieceLength: body.pieceLength,
241
+ savePath: body.savePath,
242
+ mode: body.mode,
243
+ retain: body.retain,
244
+ };
245
+
246
+ let entry;
247
+ if (body.path) {
248
+ entry = await library.addLocalArchive(body.path, options);
249
+ } else if (body.url) {
250
+ entry = await library.addRemoteArchive(body.url, options);
251
+ } else if (body.magnet) {
252
+ entry = await library.addExistingTorrent(
253
+ { magnet: body.magnet },
254
+ options,
255
+ );
256
+ } else if (body.torrentUrl) {
257
+ const response = await fetch(body.torrentUrl);
258
+ if (!response.ok) {
259
+ return res.status(400).json({
260
+ error: `could not fetch ${body.torrentUrl}: ${response.status}`,
261
+ });
262
+ }
263
+ entry = await library.addExistingTorrent(
264
+ { torrentFile: new Uint8Array(await response.arrayBuffer()) },
265
+ options,
266
+ );
267
+ } else {
268
+ return res.status(400).json({
269
+ error:
270
+ 'supply one of: path, url, magnet, torrentUrl, or a raw .torrent body',
271
+ });
272
+ }
273
+ res.status(201).json(entry);
274
+ }),
275
+ );
276
+
277
+ app.delete(
278
+ '/api/torrents/:infoHash',
279
+ route(async (req, res) => {
280
+ const removed = await library.remove(req.params.infoHash, {
281
+ deleteData: req.query.deleteData === 'true',
282
+ });
283
+ if (!removed) return res.status(404).json({ error: 'not found' });
284
+ res.status(204).end();
285
+ }),
286
+ );
287
+
288
+ // Pulls in whatever the engine already seeds — the migration path for an
289
+ // existing qBittorrent library.
290
+ app.post(
291
+ '/api/adopt',
292
+ route(async (req, res) => {
293
+ const added = await library.adoptFromEngine({
294
+ all: req.query.all === 'true',
295
+ });
296
+ res.json({ added: added.length, entries: added });
297
+ }),
298
+ );
299
+
300
+ // Has the file a torrent was built from changed since?
301
+ app.post(
302
+ '/api/torrents/:infoHash/check',
303
+ route(async (req, res) => {
304
+ const result = await library.checkOrigin(req.params.infoHash);
305
+ if (!result) return res.status(404).json({ error: 'not found' });
306
+ res.json(result);
307
+ }),
308
+ );
309
+
310
+ // Rebuild from the current source. This mints a NEW infohash, because the
311
+ // infohash is a hash of the content.
312
+ app.post(
313
+ '/api/torrents/:infoHash/rebuild',
314
+ route(async (req, res) => {
315
+ const entry = await library.rebuild(req.params.infoHash, req.body ?? {});
316
+ res.status(201).json(entry);
317
+ }),
318
+ );
319
+
320
+ app.post(
321
+ '/api/check-origins',
322
+ route(async (_req, res) => {
323
+ const changed = await library.checkAllOrigins();
324
+ res.json({ changed: changed.length, results: changed });
325
+ }),
326
+ );
327
+
328
+ app.post(
329
+ '/api/subscriptions/refresh',
330
+ route(async (_req, res) => {
331
+ const added = await subscriptions.refresh();
332
+ res.json({ added: added.length, entries: added });
333
+ }),
334
+ );
335
+
336
+ /**
337
+ * How many items this feed request should return.
338
+ *
339
+ * `?limit=` lets one publisher serve consumers with different poll intervals
340
+ * from the same catalog, without changing the configured default.
341
+ * @param {import('express').Request} req - The request.
342
+ * @returns {number} - The cap, or 0 for no limit.
343
+ */
344
+ const feedLimit = (req) => {
345
+ const requested = Number.parseInt(req.query.limit, 10);
346
+ if (Number.isFinite(requested) && requested >= 0) return requested;
347
+ return config.feedMaxItems ?? 0;
348
+ };
349
+
350
+ app.get('/feed.xml', (req, res) => {
351
+ res.type('application/rss+xml').send(
352
+ renderFeed(catalog.list(), {
353
+ title: config.feedTitle ?? 'PMTiles archives',
354
+ baseUrl: baseUrl(req),
355
+ copyright: config.feedCopyright,
356
+ maxItems: feedLimit(req),
357
+ }),
358
+ );
359
+ });
360
+
361
+ app.get('/feed/:category.xml', (req, res) => {
362
+ const { category } = req.params;
363
+ res.type('application/rss+xml').send(
364
+ renderFeed(catalog.byCategory(category), {
365
+ title: `${config.feedTitle ?? 'PMTiles archives'} — ${category}`,
366
+ baseUrl: baseUrl(req),
367
+ copyright: config.feedCopyright,
368
+ category,
369
+ maxItems: feedLimit(req),
370
+ }),
371
+ );
372
+ });
373
+
374
+ // Tile serving. These sit outside /api on purpose: they are the URLs that go
375
+ // into a map style, so they should look like a tile server, not like an
376
+ // administrative API.
377
+
378
+ app.get(
379
+ '/archives/:infoHash/tiles.json',
380
+ route(async (req, res) => {
381
+ const entry = catalog.get(req.params.infoHash);
382
+ if (!entry) return res.status(404).json({ error: 'unknown archive' });
383
+ if (!entry.pmtiles) {
384
+ return res.status(409).json({
385
+ error:
386
+ 'this archive has not been probed, so its tile metadata is unknown',
387
+ });
388
+ }
389
+ // Anyone embedding a map is doing so from another origin.
390
+ res.setHeader('access-control-allow-origin', '*');
391
+ res.json(buildTileJson(entry, baseUrl(req)));
392
+ }),
393
+ );
394
+
395
+ // The .torrent under the archive root, so everything a TileJSON consumer
396
+ // needs hangs off one prefix rather than being split across /api.
397
+ app.get('/archives/:infoHash/archive.torrent', (req, res, next) => {
398
+ req.url = `/api/torrents/${req.params.infoHash}/file`;
399
+ app.handle(req, res, next);
400
+ });
401
+
402
+ app.get(
403
+ '/archives/:infoHash/:z/:x/:y.:ext',
404
+ route(async (req, res) => {
405
+ const { infoHash, ext } = req.params;
406
+ const entry = catalog.get(infoHash);
407
+ if (!entry) return res.status(404).json({ error: 'unknown archive' });
408
+
409
+ const z = Number(req.params.z);
410
+ const x = Number(req.params.x);
411
+ const y = Number(req.params.y);
412
+ if (![z, x, y].every(Number.isInteger)) {
413
+ return res.status(400).json({ error: 'z, x and y must be integers' });
414
+ }
415
+ const limit = 2 ** z;
416
+ if (z < 0 || z > 26 || x < 0 || y < 0 || x >= limit || y >= limit) {
417
+ return res.status(400).json({ error: 'tile coordinates out of range' });
418
+ }
419
+ if (!extensionMatches(entry, ext)) {
420
+ return res.status(400).json({
421
+ error: `this archive holds ${entry.pmtiles?.format ?? 'unknown'} tiles`,
422
+ });
423
+ }
424
+
425
+ const controller = new AbortController();
426
+ // A panning map abandons requests constantly. Without this the swarm
427
+ // keeps fetching pieces for tiles nobody is waiting for any more.
428
+ res.on('close', () => {
429
+ if (!res.writableEnded) controller.abort();
430
+ });
431
+
432
+ let tile;
433
+ try {
434
+ tile = await tiles.getTile(infoHash, z, x, y, {
435
+ signal: controller.signal,
436
+ });
437
+ } catch (error) {
438
+ if (error.name === 'AbortError') return;
439
+ if (error instanceof TileReadError) {
440
+ return res.status(error.status).json({ error: error.message });
441
+ }
442
+ throw error;
443
+ }
444
+
445
+ res.setHeader('access-control-allow-origin', '*');
446
+ // An infohash pins content, so a tile under one can never change. When a
447
+ // mutable archive is updated the infohash changes and so does this URL,
448
+ // which makes cache invalidation automatic.
449
+ res.setHeader('cache-control', 'public, max-age=31536000, immutable');
450
+ res.setHeader('etag', `"${infoHash}-${z}-${x}-${y}"`);
451
+
452
+ // A missing tile is normal in a sparse archive. 204 rather than 404 is
453
+ // what vector clients expect, and it stops a map logging errors while
454
+ // panning past the edge of coverage.
455
+ if (!tile) return res.status(204).end();
456
+
457
+ res.type(entry.pmtiles?.contentType ?? 'application/octet-stream');
458
+ if (tile.encoding) res.setHeader('content-encoding', tile.encoding);
459
+ res.send(tile.data);
460
+ }),
461
+ );
462
+
463
+ app.use(express.static(path.join(here, 'web')));
464
+
465
+ // eslint-disable-next-line no-unused-vars -- express identifies error handlers by arity
466
+ app.use((error, _req, res, _next) => {
467
+ console.error(`[api] ${error.stack ?? error.message}`);
468
+ res.status(500).json({ error: error.message });
469
+ });
470
+
471
+ return app;
472
+ }
package/src/catalog.js ADDED
@@ -0,0 +1,159 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * The catalog of archives this node distributes.
6
+ *
7
+ * Deliberately a JSON file rather than a database: the record count is in the
8
+ * dozens, the whole thing is human-readable and hand-editable, and it avoids a
9
+ * native dependency in a project that already asks a lot of the install.
10
+ * Writes go through a temp file and a rename, so a crash cannot truncate it.
11
+ *
12
+ * @typedef {object} CatalogEntry
13
+ * @property {string} infoHash - Hex v1 infohash. The catalog's primary key.
14
+ * @property {string} name - Archive filename.
15
+ * @property {number} size - Bytes.
16
+ * @property {string} [category] - Grouping, also used to split RSS feeds.
17
+ * @property {object} source - Where the archive came from: {type, location}.
18
+ * @property {string} savePath - Directory holding the data.
19
+ * @property {string} torrentPath - Generated .torrent on disk.
20
+ * @property {string} magnet - Magnet URI for the current infohash.
21
+ * @property {string[]} webSeeds - BEP 19 url-list entries.
22
+ * @property {object} [pmtiles] - Header and metadata summary.
23
+ * @property {object} [mutable] - BEP 46 identity: {publicKey, salt, seq}.
24
+ * @property {string} createdAt - ISO timestamp.
25
+ * @property {string} updatedAt - ISO timestamp.
26
+ */
27
+ export class Catalog {
28
+ #file;
29
+ #entries = new Map();
30
+ #writing = Promise.resolve();
31
+
32
+ /**
33
+ * Creates a catalog backed by a file.
34
+ * @param {string} dataDir - Directory holding catalog.json.
35
+ */
36
+ constructor(dataDir) {
37
+ this.#file = path.join(dataDir, 'catalog.json');
38
+ }
39
+
40
+ /**
41
+ * Loads the catalog from disk. A missing file is an empty catalog.
42
+ * @returns {Promise<void>} - Resolves once loaded.
43
+ */
44
+ async load() {
45
+ try {
46
+ const raw = JSON.parse(await fs.readFile(this.#file, 'utf8'));
47
+ for (const entry of raw.entries ?? []) {
48
+ this.#entries.set(entry.infoHash, entry);
49
+ }
50
+ } catch (error) {
51
+ if (error.code !== 'ENOENT') throw error;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Every entry, newest first.
57
+ * @returns {CatalogEntry[]} - The catalog contents.
58
+ */
59
+ list() {
60
+ return [...this.#entries.values()].sort((a, b) =>
61
+ b.createdAt.localeCompare(a.createdAt),
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Entries in one category.
67
+ * @param {string} category - The category to filter by.
68
+ * @returns {CatalogEntry[]} - Matching entries.
69
+ */
70
+ byCategory(category) {
71
+ return this.list().filter((entry) => entry.category === category);
72
+ }
73
+
74
+ /**
75
+ * Every distinct category in use.
76
+ * @returns {string[]} - Sorted category names.
77
+ */
78
+ categories() {
79
+ const seen = new Set();
80
+ for (const entry of this.#entries.values()) {
81
+ if (entry.category) seen.add(entry.category);
82
+ }
83
+ return [...seen].sort();
84
+ }
85
+
86
+ /**
87
+ * Looks up one entry.
88
+ * @param {string} infoHash - The infohash to find.
89
+ * @returns {CatalogEntry | undefined} - The entry, if present.
90
+ */
91
+ get(infoHash) {
92
+ return this.#entries.get(infoHash?.toLowerCase());
93
+ }
94
+
95
+ /**
96
+ * Finds an entry by the source it was built from, so a watch folder does not
97
+ * re-import a file it has already seen.
98
+ * @param {string} location - Source path or URL.
99
+ * @returns {CatalogEntry | undefined} - The entry, if present.
100
+ */
101
+ findBySource(location) {
102
+ for (const entry of this.#entries.values()) {
103
+ if (entry.source?.location === location) return entry;
104
+ }
105
+ return undefined;
106
+ }
107
+
108
+ /**
109
+ * Inserts or replaces an entry and persists the catalog.
110
+ * @param {CatalogEntry} entry - The entry to store.
111
+ * @returns {Promise<CatalogEntry>} - The stored entry.
112
+ */
113
+ async put(entry) {
114
+ const now = new Date().toISOString();
115
+ const existing = this.#entries.get(entry.infoHash);
116
+ const stored = {
117
+ ...existing,
118
+ ...entry,
119
+ createdAt: existing?.createdAt ?? now,
120
+ updatedAt: now,
121
+ };
122
+ this.#entries.set(stored.infoHash, stored);
123
+ await this.#flush();
124
+ return stored;
125
+ }
126
+
127
+ /**
128
+ * Removes an entry.
129
+ * @param {string} infoHash - The entry to remove.
130
+ * @returns {Promise<CatalogEntry | undefined>} - The removed entry.
131
+ */
132
+ async remove(infoHash) {
133
+ const key = infoHash?.toLowerCase();
134
+ const entry = this.#entries.get(key);
135
+ if (!entry) return undefined;
136
+ this.#entries.delete(key);
137
+ await this.#flush();
138
+ return entry;
139
+ }
140
+
141
+ /**
142
+ * Serialises the catalog, one write at a time.
143
+ * @returns {Promise<void>} - Resolves once written.
144
+ */
145
+ #flush() {
146
+ // Chain writes so two concurrent puts cannot interleave their renames.
147
+ this.#writing = this.#writing.then(async () => {
148
+ const body = JSON.stringify(
149
+ { version: 1, entries: [...this.#entries.values()] },
150
+ null,
151
+ 2,
152
+ );
153
+ await fs.mkdir(path.dirname(this.#file), { recursive: true });
154
+ await fs.writeFile(`${this.#file}.tmp`, body);
155
+ await fs.rename(`${this.#file}.tmp`, this.#file);
156
+ });
157
+ return this.#writing;
158
+ }
159
+ }