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 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.46.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) => {
@@ -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
- const rows = [
3481
- ['TileJSON', entry.endpoints.tileJson],
3482
- ['For a style', entry.endpoints.styleUrl],
3483
- ['Torrent', entry.endpoints.torrent],
3484
- ['Magnet', entry.endpoints.magnet],
3485
- ['Feed', entry.endpoints.feed],
3486
- ['Latest only', entry.endpoints.latestFeed],
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} archive${entry.archives === 1 ? '' : 's'}</span>
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
- <table>
3503
- <tbody>
3504
- ${rows
3505
- .map(
3506
- ([label, url], row) => `
3507
- <tr>
3508
- <td style="width:8rem">${label}</td>
3509
- <td>${
3510
- url
3511
- ? `<code>${escapeHtml(
3512
- url.length > 96 ? `${url.slice(0, 96)}โ€ฆ` : url,
3513
- )}</code>${
3514
- label === 'For a style'
3515
- ? `<div class="sub">the .torrent URL and the magnet ride in the
3516
- fragment, which is never sent to the server โ€” an ordinary
3517
- client fetches the TileJSON and ignores them, a swarm-aware
3518
- one reads them before making any call${
3519
- // Matched unanchored because the magnet is
3520
- // percent-encoded in the fragment now that it
3521
- // shares one with the .torrent URL.
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
- const [index, row] = button.dataset.copy.split('-').map(Number);
3547
- const url = [
3548
- categories[index].endpoints.tileJson,
3549
- categories[index].endpoints.styleUrl,
3550
- categories[index].endpoints.torrent,
3551
- categories[index].endpoints.magnet,
3552
- categories[index].endpoints.feed,
3553
- categories[index].endpoints.latestFeed,
3554
- ][row];
3555
- button.onclick = () => copy(url, 'URL');
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
 
@@ -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
- const copy = (href, text) => {
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
- await navigator.clipboard.writeText(absolute);
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