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/feed.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RSS publishing and subscribing.
|
|
3
|
+
*
|
|
4
|
+
* The feed is deliberately plain RSS 2.0 with a torrent enclosure, because
|
|
5
|
+
* that is what qBittorrent's built-in RSS auto-downloader already understands
|
|
6
|
+
* — an operator can subscribe to a pmtiles-swarm feed today, with no new
|
|
7
|
+
* software, and have new archives download automatically.
|
|
8
|
+
*
|
|
9
|
+
* On top of that it carries a small namespaced extension describing the map:
|
|
10
|
+
* coverage, zoom range and tile format. That is the part a generic torrent feed
|
|
11
|
+
* cannot offer, and it is what lets a subscriber decide whether it wants a
|
|
12
|
+
* 72 GiB download before starting one.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const PMTILES_NS = 'https://github.com/TechIdiots-LLC/pmtiles-swarm/ns/1.0';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Escapes text for XML content.
|
|
19
|
+
* @param {unknown} value - The value to escape.
|
|
20
|
+
* @returns {string} - Escaped text.
|
|
21
|
+
*/
|
|
22
|
+
function xml(value) {
|
|
23
|
+
return String(value ?? '')
|
|
24
|
+
.replace(/&/g, '&')
|
|
25
|
+
.replace(/</g, '<')
|
|
26
|
+
.replace(/>/g, '>')
|
|
27
|
+
.replace(/"/g, '"');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Renders the catalog as an RSS 2.0 feed.
|
|
32
|
+
* @param {object[]} entries - Catalog entries to publish.
|
|
33
|
+
* @param {object} options - Feed options.
|
|
34
|
+
* @param {string} options.title - Feed title.
|
|
35
|
+
* @param {string} options.baseUrl - Public base URL for building links.
|
|
36
|
+
* @param {string} [options.description] - Feed description.
|
|
37
|
+
* @param {string} [options.copyright] - Rights statement for the channel.
|
|
38
|
+
* @param {string} [options.category] - Category this feed covers.
|
|
39
|
+
* @param {number} [options.maxItems] - Keep only this many newest items. Zero or absent means all.
|
|
40
|
+
* @returns {string} - The feed XML.
|
|
41
|
+
*/
|
|
42
|
+
export function renderFeed(entries, options) {
|
|
43
|
+
const self = options.category
|
|
44
|
+
? `${options.baseUrl}/feed/${encodeURIComponent(options.category)}.xml`
|
|
45
|
+
: `${options.baseUrl}/feed.xml`;
|
|
46
|
+
|
|
47
|
+
// Entries arrive newest first, so a cap keeps the most recent builds. Set it
|
|
48
|
+
// with a subscriber's poll interval in mind: a feed holding one item is only
|
|
49
|
+
// safe if every subscriber polls more often than you publish, or a consumer
|
|
50
|
+
// that was down overnight silently misses a build.
|
|
51
|
+
const shown =
|
|
52
|
+
options.maxItems > 0 ? entries.slice(0, options.maxItems) : entries;
|
|
53
|
+
|
|
54
|
+
const items = shown
|
|
55
|
+
.map((entry) => renderItem(entry, options.baseUrl))
|
|
56
|
+
.join('\n');
|
|
57
|
+
|
|
58
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
59
|
+
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:pmtiles="${PMTILES_NS}">
|
|
60
|
+
<channel>
|
|
61
|
+
<title>${xml(options.title)}</title>
|
|
62
|
+
<link>${xml(options.baseUrl)}</link>
|
|
63
|
+
<description>${xml(options.description ?? 'PMTiles map archives distributed over BitTorrent')}</description>
|
|
64
|
+
${options.copyright ? ` <copyright>${xml(options.copyright)}</copyright>\n` : ''} <generator>pmtiles-swarm</generator>
|
|
65
|
+
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
|
|
66
|
+
<atom:link href="${xml(self)}" rel="self" type="application/rss+xml"/>
|
|
67
|
+
${items}
|
|
68
|
+
</channel>
|
|
69
|
+
</rss>
|
|
70
|
+
`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Renders one catalog entry as a feed item.
|
|
75
|
+
* @param {object} entry - The catalog entry.
|
|
76
|
+
* @param {string} baseUrl - Public base URL.
|
|
77
|
+
* @returns {string} - The item XML.
|
|
78
|
+
*/
|
|
79
|
+
function renderItem(entry, baseUrl) {
|
|
80
|
+
const torrentUrl = `${baseUrl}/api/torrents/${entry.infoHash}/file`;
|
|
81
|
+
const map = entry.pmtiles;
|
|
82
|
+
|
|
83
|
+
const mapFields = map
|
|
84
|
+
? [
|
|
85
|
+
` <pmtiles:format>${xml(map.format)}</pmtiles:format>`,
|
|
86
|
+
` <pmtiles:minzoom>${xml(map.minZoom)}</pmtiles:minzoom>`,
|
|
87
|
+
` <pmtiles:maxzoom>${xml(map.maxZoom)}</pmtiles:maxzoom>`,
|
|
88
|
+
` <pmtiles:bounds>${xml((map.bounds ?? []).join(','))}</pmtiles:bounds>`,
|
|
89
|
+
map.tileCount
|
|
90
|
+
? ` <pmtiles:tiles>${xml(map.tileCount)}</pmtiles:tiles>`
|
|
91
|
+
: '',
|
|
92
|
+
map.attribution
|
|
93
|
+
? ` <pmtiles:attribution>${xml(map.attribution)}</pmtiles:attribution>`
|
|
94
|
+
: '',
|
|
95
|
+
]
|
|
96
|
+
.filter(Boolean)
|
|
97
|
+
.join('\n')
|
|
98
|
+
: '';
|
|
99
|
+
|
|
100
|
+
const summary = map
|
|
101
|
+
? `${map.format} tiles, zoom ${map.minZoom}-${map.maxZoom}, ${formatBytes(entry.size)}`
|
|
102
|
+
: formatBytes(entry.size);
|
|
103
|
+
|
|
104
|
+
return ` <item>
|
|
105
|
+
<title>${xml(entry.name)}</title>
|
|
106
|
+
<link>${xml(torrentUrl)}</link>
|
|
107
|
+
<guid isPermaLink="false">${xml(entry.infoHash)}</guid>
|
|
108
|
+
<pubDate>${new Date(entry.createdAt).toUTCString()}</pubDate>
|
|
109
|
+
<description>${xml(map?.description ?? summary)}</description>
|
|
110
|
+
${entry.category ? ` <category>${xml(entry.category)}</category>` : ''}
|
|
111
|
+
<enclosure url="${xml(torrentUrl)}" length="${xml(entry.size)}" type="application/x-bittorrent"/>
|
|
112
|
+
<pmtiles:infohash>${xml(entry.infoHash)}</pmtiles:infohash>
|
|
113
|
+
<pmtiles:magnet>${xml(entry.magnet)}</pmtiles:magnet>
|
|
114
|
+
${mapFields}
|
|
115
|
+
</item>`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Formats a byte count for human-readable descriptions.
|
|
120
|
+
* @param {number} bytes - The size.
|
|
121
|
+
* @returns {string} - A short label.
|
|
122
|
+
*/
|
|
123
|
+
export function formatBytes(bytes) {
|
|
124
|
+
if (!bytes) return 'unknown size';
|
|
125
|
+
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
|
|
126
|
+
let value = bytes;
|
|
127
|
+
let unit = 0;
|
|
128
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
129
|
+
value /= 1024;
|
|
130
|
+
unit++;
|
|
131
|
+
}
|
|
132
|
+
return `${value.toFixed(unit === 0 ? 0 : 2)} ${units[unit]}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* One item pulled from a subscribed feed.
|
|
137
|
+
* @typedef {object} FeedItem
|
|
138
|
+
* @property {string} title - Item title.
|
|
139
|
+
* @property {string} [infoHash] - Infohash, when the feed states it.
|
|
140
|
+
* @property {string} [magnet] - Magnet URI, when present.
|
|
141
|
+
* @property {string} [torrentUrl] - URL of a .torrent enclosure.
|
|
142
|
+
* @property {string} [category] - Item category.
|
|
143
|
+
*/
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Parses a subscribed feed.
|
|
147
|
+
*
|
|
148
|
+
* Written against the shape of RSS rather than with a full XML parser: feeds
|
|
149
|
+
* in this ecosystem are simple, and this keeps the dependency list short. It
|
|
150
|
+
* reads any RSS feed carrying torrent enclosures or magnets, not only ours.
|
|
151
|
+
* @param {string} body - The feed XML.
|
|
152
|
+
* @returns {FeedItem[]} - The items found.
|
|
153
|
+
*/
|
|
154
|
+
export function parseFeed(body) {
|
|
155
|
+
const items = [];
|
|
156
|
+
const itemPattern = /<item[\s>][\s\S]*?<\/item>/gi;
|
|
157
|
+
|
|
158
|
+
for (const [block] of body.matchAll(itemPattern)) {
|
|
159
|
+
const title = tag(block, 'title');
|
|
160
|
+
const enclosure = /<enclosure\b[^>]*\burl=["']([^"']+)["'][^>]*>/i.exec(
|
|
161
|
+
block,
|
|
162
|
+
);
|
|
163
|
+
const enclosureType =
|
|
164
|
+
/<enclosure\b[^>]*\btype=["']([^"']+)["'][^>]*>/i.exec(block)?.[1] ?? '';
|
|
165
|
+
const link = tag(block, 'link');
|
|
166
|
+
const magnetTag = tag(block, 'pmtiles:magnet');
|
|
167
|
+
|
|
168
|
+
// A magnet can arrive in its own element, as the link, or as the enclosure.
|
|
169
|
+
const candidates = [magnetTag, link, enclosure?.[1]].filter(Boolean);
|
|
170
|
+
const magnet = candidates.find((value) => value.startsWith('magnet:'));
|
|
171
|
+
|
|
172
|
+
// Only treat an enclosure as a torrent if it says so or ends in .torrent;
|
|
173
|
+
// otherwise it may be an image or an audio file.
|
|
174
|
+
const torrentUrl = [enclosure?.[1], link]
|
|
175
|
+
.filter(Boolean)
|
|
176
|
+
.find(
|
|
177
|
+
(value) =>
|
|
178
|
+
!value.startsWith('magnet:') &&
|
|
179
|
+
(/bittorrent/i.test(enclosureType) ||
|
|
180
|
+
/\.torrent(\?|$)/i.test(value) ||
|
|
181
|
+
/\/file$/i.test(value)),
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
if (!magnet && !torrentUrl) continue;
|
|
185
|
+
|
|
186
|
+
items.push({
|
|
187
|
+
title: title ?? 'untitled',
|
|
188
|
+
infoHash: tag(block, 'pmtiles:infohash')?.toLowerCase(),
|
|
189
|
+
magnet,
|
|
190
|
+
torrentUrl,
|
|
191
|
+
category: tag(block, 'category'),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return items;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Reads the text content of the first matching element.
|
|
199
|
+
* @param {string} block - The XML fragment to search.
|
|
200
|
+
* @param {string} name - Element name.
|
|
201
|
+
* @returns {string | undefined} - Decoded text, if found.
|
|
202
|
+
*/
|
|
203
|
+
function tag(block, name) {
|
|
204
|
+
const pattern = new RegExp(
|
|
205
|
+
`<${name}\\b[^>]*>([\\s\\S]*?)</${name}>`,
|
|
206
|
+
'i',
|
|
207
|
+
);
|
|
208
|
+
const match = pattern.exec(block);
|
|
209
|
+
if (!match) return undefined;
|
|
210
|
+
return decode(match[1].trim());
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Decodes CDATA and the XML entities a feed may carry.
|
|
215
|
+
* @param {string} value - Raw element content.
|
|
216
|
+
* @returns {string} - Decoded text.
|
|
217
|
+
*/
|
|
218
|
+
function decode(value) {
|
|
219
|
+
return value
|
|
220
|
+
.replace(/^<!\[CDATA\[([\s\S]*?)\]\]>$/, '$1')
|
|
221
|
+
.replace(/</g, '<')
|
|
222
|
+
.replace(/>/g, '>')
|
|
223
|
+
.replace(/"/g, '"')
|
|
224
|
+
.replace(/'/g, "'")
|
|
225
|
+
.replace(/&/g, '&');
|
|
226
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A PMTiles source reading a local file through a descriptor.
|
|
5
|
+
*
|
|
6
|
+
* Used both by the prober, which only touches the header and directories, and
|
|
7
|
+
* by the tile server when this node holds a complete copy of an archive. A
|
|
8
|
+
* complete local file needs no swarm involvement at all: reading it directly is
|
|
9
|
+
* both faster and simpler than routing through a torrent engine that would only
|
|
10
|
+
* hand back pieces it already has on disk.
|
|
11
|
+
*/
|
|
12
|
+
export class NodeFileSource {
|
|
13
|
+
#fd;
|
|
14
|
+
#path;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Opens the file.
|
|
18
|
+
* @param {string} filePath - Path to the archive.
|
|
19
|
+
*/
|
|
20
|
+
constructor(filePath) {
|
|
21
|
+
this.#path = filePath;
|
|
22
|
+
this.#fd = fs.openSync(filePath, 'r');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A stable key for PMTiles' internal caching.
|
|
27
|
+
* @returns {string} - The file path.
|
|
28
|
+
*/
|
|
29
|
+
getKey() {
|
|
30
|
+
return this.#path;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Reads a byte range.
|
|
35
|
+
* @param {number} offset - Byte offset.
|
|
36
|
+
* @param {number} length - Byte count.
|
|
37
|
+
* @returns {Promise<{data: ArrayBuffer}>} - The bytes.
|
|
38
|
+
*/
|
|
39
|
+
async getBytes(offset, length) {
|
|
40
|
+
const buffer = Buffer.alloc(length);
|
|
41
|
+
const bytesRead = await new Promise((resolve, reject) => {
|
|
42
|
+
fs.read(this.#fd, buffer, 0, length, offset, (error, read) =>
|
|
43
|
+
error ? reject(error) : resolve(read),
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
// A short read at the end of the file is normal: PMTiles over-reads the
|
|
47
|
+
// header. Hand back only what exists.
|
|
48
|
+
const slice = buffer.subarray(0, bytesRead);
|
|
49
|
+
return {
|
|
50
|
+
data: slice.buffer.slice(
|
|
51
|
+
slice.byteOffset,
|
|
52
|
+
slice.byteOffset + slice.byteLength,
|
|
53
|
+
),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Closes the descriptor.
|
|
59
|
+
* @returns {void}
|
|
60
|
+
*/
|
|
61
|
+
close() {
|
|
62
|
+
if (this.#fd !== undefined) {
|
|
63
|
+
fs.closeSync(this.#fd);
|
|
64
|
+
this.#fd = undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
import { createApp } from './api.js';
|
|
5
|
+
import { Catalog } from './catalog.js';
|
|
6
|
+
import { loadConfig } from './config.js';
|
|
7
|
+
import { LibtorrentEngine } from './engines/libtorrent.js';
|
|
8
|
+
import { QBittorrentEngine } from './engines/qbittorrent.js';
|
|
9
|
+
import { WebTorrentSeedEngine } from './engines/webtorrent.js';
|
|
10
|
+
import { Library } from './library.js';
|
|
11
|
+
import { ScheduledSourceManager } from './sources.js';
|
|
12
|
+
import { SubscriptionManager } from './subscriptions.js';
|
|
13
|
+
import { TileStore } from './tiles.js';
|
|
14
|
+
import { WarmRunner } from './warm.js';
|
|
15
|
+
import { WatchManager } from './watch.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Builds the seeding engine named by the config.
|
|
19
|
+
* @param {object} config - Resolved configuration.
|
|
20
|
+
* @returns {import('./engines/types.js').SeedEngine} - The engine.
|
|
21
|
+
*/
|
|
22
|
+
function createEngine(config) {
|
|
23
|
+
switch (config.engine) {
|
|
24
|
+
case 'qbittorrent':
|
|
25
|
+
return new QBittorrentEngine(config.qbittorrent);
|
|
26
|
+
case 'libtorrent':
|
|
27
|
+
return new LibtorrentEngine({
|
|
28
|
+
savePath: config.libtorrent?.savePath ?? config.webtorrent.savePath,
|
|
29
|
+
resumeDir: config.libtorrent?.resumeDir,
|
|
30
|
+
python: config.libtorrent?.python,
|
|
31
|
+
maxConnections: config.maxConnections,
|
|
32
|
+
listen: config.libtorrent?.listen,
|
|
33
|
+
});
|
|
34
|
+
case 'webtorrent':
|
|
35
|
+
return new WebTorrentSeedEngine({
|
|
36
|
+
savePath: config.webtorrent.savePath,
|
|
37
|
+
clientOptions: config.webtorrent.clientOptions,
|
|
38
|
+
maxConnections: config.maxConnections,
|
|
39
|
+
});
|
|
40
|
+
default:
|
|
41
|
+
throw new Error(
|
|
42
|
+
`unknown engine "${config.engine}"; expected libtorrent, qbittorrent or webtorrent`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Starts the daemon.
|
|
49
|
+
* @returns {Promise<void>} - Resolves once listening.
|
|
50
|
+
*/
|
|
51
|
+
async function main() {
|
|
52
|
+
const { values } = parseArgs({
|
|
53
|
+
options: {
|
|
54
|
+
config: { type: 'string', short: 'c' },
|
|
55
|
+
port: { type: 'string', short: 'p' },
|
|
56
|
+
help: { type: 'boolean', short: 'h' },
|
|
57
|
+
},
|
|
58
|
+
allowPositionals: false,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
if (values.help) {
|
|
62
|
+
console.log(`pmtiles-swarm — BitTorrent distribution for PMTiles archives
|
|
63
|
+
|
|
64
|
+
--config, -c path to a JSON config file
|
|
65
|
+
--port, -p override the listen port
|
|
66
|
+
--help, -h this message
|
|
67
|
+
|
|
68
|
+
Environment: PMTILES_SWARM_PORT, PMTILES_SWARM_DATA_DIR, PMTILES_SWARM_ENGINE,
|
|
69
|
+
PMTILES_SWARM_QBT_URL, PMTILES_SWARM_QBT_USERNAME, PMTILES_SWARM_QBT_PASSWORD,
|
|
70
|
+
PMTILES_SWARM_PUBLIC_URL
|
|
71
|
+
`);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const config = await loadConfig(values.config);
|
|
76
|
+
if (values.port) config.port = Number(values.port);
|
|
77
|
+
|
|
78
|
+
await fs.mkdir(config.dataDir, { recursive: true });
|
|
79
|
+
if (config.engine === 'webtorrent') {
|
|
80
|
+
await fs.mkdir(config.webtorrent.savePath, { recursive: true });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const catalog = new Catalog(config.dataDir);
|
|
84
|
+
await catalog.load();
|
|
85
|
+
|
|
86
|
+
const engine = createEngine(config);
|
|
87
|
+
try {
|
|
88
|
+
await engine.connect();
|
|
89
|
+
console.log(`[engine] ${engine.name} ready`);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
// A dead engine should not stop the daemon: the catalog and feed still
|
|
92
|
+
// work, and the engine may come back.
|
|
93
|
+
console.error(`[engine] ${engine.name} unavailable: ${error.message}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const library = new Library({ catalog, engine, config });
|
|
97
|
+
const subscriptions = new SubscriptionManager(library, config);
|
|
98
|
+
const watch = new WatchManager(library);
|
|
99
|
+
const sources = new ScheduledSourceManager(library, catalog, config);
|
|
100
|
+
|
|
101
|
+
const tiles = new TileStore({ catalog, engine, config });
|
|
102
|
+
const warm = new WarmRunner(tiles);
|
|
103
|
+
|
|
104
|
+
const app = createApp({
|
|
105
|
+
library,
|
|
106
|
+
catalog,
|
|
107
|
+
engine,
|
|
108
|
+
subscriptions,
|
|
109
|
+
tiles,
|
|
110
|
+
warm,
|
|
111
|
+
config,
|
|
112
|
+
});
|
|
113
|
+
const server = app.listen(config.port, config.host, () => {
|
|
114
|
+
console.log(
|
|
115
|
+
`[http] listening on http://${config.host}:${config.port} (${catalog.list().length} archives)`,
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
watch.start(config.watch);
|
|
120
|
+
subscriptions.start();
|
|
121
|
+
sources.start();
|
|
122
|
+
|
|
123
|
+
// Watch the sources archives were built from. A changed source does not
|
|
124
|
+
// invalidate its torrent, but it does mean any web seed pointing there will
|
|
125
|
+
// fail hash verification for every peer that tries it.
|
|
126
|
+
let originTimer;
|
|
127
|
+
if (config.originCheckIntervalSeconds > 0) {
|
|
128
|
+
const intervalMs = config.originCheckIntervalSeconds * 1000;
|
|
129
|
+
const runCheck = () =>
|
|
130
|
+
library
|
|
131
|
+
.checkAllOrigins()
|
|
132
|
+
.then((changed) => {
|
|
133
|
+
if (changed.length > 0) {
|
|
134
|
+
console.warn(
|
|
135
|
+
`[origin] ${changed.length} archive(s) no longer match their source`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
.catch((error) =>
|
|
140
|
+
console.error(`[origin] check failed: ${error.message}`),
|
|
141
|
+
);
|
|
142
|
+
runCheck();
|
|
143
|
+
originTimer = setInterval(runCheck, intervalMs);
|
|
144
|
+
originTimer.unref?.();
|
|
145
|
+
console.log(
|
|
146
|
+
`[origin] checking sources every ${config.originCheckIntervalSeconds}s`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Shuts everything down in order, so trackers see a clean stop.
|
|
152
|
+
* @param {string} signal - The signal received.
|
|
153
|
+
* @returns {Promise<void>} - Resolves once stopped.
|
|
154
|
+
*/
|
|
155
|
+
const shutdown = async (signal) => {
|
|
156
|
+
console.log(`\n[shutdown] ${signal}`);
|
|
157
|
+
if (originTimer) clearInterval(originTimer);
|
|
158
|
+
sources.stop();
|
|
159
|
+
subscriptions.stop();
|
|
160
|
+
await watch.stop().catch(() => {});
|
|
161
|
+
await new Promise((resolve) => server.close(resolve));
|
|
162
|
+
warm.stop();
|
|
163
|
+
// Before the engine, so readers let go of their torrents while the client
|
|
164
|
+
// that owns them is still alive.
|
|
165
|
+
await tiles.close().catch(() => {});
|
|
166
|
+
await engine.destroy().catch(() => {});
|
|
167
|
+
process.exit(0);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
171
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
main().catch((error) => {
|
|
175
|
+
console.error(error.stack ?? error.message);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
});
|