pmtiles-swarm 0.21.0 → 0.22.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
@@ -58,6 +58,14 @@
58
58
  `/api/categories`, filtered by the same `feedCategories` rule, so it can show nothing that was
59
59
  not already published — and it is not the console, which stays on the admin port.
60
60
  `publicIndex: false` turns it off, withdrawing the three paths it needs with it.
61
+ - **`GET /latest/` lists the categories, without a credential.** Everything else under
62
+ `/latest/` is public — the TileJSON, the torrent, the magnet, the per-category feed — so the
63
+ index of what it offers belongs beside them rather than behind the console's door. The public
64
+ front page now leads with categories rather than only listing archives, which is the more
65
+ useful handle for a visitor: an archive URL names one build and goes stale on the next, while a
66
+ category names whichever is current. Same builder as `/api/categories`, so the two cannot drift
67
+ apart. The page's footer and its noscript block no longer link `/api/` paths that answer 401 to
68
+ exactly the audience they are shown to.
61
69
  - **The feed carries the BEP 46 identity.** `<pmtiles:mutable>` holds the magnet naming an
62
70
  archive's publishing key, so a consumer following a category across rebuilds can read the
63
71
  public key straight out of the feed instead of fetching a TileJSON to find it — the key rides
@@ -41,6 +41,27 @@ swarm on every attempt, doing work that could never succeed.
41
41
 
