pmtiles-swarm 0.46.0 โ 0.48.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 +44 -0
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/api.js +62 -0
- package/src/web/index.html +80 -59
- package/src/web/public.html +10 -3
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,50 @@
|
|
|
7
7
|
### ๐ Bug fixes
|
|
8
8
|
- _...Add new stuff here..._
|
|
9
9
|
|
|
10
|
+
## 0.48.0
|
|
11
|
+
### โจ Features and improvements
|
|
12
|
+
- **An archive can now be read as a file, by byte range.** `GET /archives/<infohash>/archive.pmtiles`
|
|
13
|
+
answers `Accept-Ranges: bytes`, a `206` with `Content-Range` for a range and `416` for one it cannot
|
|
14
|
+
satisfy. Every PMTiles consumer there is โ pmtiles.js, tileserver-gl, go-pmtiles, QGIS โ reads one
|
|
15
|
+
file over HTTP that way, and until now this node offered tiles, which is a different protocol: using
|
|
16
|
+
it as an origin meant a copy of the file somewhere else. This is the file, at an address that does
|
|
17
|
+
not depend on knowing where the node keeps it.
|
|
18
|
+
|
|
19
|
+
Complete archives only. A partial file answers a range with whatever is at that offset, which for a
|
|
20
|
+
torrent's sparse allocation is zeroes โ worse than a refusal, because it looks like data. An
|
|
21
|
+
incomplete archive answers `409` and says so.
|
|
22
|
+
|
|
23
|
+
It is also, by construction, a valid BEP 19 web seed: that specification is "an HTTP URL that serves
|
|
24
|
+
the file and honours Range". Publishing it as one is a separate decision and a later change โ a node
|
|
25
|
+
is not obliged to offer 700 GiB to strangers because it can.
|
|
26
|
+
|
|
27
|
+
### ๐ Bug fixes
|
|
28
|
+
|
|
29
|
+
## 0.47.1
|
|
30
|
+
### โจ Features and improvements
|
|
31
|
+
|
|
32
|
+
### ๐ Bug fixes
|
|
33
|
+
- **The magnet button copies a magnet, not a link to one.** `/latest/<category>/magnet` answers a
|
|
34
|
+
magnet URI as `text/plain`, and both pages were copying that endpoint's own address โ so what
|
|
35
|
+
landed on the clipboard was an `http://` URL, which a torrent client cannot open. The button now
|
|
36
|
+
fetches the endpoint and copies what it answers. Every other endpoint is still copied by address,
|
|
37
|
+
because for those the address is the thing.
|
|
38
|
+
|
|
39
|
+
## 0.47.0
|
|
40
|
+
### โจ Features and improvements
|
|
41
|
+
- **The console's Categories tab now matches the public page.** They were two views of one thing that
|
|
42
|
+
had drifted into two shapes: a label/value table with Copy and Open on every row here, links and a
|
|
43
|
+
printed style URL there, different labels for the same endpoints, and a preview link on one and not
|
|
44
|
+
the other.
|
|
45
|
+
|
|
46
|
+
Both now offer each endpoint the way it is used โ `.torrent` downloads, preview opens, and
|
|
47
|
+
TileJSON, magnet, RSS and the style URL copy. The console keeps `RSS, newest only`, which is its
|
|
48
|
+
own, and gains the preview link it was missing. The style URL is no longer printed on either page:
|
|
49
|
+
here it was truncated to 96 characters, which is long enough to fill the row and too short to be
|
|
50
|
+
the thing anybody wanted.
|
|
51
|
+
|
|
52
|
+
### ๐ Bug fixes
|
|
53
|
+
|
|
10
54
|
## 0.46.0
|
|
11
55
|
### โจ Features and improvements
|
|
12
56
|
- **Each category endpoint on the public page is now offered the way it is used.** A `.torrent` is a
|
package/README.md
CHANGED
|
@@ -783,6 +783,7 @@ which the endpoint answers 501.
|
|
|
783
783
|
| `GET` | `/health` | 200 when this node can serve, 503 when its engine cannot โ **public**, no credential, for a load balancer |
|
|
784
784
|
| `GET` | `/archives/:infoHash/ready` | Whether this node can serve _this_ archive yet: 200 ready, 503 not yet, 415 never โ **public** |
|
|
785
785
|
| `GET` | `/archives/:infoHash/archive.torrent` | The `.torrent` a torrent-aware client joins with โ **public** |
|
|
786
|
+
| `GET` | `/archives/:infoHash/archive.pmtiles` | The archive itself, by byte range โ what any PMTiles reader wants, and a valid web seed. Complete archives only โ **public** |
|
|
786
787
|
| `GET` | `/archives/:infoHash/preview` | Map preview for one archive โ admin, not public |
|
|
787
788
|
| `GET` | `/latest/:category/tiles.json`, `/:name.torrent`, `/magnet` | The newest build in a category โ **public**. The torrent name is yours to choose, so a link can read `planetiler-openmaptiles-latest.torrent`; it redirects to the immutable URL, which names the download after the build it actually is |
|
|
788
789
|
| `GET` | `/feed.xml`, `/feed/:category.xml`, `/latest/:category.xml` | RSS โ **public** |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmtiles-swarm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.48.0",
|
|
4
4
|
"description": "BitTorrent distribution for PMTiles map archives: create torrents, watch folders, publish and subscribe to RSS feeds, and seed through qBittorrent or an embedded client",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/api.js
CHANGED
|
@@ -2429,6 +2429,68 @@ export function createApp({
|
|
|
2429
2429
|
}),
|
|
2430
2430
|
);
|
|
2431
2431
|
|
|
2432
|
+
/**
|
|
2433
|
+
* The archive itself, by byte range.
|
|
2434
|
+
*
|
|
2435
|
+
* Every PMTiles consumer there is โ pmtiles.js, tileserver-gl, go-pmtiles,
|
|
2436
|
+
* QGIS โ reads one file over HTTP with Range requests. Until now this node
|
|
2437
|
+
* offered tiles, which is a different protocol, so using it as an origin
|
|
2438
|
+
* meant either its own tile endpoint or a copy of the file somewhere else.
|
|
2439
|
+
* This is the file, at an address that does not depend on knowing where the
|
|
2440
|
+
* node keeps it.
|
|
2441
|
+
*
|
|
2442
|
+
* It is also, by construction, a valid BEP 19 web seed: that specification
|
|
2443
|
+
* is "an HTTP URL that serves the file and honours Range", which is exactly
|
|
2444
|
+
* this. Publishing it is a separate decision and a later one โ a node is not
|
|
2445
|
+
* obliged to offer 700 GiB to strangers because it can.
|
|
2446
|
+
*
|
|
2447
|
+
* Complete archives only. A partial file would answer a range with whatever
|
|
2448
|
+
* happened to be at that offset, which for a torrent's sparse allocation is
|
|
2449
|
+
* zeroes: worse than a refusal, because it looks like data.
|
|
2450
|
+
*/
|
|
2451
|
+
app.get('/archives/:infoHash/archive.pmtiles', (req, res) => {
|
|
2452
|
+
const entry = catalog.get(req.params.infoHash);
|
|
2453
|
+
if (!entry) return res.status(404).json({ error: 'not found' });
|
|
2454
|
+
|
|
2455
|
+
if (entry.complete === false) {
|
|
2456
|
+
return res.status(409).json({
|
|
2457
|
+
error:
|
|
2458
|
+
'this archive is not complete here, so a byte range would answer ' +
|
|
2459
|
+
'with unwritten space rather than data',
|
|
2460
|
+
});
|
|
2461
|
+
}
|
|
2462
|
+
const file = entry.savePath ? path.join(entry.savePath, entry.name) : null;
|
|
2463
|
+
if (!file) return res.status(404).json({ error: 'no file for it here' });
|
|
2464
|
+
|
|
2465
|
+
// sendFile does the range work: 206 with Content-Range, 416 for one that
|
|
2466
|
+
// cannot be satisfied, HEAD, and the conditional headers a cache needs.
|
|
2467
|
+
// Reimplementing that is how a subtly wrong Content-Range gets shipped.
|
|
2468
|
+
res.sendFile(
|
|
2469
|
+
file,
|
|
2470
|
+
{
|
|
2471
|
+
acceptRanges: true,
|
|
2472
|
+
// A tile archive is immutable โ it is addressed by the hash of its
|
|
2473
|
+
// own contents, so a byte at an offset is the same byte for ever.
|
|
2474
|
+
maxAge: '1y',
|
|
2475
|
+
immutable: true,
|
|
2476
|
+
headers: { 'content-type': 'application/octet-stream' },
|
|
2477
|
+
},
|
|
2478
|
+
(error) => {
|
|
2479
|
+
if (!error || res.headersSent) return;
|
|
2480
|
+
const missing = error.code === 'ENOENT';
|
|
2481
|
+
// sendFile reports a range it cannot satisfy as a 416 on the error,
|
|
2482
|
+
// which is an answer rather than a fault โ passing it through as 500
|
|
2483
|
+
// would tell a client its request was our problem.
|
|
2484
|
+
const status = missing ? 404 : (error.status ?? 500);
|
|
2485
|
+
res.status(status).json({
|
|
2486
|
+
error: missing
|
|
2487
|
+
? 'the catalog has this archive but its file is not there'
|
|
2488
|
+
: error.message,
|
|
2489
|
+
});
|
|
2490
|
+
},
|
|
2491
|
+
);
|
|
2492
|
+
});
|
|
2493
|
+
|
|
2432
2494
|
app.get(
|
|
2433
2495
|
'/archives/:infoHash/archive.torrent',
|
|
2434
2496
|
route(async (req, res) => {
|
package/src/web/index.html
CHANGED
|
@@ -224,6 +224,23 @@
|
|
|
224
224
|
}
|
|
225
225
|
.tag.on { background: var(--accent); color: #fff; border-color: var(--accent); }
|
|
226
226
|
#add-error { color: var(--bad); min-height: 1.2rem; }
|
|
227
|
+
/* One row of endpoints per category, matching the public page. */
|
|
228
|
+
#category-list .links {
|
|
229
|
+
display: flex;
|
|
230
|
+
flex-wrap: wrap;
|
|
231
|
+
gap: 0.4rem 0.9rem;
|
|
232
|
+
align-items: center;
|
|
233
|
+
}
|
|
234
|
+
/* A copy button reads as a link's sibling, not as a form control. */
|
|
235
|
+
button.copy {
|
|
236
|
+
border: 1px solid var(--line);
|
|
237
|
+
border-radius: 999px;
|
|
238
|
+
background: none;
|
|
239
|
+
color: var(--muted);
|
|
240
|
+
font-size: 0.74rem;
|
|
241
|
+
padding: 0 0.45rem;
|
|
242
|
+
}
|
|
243
|
+
button.copy:hover { color: var(--fg); border-color: var(--fg); }
|
|
227
244
|
/* Category endpoints. `th, td` is nowrap for the archive table, where a
|
|
228
245
|
wrapped number is worse than a wide one โ but here the cell holds a
|
|
229
246
|
style URL and a paragraph explaining it, so nowrap pushed the table
|
|
@@ -3477,20 +3494,40 @@ Every piece is hashed against the ` +
|
|
|
3477
3494
|
list.innerHTML = categories
|
|
3478
3495
|
.map((entry, index) => {
|
|
3479
3496
|
const newest = entry.newest;
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3497
|
+
// The same shape as the public page, and for the same reason: what
|
|
3498
|
+
// an endpoint is for decides how it is offered. A `.torrent` is a
|
|
3499
|
+
// file, so it downloads; a preview is a page, so it opens. The
|
|
3500
|
+
// rest are addresses that belong somewhere else โ a style, a
|
|
3501
|
+
// torrent client, a feed reader โ and following one from here
|
|
3502
|
+
// achieves nothing, so they copy.
|
|
3503
|
+
//
|
|
3504
|
+
// The style URL is not printed. It is a TileJSON URL carrying a
|
|
3505
|
+
// `.torrent` URL and a percent-encoded magnet in its fragment,
|
|
3506
|
+
// several hundred characters of it, and it was truncated to 96
|
|
3507
|
+
// here โ long enough to fill the row and too short to be the
|
|
3508
|
+
// thing anybody wanted.
|
|
3509
|
+
const ends = entry.endpoints;
|
|
3510
|
+
const link = (url, text, download) =>
|
|
3511
|
+
url
|
|
3512
|
+
? `<a href="${escapeHtml(url)}"${download ? ' download' : ' target="_blank" rel="noreferrer"'}>${text}</a>`
|
|
3513
|
+
: '';
|
|
3514
|
+
// `fetched` for an endpoint whose *answer* is the thing worth
|
|
3515
|
+
// having rather than its address. /magnet serves a magnet URI as
|
|
3516
|
+
// text/plain, and copying the URL of that handed somebody a link
|
|
3517
|
+
// to a magnet instead of a magnet โ which a torrent client cannot
|
|
3518
|
+
// open.
|
|
3519
|
+
const copyable = (url, text, fetched) =>
|
|
3520
|
+
url
|
|
3521
|
+
? `<button class="copy" data-copy-url="${escapeHtml(url)}"${
|
|
3522
|
+
fetched ? ' data-copy-fetch="1"' : ''
|
|
3523
|
+
} title="${escapeHtml(url)}">${text}</button>`
|
|
3524
|
+
: '';
|
|
3488
3525
|
|
|
3489
3526
|
return `
|
|
3490
3527
|
<div class="panel" style="margin-bottom:0.8rem">
|
|
3491
3528
|
<h2 style="margin-top:0">
|
|
3492
3529
|
${escapeHtml(entry.category)}
|
|
3493
|
-
<span class="sub">ยท ${entry.archives}
|
|
3530
|
+
<span class="sub">ยท ${entry.archives} build${entry.archives === 1 ? '' : 's'}</span>
|
|
3494
3531
|
</h2>
|
|
3495
3532
|
<div class="sub" style="margin-bottom:0.6rem">
|
|
3496
3533
|
${
|
|
@@ -3499,60 +3536,44 @@ Every piece is hashed against the ` +
|
|
|
3499
3536
|
: 'nothing published under this tag yet'
|
|
3500
3537
|
}
|
|
3501
3538
|
</div>
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
url.includes('btpk')
|
|
3523
|
-
? ', and both follow the category rather than this build'
|
|
3524
|
-
: ''
|
|
3525
|
-
}</div>`
|
|
3526
|
-
: ''
|
|
3527
|
-
}`
|
|
3528
|
-
: '<span class="sub">not a PMTiles archive, so there is no tile endpoint</span>'
|
|
3529
|
-
}</td>
|
|
3530
|
-
<td style="width:11rem">${
|
|
3531
|
-
url
|
|
3532
|
-
? `<button data-copy="${index}-${row}">Copy</button>
|
|
3533
|
-
<a href="${escapeHtml(url)}" target="_blank" rel="noreferrer"><button type="button">Open</button></a>`
|
|
3534
|
-
: ''
|
|
3535
|
-
}</td>
|
|
3536
|
-
</tr>`,
|
|
3537
|
-
)
|
|
3538
|
-
.join('')}
|
|
3539
|
-
</tbody>
|
|
3540
|
-
</table>
|
|
3539
|
+
${
|
|
3540
|
+
ends.tileJson
|
|
3541
|
+
? `<div class="links">
|
|
3542
|
+
${copyable(ends.tileJson, 'TileJSON')}
|
|
3543
|
+
${link(ends.preview, 'preview')}
|
|
3544
|
+
${link(ends.torrent, '.torrent', true)}
|
|
3545
|
+
${copyable(ends.magnet, 'magnet', true)}
|
|
3546
|
+
${copyable(ends.feed, 'RSS')}
|
|
3547
|
+
${copyable(ends.latestFeed, 'RSS, newest only')}
|
|
3548
|
+
${copyable(ends.styleUrl, 'style URL')}
|
|
3549
|
+
</div>
|
|
3550
|
+
<div class="sub" style="margin-top:0.5rem">
|
|
3551
|
+
The style URL is the TileJSON with the .torrent URL and
|
|
3552
|
+
the magnet in its fragment, which is never sent to the
|
|
3553
|
+
server โ an ordinary client fetches the TileJSON and
|
|
3554
|
+
ignores them, a swarm-aware one reads them first, and
|
|
3555
|
+
both follow the category rather than this build.
|
|
3556
|
+
</div>`
|
|
3557
|
+
: '<div class="sub">not a PMTiles archive, so there is no tile endpoint</div>'
|
|
3558
|
+
}
|
|
3541
3559
|
</div>`;
|
|
3542
3560
|
})
|
|
3543
3561
|
.join('');
|
|
3544
3562
|
|
|
3545
|
-
for (const button of list.querySelectorAll('[data-copy]')) {
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3563
|
+
for (const button of list.querySelectorAll('[data-copy-url]')) {
|
|
3564
|
+
button.onclick = async () => {
|
|
3565
|
+
const url = button.dataset.copyUrl;
|
|
3566
|
+
if (!button.dataset.copyFetch) return copy(url, 'URL');
|
|
3567
|
+
try {
|
|
3568
|
+
// A plain fetch, not `api`: that one parses JSON, and this
|
|
3569
|
+
// endpoint answers text/plain.
|
|
3570
|
+
const response = await fetch(url);
|
|
3571
|
+
if (!response.ok) throw new Error(response.statusText);
|
|
3572
|
+
copy((await response.text()).trim(), 'magnet');
|
|
3573
|
+
} catch (error) {
|
|
3574
|
+
toast(error.message);
|
|
3575
|
+
}
|
|
3576
|
+
};
|
|
3556
3577
|
}
|
|
3557
3578
|
}
|
|
3558
3579
|
|
package/src/web/public.html
CHANGED
|
@@ -408,7 +408,11 @@
|
|
|
408
408
|
// preview is a page to visit. The rest are addresses that belong
|
|
409
409
|
// somewhere else โ a style, a torrent client, a feed reader โ and
|
|
410
410
|
// following one here achieves nothing, so those copy.
|
|
411
|
-
|
|
411
|
+
// `fetched` for an endpoint whose *answer* is the thing worth
|
|
412
|
+
// having rather than its address. /magnet serves a magnet URI as
|
|
413
|
+
// text/plain, and copying the URL of that handed somebody a link to
|
|
414
|
+
// a magnet instead of a magnet โ which a torrent client cannot open.
|
|
415
|
+
const copy = (href, text, fetched) => {
|
|
412
416
|
const button = el('button', 'copy', text);
|
|
413
417
|
button.type = 'button';
|
|
414
418
|
const absolute = new URL(href, location.href).href;
|
|
@@ -418,7 +422,10 @@
|
|
|
418
422
|
button.title = absolute;
|
|
419
423
|
button.onclick = async () => {
|
|
420
424
|
try {
|
|
421
|
-
|
|
425
|
+
const value = fetched
|
|
426
|
+
? (await (await fetch(absolute)).text()).trim()
|
|
427
|
+
: absolute;
|
|
428
|
+
await navigator.clipboard.writeText(value);
|
|
422
429
|
button.classList.add('done');
|
|
423
430
|
button.textContent = 'copied';
|
|
424
431
|
setTimeout(() => {
|
|
@@ -444,7 +451,7 @@
|
|
|
444
451
|
a.download = '';
|
|
445
452
|
links.append(a);
|
|
446
453
|
}
|
|
447
|
-
if (ends.magnet) copy(ends.magnet, 'magnet');
|
|
454
|
+
if (ends.magnet) copy(ends.magnet, 'magnet', true);
|
|
448
455
|
if (ends.feed) copy(ends.feed, 'RSS');
|
|
449
456
|
// The one worth copying and the one nobody wants to read: a TileJSON
|
|
450
457
|
// URL carrying the .torrent URL and a percent-encoded magnet in its
|