pmtiles-swarm 0.45.1 → 0.46.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,60 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.46.0
11
+ ### ✨ Features and improvements
12
+ - **Each category endpoint on the public page is now offered the way it is used.** A `.torrent` is a
13
+ file, so it downloads. A preview is a page, so it opens. The rest are addresses that belong
14
+ somewhere else — a style, a torrent client, a feed reader — and following one here achieves
15
+ nothing, so TileJSON, magnet, RSS and the style URL copy instead.
16
+
17
+ The style URL is no longer printed. It is a TileJSON URL carrying a `.torrent` URL and a
18
+ percent-encoded magnet in its fragment, several hundred characters of it, and it was most of the
19
+ card — while being the one thing on the page nobody reads and everybody pastes. Every copy button
20
+ also carries its URL as a tooltip, since a browser on plain HTTP has no clipboard and a string
21
+ somebody can select beats a button that does nothing.
22
+
23
+ ### 🐞 Bug fixes
24
+
25
+ ## 0.45.4
26
+ ### ✨ Features and improvements
27
+
28
+ ### 🐞 Bug fixes
29
+ - **URLs shown in the console name the public port, not the admin one.** The console is served by the
30
+ admin listener, so every address it handed out named that listener — a TileJSON URL on `:8091`, a
31
+ `.torrent` on `:8091`, a style URL carrying both. Those are exactly the addresses that get pasted
32
+ into a style, a torrent client or another node, and none of them can reach the admin port: it binds
33
+ to localhost by default and serves nothing public even when it does not.
34
+
35
+ Only the port is corrected, and only when it is the admin one. The host stays whatever the request
36
+ arrived as, so a node behind a proxy still names itself correctly without anyone setting
37
+ `publicUrl` — and `publicUrl` still wins outright where it is set.
38
+
39
+ ## 0.45.3
40
+ ### ✨ Features and improvements
41
+
42
+ ### 🐞 Bug fixes
43
+ - **The Copy and Open buttons on the Categories tab no longer sit off the edge of the page.** Table
44
+ cells are `nowrap`, which is right for the archive list where a wrapped number is worse than a wide
45
+ column — but a category cell holds a style URL and a paragraph explaining it, so nothing could
46
+ wrap and the table grew past the window, carrying the buttons at its end out of reach. The
47
+ endpoints table lays out fixed now: the label and button columns keep their width and the URL takes
48
+ what is left.
49
+
50
+ ## 0.45.2
51
+ ### ✨ Features and improvements
52
+
53
+ ### 🐞 Bug fixes
54
+ - **A peer's pieces bar now gets a row of its own on the Peers tab.** As a column it took most of the
55
+ table and left every other cell wrapping a word at a time — `WebT orrent 0.3. 0` down four lines —
56
+ while the bar itself was still too narrow to read. The figures go on one line and the bar spans the
57
+ table beneath them, labelled **pieces** rather than **Has**.
58
+ - **A peer that describes itself with an object is no longer shown as `[object Object]`.** Engines
59
+ disagree about the shape: libtorrent sends a version string and a WebTorrent peer arrives as an
60
+ object, which went into the column unchanged — and that reads as a fault in this node rather than a
61
+ peer describing itself differently. Whatever the object carries that names it is used, and a dash
62
+ where nothing does.
63
+
10
64
  ## 0.45.1
11
65
  ### ✨ Features and improvements
