pmtiles-swarm 0.45.2 → 0.47.1

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,71 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.47.1
11
+ ### ✨ Features and improvements
12
+
13
+ ### 🐞 Bug fixes
14
+ - **The magnet button copies a magnet, not a link to one.** `/latest/<category>/magnet` answers a
15
+ magnet URI as `text/plain`, and both pages were copying that endpoint's own address — so what
16
+ landed on the clipboard was an `http://` URL, which a torrent client cannot open. The button now
17
+ fetches the endpoint and copies what it answers. Every other endpoint is still copied by address,
18
+ because for those the address is the thing.
19
+
20
+ ## 0.47.0
21
+ ### ✨ Features and improvements
22
+ - **The console's Categories tab now matches the public page.** They were two views of one thing that
23
+ had drifted into two shapes: a label/value table with Copy and Open on every row here, links and a
24
+ printed style URL there, different labels for the same endpoints, and a preview link on one and not
25
+ the other.
26
+
27
+ Both now offer each endpoint the way it is used — `.torrent` downloads, preview opens, and
28
+ TileJSON, magnet, RSS and the style URL copy. The console keeps `RSS, newest only`, which is its
29
+ own, and gains the preview link it was missing. The style URL is no longer printed on either page:
30
+ here it was truncated to 96 characters, which is long enough to fill the row and too short to be
31
+ the thing anybody wanted.
32
+
33
+ ### 🐞 Bug fixes
34
+
35
+ ## 0.46.0
36
+ ### ✨ Features and improvements
37
+ - **Each category endpoint on the public page is now offered the way it is used.** A `.torrent` is a
38
+ file, so it downloads. A preview is a page, so it opens. The rest are addresses that belong
39
+ somewhere else — a style, a torrent client, a feed reader — and following one here achieves
40
+ nothing, so TileJSON, magnet, RSS and the style URL copy instead.
41
+
42
+ The style URL is no longer printed. It is a TileJSON URL carrying a `.torrent` URL and a
43
+ percent-encoded magnet in its fragment, several hundred characters of it, and it was most of the
44
+ card — while being the one thing on the page nobody reads and everybody pastes. Every copy button
45
+ also carries its URL as a tooltip, since a browser on plain HTTP has no clipboard and a string
46
+ somebody can select beats a button that does nothing.
47
+
48
+ ### 🐞 Bug fixes
49
+
50
+ ## 0.45.4
51
+ ### ✨ Features and improvements
52
+
53
+ ### 🐞 Bug fixes
54
+ - **URLs shown in the console name the public port, not the admin one.** The console is served by the
55
+ admin listener, so every address it handed out named that listener — a TileJSON URL on `:8091`, a
56
+ `.torrent` on `:8091`, a style URL carrying both. Those are exactly the addresses that get pasted
57
+ into a style, a torrent client or another node, and none of them can reach the admin port: it binds
58
+ to localhost by default and serves nothing public even when it does not.
59
+
60
+ Only the port is corrected, and only when it is the admin one. The host stays whatever the request
61
+ arrived as, so a node behind a proxy still names itself correctly without anyone setting
62
+ `publicUrl` — and `publicUrl` still wins outright where it is set.
63
+
64
+ ## 0.45.3
65
+ ### ✨ Features and improvements
66
+
67
+ ### 🐞 Bug fixes
68
+ - **The Copy and Open buttons on the Categories tab no longer sit off the edge of the page.** Table
69
+ cells are `nowrap`, which is right for the archive list where a wrapped number is worse than a wide
70
+ column — but a category cell holds a style URL and a paragraph explaining it, so nothing could
71
+ wrap and the table grew past the window, carrying the buttons at its end out of reach. The
72
+ endpoints table lays out fixed now: the label and button columns keep their width and the URL takes
73
+ what is left.
74
+
10
75
  ## 0.45.2
11
76
  ### ✨ Features and improvements
12
77
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.45.2",
3
+ "version": "0.47.1",
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,32 @@
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); }
244
+ /* Category endpoints. `th, td` is nowrap for the archive table, where a
245
+ wrapped number is worse than a wide one — but here the cell holds a
246
+ style URL and a paragraph explaining it, so nowrap pushed the table
247
+ wider than the window and took the Copy and Open buttons off the far
248
+ edge with it. Fixed layout keeps the two end columns where they are
249
+ and gives the middle whatever is left. */
250
+ #category-list table { table-layout: fixed; }
251
+ #category-list td { white-space: normal; }
252
+ #category-list code { word-break: break-all; }
227
253
  /* Separates what acts on one archive from what acts on all of them. */
228
254
  .bar-gap { flex: 1 1 1.5rem; min-width: 0.75rem; }