42
42
  See [internals.md](internals.md#serving-an-mbtiles-archive).
43
43
 
44
+ ## The category index
45
+
46
+ `GET /latest/` lists every category this node publishes, with the endpoints
47
+ that resolve to each one's newest build — the TileJSON, the `.torrent`, the
48
+ magnet, the per-category feed, and the style URL with the magnet in its
49
+ fragment.
50
+
51
+ Public, and deliberately so. Everything else under `/latest/` is — the
52
+ TileJSON, the torrent, the magnet, the feed — so the index of what `/latest/`
53
+ offers belongs beside them rather than behind the console's door.
54
+ `/api/categories` returns the identical list from the identical builder, and is
55
+ guarded only because everything under `/api/` is.
56
+
57
+ A category is also the handle worth publishing. An archive URL names one build
58
+ and goes stale on the next; a category names whichever is current, so a style
59
+ pointing at one keeps working across rebuilds.
60
+
61
+ Filtered by `feedCategories` and by whatever token was presented, the same as
62
+ the catalogue and the feeds — so it can show nothing this node was not already
63
+ publishing.
64
+
44
65
  ## Missing tiles
45
66
 
46
67
  A tile the archive does not hold answers **404 for raster and 204 for vector**,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.21.0",
3
+ "version": "0.22.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
@@ -1744,9 +1744,19 @@ export function createApp({
1744
1744
  // what makes a tile immutable, but leaves nothing for a style to point at
1745
1745
  // that survives a rebuild — and until now the endpoints that give you one
1746
1746
  // were documented rather than discoverable.
1747
- app.get(
1748
- '/api/categories',
1749
- route(async (req, res) => {
1747
+ /**
1748
+ * Every category, with the endpoints that resolve to its newest build.
1749
+ *
1750
+ * Shared by the guarded `/api/categories` and the public `/latest/`, which
1751
+ * want exactly the same answer — one is the console asking and the other is
1752
+ * a stranger, and neither is told anything the other is not. Filtering runs
1753
+ * through `publishesEntry`, so `feedCategories` and whatever token was
1754
+ * presented decide what appears, the same as everywhere else.
1755
+ * @param {import('express').Request} req - The request, for its credential and base URL.
1756
+ * @returns {object[]} - One row per category, sorted by name.
1757
+ */
1758
+ const describeCategories = (req) => {
1759
+ {
1750
1760
  const base = baseUrl(req);
1751
1761
  const visible = catalog
1752
1762
  .list()
@@ -1759,51 +1769,82 @@ export function createApp({
1759
1769
  }
1760
1770
  }
1761
1771
 
1762
- res.json(
1763
- [...counts.keys()].sort().map((category) => {
1764
- const newest = newestIn(category, req);
1765
- // Only PMTiles has tiles to serve, so a category whose newest build
1766
- // is an MBTiles archive gets a feed and a torrent but no tile
1767
- // endpoint — the same rule as an individual archive.
1768
- const servable =
1769
- Boolean(newest?.pmtiles) &&
1770
- (newest.kind ?? 'pmtiles') === 'pmtiles';
1771
-
1772
- return {
1773
- category,
1774
- archives: counts.get(category),
1775
- newest: newest && {
1776
- infoHash: newest.infoHash,
1777
- name: newest.name,
1778
- size: newest.size,
1779
- createdAt: newest.createdAt,
1780
- kind: newest.kind ?? 'pmtiles',
1781
- },
1782
- servable,
1783
- endpoints: {
1784
- tileJson: servable
1785
- ? `${base}/latest/${category}/tiles.json`
1786
- : null,
1787
- // The same URL with a magnet in the fragment, which is what a
1788
- // style should carry. A fragment is never sent in a request, so
1789
- // an ordinary client fetches the TileJSON and ignores it, while
1790
- // a swarm-aware one has the magnet before it makes any call --
1791
- // and can therefore still start when this server cannot answer.
1792
- //
1793
- // The mutable magnet where there is one, because a category is
1794
- // precisely where an infohash goes stale: it names the category
1795
- // and resolves the current build over the DHT. Otherwise the
1796
- // newest build's own magnet, which pins that build but still
1797
- // beats a blank map when the fallback is needed at all.
1798
- styleUrl: servable ? styleUrlFor(category, newest, base) : null,
1799
- torrent: `${base}/latest/${category}/archive.torrent`,
1800
- magnet: `${base}/latest/${category}/magnet`,
1801
- feed: `${base}/feed/${category}.xml`,
1802
- latestFeed: `${base}/latest/${category}.xml`,
1803
- },
1804
- };
1805
- }),
1806
- );
1772
+ return [...counts.keys()].sort().map((category) => {
1773
+ const newest = newestIn(category, req);
1774
+ // Only PMTiles has tiles to serve, so a category whose newest build
1775
+ // is an MBTiles archive gets a feed and a torrent but no tile
1776
+ // endpoint — the same rule as an individual archive.
1777
+ const servable =
1778
+ Boolean(newest?.pmtiles) && (newest.kind ?? 'pmtiles') === 'pmtiles';
1779
+
1780
+ return {
1781
+ category,
1782
+ archives: counts.get(category),
1783
+ newest: newest && {
1784
+ infoHash: newest.infoHash,
1785
+ name: newest.name,
1786
+ size: newest.size,
1787
+ createdAt: newest.createdAt,
1788
+ kind: newest.kind ?? 'pmtiles',
1789
+ },
1790
+ servable,
1791
+ endpoints: {
1792
+ tileJson: servable ? `${base}/latest/${category}/tiles.json` : null,
1793
+ // The same URL with a magnet in the fragment, which is what a
1794
+ // style should carry. A fragment is never sent in a request, so
1795
+ // an ordinary client fetches the TileJSON and ignores it, while
1796
+ // a swarm-aware one has the magnet before it makes any call --
1797
+ // and can therefore still start when this server cannot answer.
1798
+ //
1799
+ // The mutable magnet where there is one, because a category is
1800
+ // precisely where an infohash goes stale: it names the category
1801
+ // and resolves the current build over the DHT. Otherwise the
1802
+ // newest build's own magnet, which pins that build but still
1803
+ // beats a blank map when the fallback is needed at all.
1804
+ styleUrl: servable ? styleUrlFor(category, newest, base) : null,
1805
+ torrent: `${base}/latest/${category}/archive.torrent`,
1806
+ magnet: `${base}/latest/${category}/magnet`,
1807
+ feed: `${base}/feed/${category}.xml`,
1808
+ latestFeed: `${base}/latest/${category}.xml`,
1809
+ },
1810
+ };
1811
+ });
1812
+ }
1813
+ };
1814
+
1815
+ // The console's view. Identical to the public one below, and guarded only
1816
+ // because everything under /api/ is.
1817
+ app.get(
1818
+ '/api/categories',
1819
+ route(async (req, res) => {
1820
+ res.json(describeCategories(req));
1821
+ }),
1822
+ );
1823
+
1824
+ // The same list, without a credential.
1825
+ //
1826
+ // Everything under /latest/ is public already — the TileJSON, the torrent,
1827
+ // the magnet, the per-category feed — so the index of what /latest/ offers
1828
+ // belongs here rather than behind the console's door. A category is also the
1829
+ // handle a person actually wants: an infohash names one build, and this
1830
+ // names whichever is current, so a style pointing at it keeps working across
1831
+ // rebuilds.
1832
+ //
1833
+ // Not a second implementation. It is the same builder, filtered by the same
1834
+ // rule, so the two cannot drift into disagreeing about what is published.
1835
+ app.get(
1836
+ '/latest/',
1837
+ route(async (req, res) => {
1838
+ res.setHeader('access-control-allow-origin', '*');
1839
+ // Short, because the answer changes when a build lands.
1840
+ res.setHeader('cache-control', 'public, max-age=60');
1841
+ res.json({
1842
+ // Named so a consumer can refuse a document it does not understand,
1843
+ // the same as the catalogue does.
1844
+ format: 'pmtiles-swarm-categories/1',
1845
+ generatedAt: new Date().toISOString(),
1846
+ categories: describeCategories(req),
1847
+ });
1807
1848
  }),
1808
1849
  );
1809
1850
 
@@ -3,10 +3,14 @@
3
3
  The public front door, served at / on the public listener when the console
4
4
  has an admin port of its own.
5
5
 
6
- Reads /api/catalog and /api/categories, both of which are already public and
7
- already filtered by feedCategories — so this page shows exactly what this node
8
- publishes and cannot show anything else. It is deliberately plain: no build
9
- step, no dependencies, and it degrades to a readable list of links if the
6
+ Reads /latest/ for the categories and, for the archives, /api/catalog where a
7
+ credential allows it and /feed.xml where one does not — everything under /api/
8
+ is guarded on a node with any authentication configured, which is most of them,
9
+ and a front page that answers 401 to its own audience is worse than none.
10
+
11
+ All three are filtered by feedCategories, so this page shows exactly what the
12
+ node publishes and cannot show anything else. It is deliberately plain: no
13
+ build step, no dependencies, and it degrades to a readable list of links if the
10
14
  script does not run.
11
15
  -->
12
16
  <html lang="en">
@@ -167,7 +171,7 @@
167
171
  <noscript>
168
172
  <p class="failed">
169
173
  This page lists the archives using JavaScript. Without it, the same
170
- information is at <a href="/api/catalog">/api/catalog</a> as JSON and
174
+ information is at <a href="/latest/">/latest/</a> as JSON and
171
175
  <a href="/feed.xml">/feed.xml</a> as RSS.
172
176
  </p>
173
177
  </noscript>
@@ -176,8 +180,7 @@
176
180
  <div id="archives"><p class="empty">Loading…</p></div>
177
181
 
178
182
  <footer>
179
- <a href="/api/catalog">catalog JSON</a> ·
180
- <a href="/api/categories">categories JSON</a> ·
183
+ <a href="/latest/">categories JSON</a> ·
181
184
  <a href="/feed.xml">RSS feed</a> ·
182
185
  <a href="/health">health</a>
183
186
  </footer>
@@ -280,27 +283,85 @@
280
283
 
281
284
  // Categories are the handle that survives a rebuild, so they go first —
282
285
  // a style should point at one of these rather than at an infohash.
286
+ // Categories first, and given the same weight as an archive, because
287
+ // for most visitors they are the more useful thing: an archive URL names
288
+ // one build and goes stale on the next, while a category names whichever
289
+ // is current and keeps working across rebuilds. A style should point at
290
+ // one of these.
283
291
  const renderCategories = (categories) => {
284
292
  const host = document.getElementById('categories');
285
- const usable = categories.filter((c) => c.endpoints?.styleUrl);
293
+ host.textContent = '';
294
+ const usable = categories.filter((c) => c.endpoints);
286
295
  if (usable.length === 0) return;
287
296
 
288
297
  host.append(el('h2', null, 'Categories'));
289
- const urls = el('div', 'urls');
298
+ host.append(
299
+ el(
300
+ 'p',
301
+ 'facts',
302
+ 'These follow the newest build. Point a style at one of these ' +
303
+ 'rather than at an archive below, and it keeps working when the ' +
304
+ 'archive is rebuilt.',
305
+ ),
306
+ );
307
+
290
308
  for (const entry of usable) {
291
- // `category` and `archives` are what /api/categories actually calls
309
+ // `category` and `archives` are what the endpoint actually calls
292
310
  // these; `name` and `count` would read better and are not there.
293
- const label = Number.isFinite(entry.archives)
294
- ? `${entry.category} (${entry.archives})`
295
- : entry.category;
296
- urls.append(
297
- urlRow(label, new URL(entry.endpoints.styleUrl, location.href).href),
298
- );
311
+ const card = el('div', 'archive');
312
+ const head = el('header');
313
+ head.append(el('span', 'name', entry.category));
314
+ if (Number.isFinite(entry.archives)) {
315
+ head.append(
316
+ el(
317
+ 'span',
318
+ 'tag',
319
+ `${entry.archives} build${entry.archives === 1 ? '' : 's'}`,
320
+ ),
321
+ );
322
+ }
323
+ card.append(head);
324
+
325
+ if (entry.newest?.name) {
326
+ const facts = [`newest: ${entry.newest.name}`];
327
+ const size = bytes(entry.newest.size);
328
+ if (size) facts.push(size);
329
+ card.append(el('p', 'facts', facts.join(' · ')));
330
+ }
331
+
332
+ const links = el('div', 'links');
333
+ const add = (href, text) => {
334
+ const a = el('a', null, text);
335
+ a.href = href;
336
+ links.append(a);
337
+ };
338
+ const ends = entry.endpoints;
339
+ if (ends.tileJson) add(ends.tileJson, 'TileJSON');
340
+ if (ends.torrent) add(ends.torrent, '.torrent');
341
+ if (ends.magnet) add(ends.magnet, 'magnet');
342
+ if (ends.feed) add(ends.feed, 'RSS');
343
+ card.append(links);
344
+
345
+ const urls = el('div', 'urls');
346
+ // The one worth copying: the TileJSON URL with the magnet in its
347
+ // fragment, which a plain client fetches over HTTP and a swarm-aware
348
+ // one joins directly.
349
+ if (ends.styleUrl) {
350
+ urls.append(
351
+ urlRow('style URL', new URL(ends.styleUrl, location.href).href),
352
+ );
353
+ } else if (ends.tileJson) {
354
+ urls.append(
355
+ urlRow('TileJSON', new URL(ends.tileJson, location.href).href),
356
+ );
357
+ }
358
+ if (urls.childElementCount > 0) card.append(urls);
359
+
360
+ host.append(card);
299
361
  }
300
- host.append(urls);
362
+
301
363
  host.append(el('h2', null, 'Archives'));
302
364
  };
303
-
304
365
  const PMTILES_NS =
305
366
  'https://github.com/TechIdiots-LLC/pmtiles-swarm/ns/1.0';
306
367
 
@@ -381,11 +442,17 @@
381
442
 
382
443
  render(archives.filter((archive) => archive.infoHash));
383
444
 
384
- // Best effort, and after the archives: this one is guarded on an
385
- // authenticated node and the page is worth showing without it.
445
+ // /latest/ rather than /api/categories: the same list, without a
446
+ // credential. This is the part most visitors actually want -- an
447
+ // archive URL names one build, a category names whichever is current
448
+ // -- so it is worth more than a best-effort attempt at a guarded
449
+ // endpoint that answers 401 to exactly the audience this page is for.
386
450
  try {
387
- const response = await fetch('/api/categories');
388
- if (response.ok) renderCategories(await response.json());
451
+ const response = await fetch('/latest/');
452
+ if (response.ok) {
453
+ const body = await response.json();
454
+ renderCategories(body.categories ?? []);
455
+ }
389
456
  } catch {
390
457
  // Nothing to say. The archives above are the substance.
391
458
  }