12
66
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.45.1",
3
+ "version": "0.46.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
@@ -317,8 +317,27 @@ export function createApp({
317
317
  // a torrent client handed the `torrent` link, another node syncing from
318
318
  // the feed -- silently gets something it cannot use.
319
319
  const configured = String(config.publicUrl ?? '').trim();
320
- const base = configured || `${req.protocol}://${req.host}`;
321
- return base.replace(/\/$/, '');
320
+ if (configured) return configured.replace(/\/$/, '');
321
+
322
+ // Never the admin port, whichever one the request came in on.
323
+ //
324
+ // The console lives on the admin listener, so every URL it showed named
325
+ // that listener — a TileJSON URL on :8091, a `.torrent` on :8091, a style
326
+ // URL carrying both. Those are the addresses people copy into a style, a
327
+ // torrent client or another node, and none of them can reach the admin
328
+ // port: it is bound to localhost by default and serves nothing public
329
+ // even when it is not. Half the addresses on a node pointing somewhere
330
+ // the other half do not is a confusion nobody should have to diagnose.
331
+ //
332
+ // The host is still whatever the request arrived as, so a node behind a
333
+ // proxy keeps naming itself correctly without anybody configuring
334
+ // `publicUrl`. Only the port is corrected, and only when it is the one
335
+ // port that is not for the public.
336
+ const host = onAdminPort(req)
337
+ ? String(req.host).replace(/:\d+$/, '') +
338
+ (config.port ? `:${config.port}` : '')
339
+ : req.host;
340
+ return `${req.protocol}://${host}`.replace(/\/$/, '');
322
341
  };
323
342
 
324
343
  /**
@@ -224,6 +224,15 @@
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
+ /* Category endpoints. `th, td` is nowrap for the archive table, where a
228
+ wrapped number is worse than a wide one — but here the cell holds a
229
+ style URL and a paragraph explaining it, so nowrap pushed the table
230
+ wider than the window and took the Copy and Open buttons off the far
231
+ edge with it. Fixed layout keeps the two end columns where they are
232
+ and gives the middle whatever is left. */
233
+ #category-list table { table-layout: fixed; }
234
+ #category-list td { white-space: normal; }
235
+ #category-list code { word-break: break-all; }
227
236
  /* Separates what acts on one archive from what acts on all of them. */
228
237
  .bar-gap { flex: 1 1 1.5rem; min-width: 0.75rem; }
229
238
  .tabs {
@@ -252,6 +261,12 @@
252
261
  word-break: break-all;
253
262
  }
254
263
  .rows th { color: var(--muted); font-size: 0.75rem; white-space: nowrap; }
264
+ /* A peer's figures fit on one line once the bar is not competing with
265
+ them for width; the bar gets the whole table on the row below. */
266
+ .rows.peers td { white-space: nowrap; word-break: normal; }
267
+ .rows.peers tr.peerbar > td { border-bottom: 1px solid var(--line); }
268
+ .rows.peers tr.peerbar .piecerow { margin: 0; }
269
+ .rows.peers tr:not(.peerbar) > td { border-bottom: none; padding-bottom: 0.1rem; }
255
270
  [hidden] { display: none !important; }
256
271
 
257
272
  /* A radio or checkbox beside its own label.
@@ -1588,6 +1603,30 @@
1588
1603
  * @param {object} entry - What the detail endpoint already returned.
1589
1604
  * @returns {Promise<void>} - Resolves once rendered.
1590
1605
  */
1606
+ /**
1607
+ * What a peer says it is, as a string.
1608
+ *
1609
+ * Engines disagree about the shape: libtorrent sends a version string,
1610
+ * and a WebTorrent peer arrives as an object. The object went through
1611
+ * escapeHtml unchanged and the column read "[object Object]" — which
1612
+ * looks like a fault in this node rather than a peer that described
1613
+ * itself differently.
1614
+ * @param {object} peer - One peer from the engine.
1615
+ * @returns {string} - Something worth printing.
1616
+ */
1617
+ function clientOf(peer) {
1618
+ const said = peer?.client;
1619
+ if (typeof said === 'string' && said.trim()) return said;
1620
+ if (said && typeof said === 'object') {
1621
+ // Whatever it does carry, in the order most likely to name it.
1622
+ for (const key of ['name', 'client', 'version', 'id', 'peerId']) {
1623
+ const value = said[key];
1624
+ if (typeof value === 'string' && value.trim()) return value;
1625
+ }
1626
+ }
1627
+ return '—';
1628
+ }
1629
+
1591
1630
  async function fillPane(name, infoHash, entry) {
1592
1631
  const pane = document.querySelector(`[data-pane="${name}"]`);
1593
1632
  if (!pane) return;
@@ -1677,39 +1716,58 @@
1677
1716
  (peer) => !listed.has(peer.address),
1678
1717
  );
1679
1718
 
1680
- const rows = [...peers, ...extra].map((peer, index) => {
1681
- const address = peer.address ?? peer.ip ?? '—';
1682
- const have = bitfields.get(address);
1683
- return [
1684
- escapeHtml(address),
1685
- escapeHtml(peer.client ?? '—'),
1686
- escapeHtml(peer.kind ?? 'peer'),
1687
- escapeHtml(peer.connection ?? '—'),
1688
- have
1689
- ? `<canvas class="piecebar thin" data-bar="peer-${index}"></canvas>`
1690
- : '<span class="sub">not reported</span>',
1691
- peer.progress != null ? pct(peer.progress) : '—',
1692
- rate(peer.downloadSpeed ?? peer.dlSpeed),
1693
- rate(peer.uploadSpeed ?? peer.upSpeed),
1694
- ];
1695
- });
1719
+ // Two rows a peer: the figures, then its pieces across the full
1720
+ // width. As a column the bar took most of the table and left every
1721
+ // other cell wrapping a word at a time -- "WebT orrent 0.3. 0" in
1722
+ // four lines -- while the bar itself was still too narrow to read.
1723
+ const columns = ['Address', 'Client', 'Kind', 'Conn', 'Progress', 'Down', 'Up'];
1724
+ const body = [...peers, ...extra]
1725
+ .map((peer, index) => {
1726
+ const address = peer.address ?? peer.ip ?? '—';
1727
+ const have = bitfields.get(address);
1728
+ const cells = [
1729
+ escapeHtml(address),
1730
+ escapeHtml(clientOf(peer)),
1731
+ escapeHtml(peer.kind ?? 'peer'),
1732
+ escapeHtml(peer.connection ?? '—'),
1733
+ peer.progress != null ? pct(peer.progress) : '—',
1734
+ rate(peer.downloadSpeed ?? peer.dlSpeed),
1735
+ rate(peer.uploadSpeed ?? peer.upSpeed),
1736
+ ];
1737
+ return (
1738
+ `<tr>${cells.map((cell) => `<td>${cell}</td>`).join('')}</tr>` +
1739
+ `<tr class="peerbar"><td colspan="${columns.length}">
1740
+ <div class="piecerow">
1741
+ <span class="label">pieces</span>
1742
+ ${
1743
+ have
1744
+ ? `<canvas class="piecebar thin" data-bar="peer-${index}"></canvas>`
1745
+ : '<span class="sub">not reported</span>'
1746
+ }
1747
+ <span class="value">${peer.progress != null ? pct(peer.progress) : ''}</span>
1748
+ </div>
1749
+ </td></tr>`
1750
+ );
1751
+ })
1752
+ .join('');
1696
1753
 
1697
1754
  pane.innerHTML =
1698
- table(
1699
- ['Address', 'Client', 'Kind', 'Conn', 'Has', 'Progress', 'Down', 'Up'],
1700
- rows,
1701
- ) +
1755
+ (body === ''
1756
+ ? '<div class="sub">Nothing to show.</div>'
1757
+ : `<table class="rows peers"><thead><tr>${columns
1758
+ .map((header) => `<th>${escapeHtml(header)}</th>`)
1759
+ .join('')}</tr></thead><tbody>${body}</tbody></table>`) +
1702
1760
  (failed
1703
1761
  ? `<div class="sub" style="margin-top:0.5rem">
1704
1762
  The engine could not list peers: ${escapeHtml(failed)}
1705
1763
  </div>`
1706
1764
  : '') +
1707
- (rows.length > 0
1765
+ (body !== ''
1708
1766
  ? `<div class="sub" style="margin-top:0.5rem">
1709
- <b>Has</b> is drawn the same way as the bars on General —
1710
- a column covers many pieces and is only filled when all of
1711
- them are held. <b>Progress</b> is the engine's own figure
1712
- for the same peer, which is exact.
1767
+ Each peer's <b>pieces</b> bar is drawn the way the bars on
1768
+ General are — a column covers many pieces and is only
1769
+ filled when all of them are held. <b>Progress</b> is the
1770
+ engine's own figure for the same peer, which is exact.
1713
1771
  </div>`
1714
1772
  : '');
1715
1773
 
@@ -118,6 +118,19 @@
118
118
  gap: 0.35rem 0.9rem;
119
119
  font-size: 0.9rem;
120
120
  }
121
+ /* A copy button reads as a link's sibling, not as a form control. */
122
+ .copy {
123
+ border: 1px solid var(--line);
124
+ border-radius: 999px;
125
+ background: none;
126
+ color: var(--muted);
127
+ font: inherit;
128
+ font-size: 0.74rem;
129
+ padding: 0 0.45rem;
130
+ cursor: pointer;
131
+ }
132
+ .copy:hover { color: var(--fg); border-color: var(--fg); }
133
+ .copy.done { border-color: currentColor; color: #3fb950; }
121
134
  .urls {
122
135
  margin-top: 0.7rem;
123
136
  display: grid;
@@ -390,30 +403,55 @@
390
403
  a.href = href;
391
404
  links.append(a);
392
405
  };
406
+ // What each endpoint is for decides how it is offered. A .torrent
407
+ // is a file to fetch, so it stays a link the browser downloads; a
408
+ // preview is a page to visit. The rest are addresses that belong
409
+ // somewhere else — a style, a torrent client, a feed reader — and
410
+ // following one here achieves nothing, so those copy.
411
+ const copy = (href, text) => {
412
+ const button = el('button', 'copy', text);
413
+ button.type = 'button';
414
+ const absolute = new URL(href, location.href).href;
415
+ // The URL either way: a browser on plain HTTP has no clipboard,
416
+ // and a tooltip somebody can select beats a button that does
417
+ // nothing.
418
+ button.title = absolute;
419
+ button.onclick = async () => {
420
+ try {
421
+ await navigator.clipboard.writeText(absolute);
422
+ button.classList.add('done');
423
+ button.textContent = 'copied';
424
+ setTimeout(() => {
425
+ button.classList.remove('done');
426
+ button.textContent = text;
427
+ }, 1200);
428
+ } catch {
429
+ button.textContent = 'copy failed — it is in the tooltip';
430
+ }
431
+ };
432
+ links.append(button);
433
+ };
434
+
393
435
  const ends = entry.endpoints;
394
- if (ends.tileJson) add(ends.tileJson, 'TileJSON');
436
+ if (ends.tileJson) copy(ends.tileJson, 'TileJSON');
395
437
  // The newest build's preview, which is what "show me this
396
438
  // category" means.
397
439
  if (ends.preview) add(ends.preview, 'preview');
398
- if (ends.torrent) add(ends.torrent, '.torrent');
399
- if (ends.magnet) add(ends.magnet, 'magnet');
400
- if (ends.feed) add(ends.feed, 'RSS');
401
- card.append(links);
402
-
403
- const urls = el('div', 'urls');
404
- // The one worth copying: the TileJSON URL with the .torrent URL and
405
- // the magnet in its fragment, which a plain client fetches over HTTP
406
- // and a swarm-aware one joins directly.
407
- if (ends.styleUrl) {
408
- urls.append(
409
- urlRow('style URL', new URL(ends.styleUrl, location.href).href),
410
- );
411
- } else if (ends.tileJson) {
412
- urls.append(
413
- urlRow('TileJSON', new URL(ends.tileJson, location.href).href),
414
- );
440
+ // A file, so a link the browser saves rather than renders.
441
+ if (ends.torrent) {
442
+ const a = el('a', null, '.torrent');
443
+ a.href = ends.torrent;
444
+ a.download = '';
445
+ links.append(a);
415
446
  }
416
- if (urls.childElementCount > 0) card.append(urls);
447
+ if (ends.magnet) copy(ends.magnet, 'magnet');
448
+ if (ends.feed) copy(ends.feed, 'RSS');
449
+ // The one worth copying and the one nobody wants to read: a TileJSON
450
+ // URL carrying the .torrent URL and a percent-encoded magnet in its
451
+ // fragment. Printed in full it was most of the card — several
452
+ // hundred characters of it — and it is never read, only pasted.
453
+ if (ends.styleUrl) copy(ends.styleUrl, 'style URL');
454
+ card.append(links);
417
455
 
418
456
  host.append(card);
419
457
  }