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/sources.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduled sources: upstreams that publish a new archive on a schedule, at a
|
|
3
|
+
* URL that encodes the date.
|
|
4
|
+
*
|
|
5
|
+
* This is a different shape from the origin checking in `origin.js`. That
|
|
6
|
+
* watches one fixed URL for its content changing. An upstream like
|
|
7
|
+
* `https://build.protomaps.com/20260806.pmtiles` never changes any given URL —
|
|
8
|
+
* it publishes a *new* one every day, and yesterday's stays exactly as it was.
|
|
9
|
+
* Watching for change would never fire; what is needed is to work out today's
|
|
10
|
+
* URL and see whether it exists yet.
|
|
11
|
+
*
|
|
12
|
+
* Each build therefore becomes its own archive with its own torrent and its own
|
|
13
|
+
* lifetime, which is what you want: old builds stay seedable for as long as
|
|
14
|
+
* anyone still wants them.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs/promises';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Expands date placeholders in a template.
|
|
22
|
+
*
|
|
23
|
+
* Supported: {YYYYMMDD} {YYYY-MM-DD} {YYYY} {MM} {DD}
|
|
24
|
+
* @param {string} template - The template string.
|
|
25
|
+
* @param {Date} date - The date to substitute.
|
|
26
|
+
* @returns {string} - The expanded string.
|
|
27
|
+
*/
|
|
28
|
+
export function expandTemplate(template, date) {
|
|
29
|
+
const yyyy = String(date.getUTCFullYear());
|
|
30
|
+
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
|
|
31
|
+
const dd = String(date.getUTCDate()).padStart(2, '0');
|
|
32
|
+
|
|
33
|
+
return template
|
|
34
|
+
.replaceAll('{YYYYMMDD}', `${yyyy}${mm}${dd}`)
|
|
35
|
+
.replaceAll('{YYYY-MM-DD}', `${yyyy}-${mm}-${dd}`)
|
|
36
|
+
.replaceAll('{YYYY}', yyyy)
|
|
37
|
+
.replaceAll('{MM}', mm)
|
|
38
|
+
.replaceAll('{DD}', dd);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The dates a source should currently be looking for, newest first.
|
|
43
|
+
*
|
|
44
|
+
* `offsetDays` handles upstreams that publish yesterday's build (protomaps
|
|
45
|
+
* does). `lookbackDays` covers the case where the daemon was down, or the
|
|
46
|
+
* upstream published late — without it, one missed poll loses that build
|
|
47
|
+
* permanently.
|
|
48
|
+
* @param {object} source - The source definition.
|
|
49
|
+
* @param {Date} [now] - Override the current time, for testing.
|
|
50
|
+
* @returns {Date[]} - Candidate dates.
|
|
51
|
+
*/
|
|
52
|
+
export function candidateDates(source, now = new Date()) {
|
|
53
|
+
const offset = source.offsetDays ?? 0;
|
|
54
|
+
const lookback = Math.max(0, source.lookbackDays ?? 3);
|
|
55
|
+
const dates = [];
|
|
56
|
+
for (let back = 0; back <= lookback; back++) {
|
|
57
|
+
const date = new Date(now);
|
|
58
|
+
date.setUTCDate(date.getUTCDate() + offset - back);
|
|
59
|
+
dates.push(date);
|
|
60
|
+
}
|
|
61
|
+
return dates;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Polls scheduled sources and imports whatever has appeared.
|
|
66
|
+
*/
|
|
67
|
+
export class ScheduledSourceManager {
|
|
68
|
+
#library;
|
|
69
|
+
#catalog;
|
|
70
|
+
#config;
|
|
71
|
+
#timer;
|
|
72
|
+
#running = false;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Creates the manager.
|
|
76
|
+
* @param {import('./library.js').Library} library - Where imports go.
|
|
77
|
+
* @param {import('./catalog.js').Catalog} catalog - Used to skip what we already have.
|
|
78
|
+
* @param {object} config - Resolved configuration.
|
|
79
|
+
*/
|
|
80
|
+
constructor(library, catalog, config) {
|
|
81
|
+
this.#library = library;
|
|
82
|
+
this.#catalog = catalog;
|
|
83
|
+
this.#config = config;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Starts polling.
|
|
88
|
+
* @returns {void}
|
|
89
|
+
*/
|
|
90
|
+
start() {
|
|
91
|
+
const sources = this.#config.sources ?? [];
|
|
92
|
+
if (sources.length === 0) return;
|
|
93
|
+
|
|
94
|
+
const intervalMs = (this.#config.sourceCheckIntervalHours ?? 6) * 3600 * 1000;
|
|
95
|
+
this.poll().catch((error) =>
|
|
96
|
+
console.error(`[source] initial poll failed: ${error.message}`),
|
|
97
|
+
);
|
|
98
|
+
this.#timer = setInterval(() => {
|
|
99
|
+
this.poll().catch((error) =>
|
|
100
|
+
console.error(`[source] poll failed: ${error.message}`),
|
|
101
|
+
);
|
|
102
|
+
}, intervalMs);
|
|
103
|
+
this.#timer.unref?.();
|
|
104
|
+
|
|
105
|
+
console.log(
|
|
106
|
+
`[source] following ${sources.length} scheduled source(s) every ${intervalMs / 3600000}h`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Polls every configured source once.
|
|
112
|
+
* @returns {Promise<object[]>} - Entries imported this pass.
|
|
113
|
+
*/
|
|
114
|
+
async poll() {
|
|
115
|
+
// Downloading a planet archive takes hours; a poll landing on top of one
|
|
116
|
+
// already in progress would start it again.
|
|
117
|
+
if (this.#running) {
|
|
118
|
+
console.log('[source] poll already in progress, skipping');
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
this.#running = true;
|
|
122
|
+
try {
|
|
123
|
+
const imported = [];
|
|
124
|
+
for (const source of this.#config.sources ?? []) {
|
|
125
|
+
imported.push(...(await this.#pollSource(source)));
|
|
126
|
+
}
|
|
127
|
+
return imported;
|
|
128
|
+
} finally {
|
|
129
|
+
this.#running = false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Polls one source across its candidate dates.
|
|
135
|
+
* @param {object} source - The source definition.
|
|
136
|
+
* @returns {Promise<object[]>} - Entries imported.
|
|
137
|
+
*/
|
|
138
|
+
async #pollSource(source) {
|
|
139
|
+
if (!source.url) {
|
|
140
|
+
console.error(`[source] ${source.name ?? 'unnamed'}: no url template`);
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const imported = [];
|
|
145
|
+
for (const date of candidateDates(source)) {
|
|
146
|
+
const url = expandTemplate(source.url, date);
|
|
147
|
+
|
|
148
|
+
// Already have it: this is the common case on every poll after the first.
|
|
149
|
+
if (this.#catalog.findBySource(url)) continue;
|
|
150
|
+
|
|
151
|
+
const exists = await this.#exists(url);
|
|
152
|
+
if (!exists) continue;
|
|
153
|
+
|
|
154
|
+
const filename = source.filename
|
|
155
|
+
? expandTemplate(source.filename, date)
|
|
156
|
+
: undefined;
|
|
157
|
+
|
|
158
|
+
console.log(`[source] ${source.name ?? url}: found ${url}, importing`);
|
|
159
|
+
try {
|
|
160
|
+
const entry = await this.#library.addRemoteArchive(url, {
|
|
161
|
+
name: filename,
|
|
162
|
+
category: source.category,
|
|
163
|
+
savePath: source.savePath,
|
|
164
|
+
trackers: source.trackers,
|
|
165
|
+
pieceLength: source.pieceLength,
|
|
166
|
+
retain: source.retain !== false,
|
|
167
|
+
comment: source.comment
|
|
168
|
+
? `${source.comment} ${expandTemplate('{YYYY-MM-DD}', date)}`
|
|
169
|
+
: undefined,
|
|
170
|
+
});
|
|
171
|
+
imported.push(entry);
|
|
172
|
+
console.log(
|
|
173
|
+
`[source] ${source.name ?? url}: imported ${entry.name} (${entry.infoHash})`,
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
if (source.latestLink) {
|
|
177
|
+
await this.#linkLatest(source, entry);
|
|
178
|
+
}
|
|
179
|
+
} catch (error) {
|
|
180
|
+
console.error(`[source] ${url}: ${error.message}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return imported;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Does this URL exist yet?
|
|
188
|
+
*
|
|
189
|
+
* A build that has not been published returns 404, which is the normal case
|
|
190
|
+
* for most of the day — so this must not be noisy.
|
|
191
|
+
* @param {string} url - The URL to test.
|
|
192
|
+
* @returns {Promise<boolean>} - True if it is there.
|
|
193
|
+
*/
|
|
194
|
+
async #exists(url) {
|
|
195
|
+
try {
|
|
196
|
+
const response = await fetch(url, { method: 'HEAD' });
|
|
197
|
+
if (response.ok) return true;
|
|
198
|
+
// Some origins do not answer HEAD; a one-byte range asks the same
|
|
199
|
+
// question without transferring anything.
|
|
200
|
+
if (response.status === 405 || response.status === 501) {
|
|
201
|
+
const ranged = await fetch(url, { headers: { range: 'bytes=0-0' } });
|
|
202
|
+
return ranged.ok;
|
|
203
|
+
}
|
|
204
|
+
return false;
|
|
205
|
+
} catch {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Points a stable "latest" name at the newest build.
|
|
212
|
+
*
|
|
213
|
+
* A symlink keeps the dated file as the real one, so it stays seedable under
|
|
214
|
+
* its own torrent while consumers can still reference a fixed path.
|
|
215
|
+
* @param {object} source - The source definition.
|
|
216
|
+
* @param {object} entry - The freshly imported entry.
|
|
217
|
+
* @returns {Promise<void>} - Resolves once linked, or logs and continues.
|
|
218
|
+
*/
|
|
219
|
+
async #linkLatest(source, entry) {
|
|
220
|
+
const target = entry.retainedAt ?? path.join(entry.savePath, entry.name);
|
|
221
|
+
const link = path.isAbsolute(source.latestLink)
|
|
222
|
+
? source.latestLink
|
|
223
|
+
: path.join(path.dirname(target), source.latestLink);
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
await fs.rm(link, { force: true });
|
|
227
|
+
await fs.symlink(target, link);
|
|
228
|
+
console.log(`[source] latest -> ${path.basename(target)}`);
|
|
229
|
+
} catch (error) {
|
|
230
|
+
// Windows needs elevation or developer mode for symlinks; not fatal.
|
|
231
|
+
console.warn(`[source] could not update ${link}: ${error.message}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Stops polling.
|
|
237
|
+
* @returns {void}
|
|
238
|
+
*/
|
|
239
|
+
stop() {
|
|
240
|
+
if (this.#timer !== undefined) {
|
|
241
|
+
clearInterval(this.#timer);
|
|
242
|
+
this.#timer = undefined;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { parseFeed } from './feed.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Follows other nodes' feeds and picks up what they publish.
|
|
5
|
+
*
|
|
6
|
+
* Two modes, and the difference matters:
|
|
7
|
+
*
|
|
8
|
+
* mirror — join the torrent and download the whole archive. The node becomes
|
|
9
|
+
* a full seeder, adding redundancy to the swarm. Costs the archive's
|
|
10
|
+
* full size in disk.
|
|
11
|
+
*
|
|
12
|
+
* cache — record the torrent but download nothing. A tile server reads byte
|
|
13
|
+
* ranges from it on demand through pmtiles-torrent, so disk use
|
|
14
|
+
* tracks what is actually viewed rather than what exists. The node
|
|
15
|
+
* still seeds the pieces it has picked up along the way.
|
|
16
|
+
*
|
|
17
|
+
* Cache mode is what makes a 700 GiB planet archive usable on a small server.
|
|
18
|
+
*/
|
|
19
|
+
export class SubscriptionManager {
|
|
20
|
+
#library;
|
|
21
|
+
#config;
|
|
22
|
+
#timer;
|
|
23
|
+
#seen = new Set();
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Creates the manager.
|
|
27
|
+
* @param {import('./library.js').Library} library - Where new torrents go.
|
|
28
|
+
* @param {object} config - Resolved configuration.
|
|
29
|
+
*/
|
|
30
|
+
constructor(library, config) {
|
|
31
|
+
this.#library = library;
|
|
32
|
+
this.#config = config;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Starts polling the configured feeds.
|
|
37
|
+
* @returns {void}
|
|
38
|
+
*/
|
|
39
|
+
start() {
|
|
40
|
+
const feeds = this.#config.subscriptions ?? [];
|
|
41
|
+
if (feeds.length === 0) return;
|
|
42
|
+
|
|
43
|
+
const intervalMs = (this.#config.subscriptionIntervalSeconds ?? 900) * 1000;
|
|
44
|
+
// Poll once at startup, then on the interval.
|
|
45
|
+
this.refresh().catch((error) =>
|
|
46
|
+
console.error(`[feed] initial refresh failed: ${error.message}`),
|
|
47
|
+
);
|
|
48
|
+
this.#timer = setInterval(() => {
|
|
49
|
+
this.refresh().catch((error) =>
|
|
50
|
+
console.error(`[feed] refresh failed: ${error.message}`),
|
|
51
|
+
);
|
|
52
|
+
}, intervalMs);
|
|
53
|
+
this.#timer.unref?.();
|
|
54
|
+
|
|
55
|
+
console.log(
|
|
56
|
+
`[feed] following ${feeds.length} feed(s) every ${intervalMs / 1000}s`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Polls every configured feed once.
|
|
62
|
+
* @returns {Promise<object[]>} - The entries added this pass.
|
|
63
|
+
*/
|
|
64
|
+
async refresh() {
|
|
65
|
+
const added = [];
|
|
66
|
+
for (const subscription of this.#config.subscriptions ?? []) {
|
|
67
|
+
try {
|
|
68
|
+
added.push(...(await this.#poll(subscription)));
|
|
69
|
+
} catch (error) {
|
|
70
|
+
console.error(`[feed] ${subscription.url}: ${error.message}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return added;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Polls one feed.
|
|
78
|
+
* @param {object} subscription - Entry of {url, mode, category, filter}.
|
|
79
|
+
* @returns {Promise<object[]>} - Entries added from this feed.
|
|
80
|
+
*/
|
|
81
|
+
async #poll(subscription) {
|
|
82
|
+
const response = await fetch(subscription.url, {
|
|
83
|
+
headers: { accept: 'application/rss+xml, application/xml, text/xml' },
|
|
84
|
+
});
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
throw new Error(`${response.status} ${response.statusText}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const items = parseFeed(await response.text());
|
|
90
|
+
const added = [];
|
|
91
|
+
|
|
92
|
+
for (const item of items) {
|
|
93
|
+
// A regex filter lets one feed serve several consumers with different
|
|
94
|
+
// appetites, e.g. only taking Europe extracts.
|
|
95
|
+
if (subscription.filter && !new RegExp(subscription.filter, 'i').test(item.title)) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const marker = item.infoHash ?? item.magnet ?? item.torrentUrl;
|
|
99
|
+
if (this.#seen.has(marker)) continue;
|
|
100
|
+
this.#seen.add(marker);
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const entry = await this.#add(item, subscription);
|
|
104
|
+
if (entry) {
|
|
105
|
+
added.push(entry);
|
|
106
|
+
console.log(
|
|
107
|
+
`[feed] ${subscription.mode ?? 'cache'} ${entry.name} from ${subscription.url}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.error(`[feed] could not add "${item.title}": ${error.message}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return added;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Adds one feed item to the library.
|
|
119
|
+
* @param {import('./feed.js').FeedItem} item - The item.
|
|
120
|
+
* @param {object} subscription - The subscription it came from.
|
|
121
|
+
* @returns {Promise<object | null>} - The catalog entry.
|
|
122
|
+
*/
|
|
123
|
+
async #add(item, subscription) {
|
|
124
|
+
const options = {
|
|
125
|
+
category: subscription.category ?? item.category,
|
|
126
|
+
savePath: subscription.savePath,
|
|
127
|
+
// Cache mode joins the swarm without pulling the whole archive; the
|
|
128
|
+
// pieces it does hold are still served to other peers.
|
|
129
|
+
paused: (subscription.mode ?? 'cache') === 'cache',
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
if (item.torrentUrl) {
|
|
133
|
+
const response = await fetch(item.torrentUrl);
|
|
134
|
+
if (!response.ok) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`torrent fetch failed: ${response.status} ${response.statusText}`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
const torrentFile = new Uint8Array(await response.arrayBuffer());
|
|
140
|
+
return this.#library.addExistingTorrent({ torrentFile }, options);
|
|
141
|
+
}
|
|
142
|
+
if (item.magnet) {
|
|
143
|
+
return this.#library.addExistingTorrent({ magnet: item.magnet }, options);
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Stops polling.
|
|
150
|
+
* @returns {void}
|
|
151
|
+
*/
|
|
152
|
+
stop() {
|
|
153
|
+
if (this.#timer !== undefined) {
|
|
154
|
+
clearInterval(this.#timer);
|
|
155
|
+
this.#timer = undefined;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
package/src/tilejson.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { TileStore } from './tiles.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Builds TileJSON for an archive.
|
|
5
|
+
*
|
|
6
|
+
* Everything here comes from the catalog rather than from the archive itself,
|
|
7
|
+
* because the probe already recorded it when the archive was added. That is not
|
|
8
|
+
* just a saving: a node in cache mode holds almost none of the archive, so
|
|
9
|
+
* reading the header to answer a TileJSON request would mean pulling pieces out
|
|
10
|
+
* of the swarm before a map has asked for a single tile.
|
|
11
|
+
*
|
|
12
|
+
* @see https://github.com/mapbox/tilejson-spec/tree/master/3.0.0
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Tile format to the extension used in the tile URL template. */
|
|
16
|
+
const URL_EXTENSION = {
|
|
17
|
+
pbf: 'pbf',
|
|
18
|
+
png: 'png',
|
|
19
|
+
jpeg: 'jpg',
|
|
20
|
+
webp: 'webp',
|
|
21
|
+
avif: 'avif',
|
|
22
|
+
mlt: 'mlt',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Builds the TileJSON document for one catalog entry.
|
|
27
|
+
* @param {object} entry - Catalog entry.
|
|
28
|
+
* @param {string} baseUrl - Public base URL, without a trailing slash.
|
|
29
|
+
* @returns {object} - A TileJSON 3.0.0 document.
|
|
30
|
+
*/
|
|
31
|
+
export function buildTileJson(entry, baseUrl) {
|
|
32
|
+
const summary = entry.pmtiles ?? {};
|
|
33
|
+
const extension = URL_EXTENSION[summary.format] ?? 'bin';
|
|
34
|
+
const root = `${baseUrl}/archives/${entry.infoHash}`;
|
|
35
|
+
|
|
36
|
+
const doc = {
|
|
37
|
+
tilejson: '3.0.0',
|
|
38
|
+
scheme: 'xyz',
|
|
39
|
+
tiles: [`${root}/{z}/{x}/{y}.${extension}`],
|
|
40
|
+
name: summary.name ?? entry.name,
|
|
41
|
+
minzoom: summary.minZoom ?? 0,
|
|
42
|
+
maxzoom: summary.maxZoom ?? 14,
|
|
43
|
+
bounds: summary.bounds ?? [-180, -85.051129, 180, 85.051129],
|
|
44
|
+
center: summary.center,
|
|
45
|
+
// The infohash is a content hash, so it doubles as a version: any change to
|
|
46
|
+
// the archive produces a different one, and the tile URLs change with it.
|
|
47
|
+
version: `1.0.0+${entry.infoHash.slice(0, 12)}`,
|
|
48
|
+
torrent: buildTorrentBlock(entry, root),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
if (summary.description) doc.description = summary.description;
|
|
52
|
+
if (summary.attribution) doc.attribution = summary.attribution;
|
|
53
|
+
if (summary.vectorLayers) doc.vector_layers = summary.vectorLayers;
|
|
54
|
+
if (summary.format === 'pbf') doc.format = 'pbf';
|
|
55
|
+
else if (summary.format) doc.format = summary.format;
|
|
56
|
+
|
|
57
|
+
// A TileJSON consumer that ignores unknown members sees a perfectly ordinary
|
|
58
|
+
// document; dropping empty keys keeps it that way.
|
|
59
|
+
for (const [key, value] of Object.entries(doc)) {
|
|
60
|
+
if (value === undefined) delete doc[key];
|
|
61
|
+
}
|
|
62
|
+
return doc;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Builds the non-standard `torrent` member.
|
|
67
|
+
*
|
|
68
|
+
* This is the progressive-enhancement hook. TileJSON's spec allows unknown
|
|
69
|
+
* members and MapLibre's style-spec permits arbitrary source properties, so a
|
|
70
|
+
* plain client — maplibre-gl-js, Leaflet, anything — ignores this entirely and
|
|
71
|
+
* fetches tiles over HTTP as usual. A torrent-aware client reads it, joins the
|
|
72
|
+
* swarm, and serves the same tiles from pieces instead, falling back to the
|
|
73
|
+
* HTTP URLs whenever the swarm cannot answer.
|
|
74
|
+
*
|
|
75
|
+
* One URL works for both, which is the property that makes it worth having: the
|
|
76
|
+
* style does not have to know which kind of client will load it.
|
|
77
|
+
* @param {object} entry - Catalog entry.
|
|
78
|
+
* @param {string} root - This archive's URL root.
|
|
79
|
+
* @returns {object} - The torrent block.
|
|
80
|
+
*/
|
|
81
|
+
function buildTorrentBlock(entry, root) {
|
|
82
|
+
const block = {
|
|
83
|
+
infohash: entry.infoHash,
|
|
84
|
+
magnet: entry.magnet,
|
|
85
|
+
torrent: `${root}/archive.torrent`,
|
|
86
|
+
name: entry.name,
|
|
87
|
+
size: entry.size,
|
|
88
|
+
};
|
|
89
|
+
if (entry.webSeeds?.length) block.webseeds = entry.webSeeds;
|
|
90
|
+
// A mutable torrent is addressed by public key rather than by infohash, so a
|
|
91
|
+
// client that understands BEP 46 can follow updates instead of pinning to the
|
|
92
|
+
// version this document was generated from.
|
|
93
|
+
if (entry.mutable?.publicKey) {
|
|
94
|
+
block.mutable = {
|
|
95
|
+
publicKey: entry.mutable.publicKey,
|
|
96
|
+
salt: entry.mutable.salt,
|
|
97
|
+
seq: entry.mutable.seq,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return block;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Checks a requested extension against what the archive actually holds.
|
|
105
|
+
* @param {object} entry - Catalog entry.
|
|
106
|
+
* @param {string} extension - Requested extension, without a dot.
|
|
107
|
+
* @returns {boolean} - Whether it matches.
|
|
108
|
+
*/
|
|
109
|
+
export function extensionMatches(entry, extension) {
|
|
110
|
+
const accepted = TileStore.extensionsFor(entry.pmtiles?.format);
|
|
111
|
+
return accepted.includes(extension.toLowerCase());
|
|
112
|
+
}
|