229
255
  .tabs {
@@ -3468,20 +3494,40 @@ Every piece is hashed against the ` +
3468
3494
  list.innerHTML = categories
3469
3495
  .map((entry, index) => {
3470
3496
  const newest = entry.newest;
3471
- const rows = [
3472
- ['TileJSON', entry.endpoints.tileJson],
3473
- ['For a style', entry.endpoints.styleUrl],
3474
- ['Torrent', entry.endpoints.torrent],
3475
- ['Magnet', entry.endpoints.magnet],
3476
- ['Feed', entry.endpoints.feed],
3477
- ['Latest only', entry.endpoints.latestFeed],
3478
- ];
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
+ : '';
3479
3525
 
3480
3526
  return `
3481
3527
  <div class="panel" style="margin-bottom:0.8rem">
3482
3528
  <h2 style="margin-top:0">
3483
3529
  ${escapeHtml(entry.category)}
3484
- <span class="sub">· ${entry.archives} archive${entry.archives === 1 ? '' : 's'}</span>
3530
+ <span class="sub">· ${entry.archives} build${entry.archives === 1 ? '' : 's'}</span>
3485
3531
  </h2>
3486
3532
  <div class="sub" style="margin-bottom:0.6rem">
3487
3533
  ${
@@ -3490,60 +3536,44 @@ Every piece is hashed against the ` +
3490
3536
  : 'nothing published under this tag yet'
3491
3537
  }
3492
3538
  </div>
3493
- <table>
3494
- <tbody>
3495
- ${rows
3496
- .map(
3497
- ([label, url], row) => `
3498
- <tr>
3499
- <td style="width:8rem">${label}</td>
3500
- <td>${
3501
- url
3502
- ? `<code>${escapeHtml(
3503
- url.length > 96 ? `${url.slice(0, 96)}…` : url,
3504
- )}</code>${
3505
- label === 'For a style'
3506
- ? `<div class="sub">the .torrent URL and the magnet ride in the
3507
- fragment, which is never sent to the server — an ordinary
3508
- client fetches the TileJSON and ignores them, a swarm-aware
3509
- one reads them before making any call${
3510
- // Matched unanchored because the magnet is
3511
- // percent-encoded in the fragment now that it
3512
- // shares one with the .torrent URL.
3513
- url.includes('btpk')
3514
- ? ', and both follow the category rather than this build'
3515
- : ''
3516
- }</div>`
3517
- : ''
3518
- }`
3519
- : '<span class="sub">not a PMTiles archive, so there is no tile endpoint</span>'
3520
- }</td>
3521
- <td style="width:11rem">${
3522
- url
3523
- ? `<button data-copy="${index}-${row}">Copy</button>
3524
- <a href="${escapeHtml(url)}" target="_blank" rel="noreferrer"><button type="button">Open</button></a>`
3525
- : ''
3526
- }</td>
3527
- </tr>`,
3528
- )
3529
- .join('')}
3530
- </tbody>
3531
- </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
+ }
3532
3559
  </div>`;
3533
3560
  })
3534
3561
  .join('');
3535
3562
 
3536
- for (const button of list.querySelectorAll('[data-copy]')) {
3537
- const [index, row] = button.dataset.copy.split('-').map(Number);
3538
- const url = [
3539
- categories[index].endpoints.tileJson,
3540
- categories[index].endpoints.styleUrl,
3541
- categories[index].endpoints.torrent,
3542
- categories[index].endpoints.magnet,
3543
- categories[index].endpoints.feed,
3544
- categories[index].endpoints.latestFeed,
3545
- ][row];
3546
- 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
+ };
3547
3577
  }
3548
3578
  }
3549
3579
 
@@ -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,62 @@
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
+ // `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) => {
416
+ const button = el('button', 'copy', text);
417
+ button.type = 'button';
418
+ const absolute = new URL(href, location.href).href;
419
+ // The URL either way: a browser on plain HTTP has no clipboard,
420
+ // and a tooltip somebody can select beats a button that does
421
+ // nothing.
422
+ button.title = absolute;
423
+ button.onclick = async () => {
424
+ try {
425
+ const value = fetched
426
+ ? (await (await fetch(absolute)).text()).trim()
427
+ : absolute;
428
+ await navigator.clipboard.writeText(value);
429
+ button.classList.add('done');
430
+ button.textContent = 'copied';
431
+ setTimeout(() => {
432
+ button.classList.remove('done');
433
+ button.textContent = text;
434
+ }, 1200);
435
+ } catch {
436
+ button.textContent = 'copy failed — it is in the tooltip';
437
+ }
438
+ };
439
+ links.append(button);
440
+ };
441
+
393
442
  const ends = entry.endpoints;
394
- if (ends.tileJson) add(ends.tileJson, 'TileJSON');
443
+ if (ends.tileJson) copy(ends.tileJson, 'TileJSON');
395
444
  // The newest build's preview, which is what "show me this
396
445
  // category" means.
397
446
  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
- );
447
+ // A file, so a link the browser saves rather than renders.
448
+ if (ends.torrent) {
449
+ const a = el('a', null, '.torrent');
450
+ a.href = ends.torrent;
451
+ a.download = '';
452
+ links.append(a);
415
453
  }
416
- if (urls.childElementCount > 0) card.append(urls);
454
+ if (ends.magnet) copy(ends.magnet, 'magnet', true);
455
+ if (ends.feed) copy(ends.feed, 'RSS');
456
+ // The one worth copying and the one nobody wants to read: a TileJSON
457
+ // URL carrying the .torrent URL and a percent-encoded magnet in its
458
+ // fragment. Printed in full it was most of the card — several
459
+ // hundred characters of it — and it is never read, only pasted.
460
+ if (ends.styleUrl) copy(ends.styleUrl, 'style URL');
461
+ card.append(links);
417
462
 
418
463
  host.append(card);
419
464
  }