pmtiles-swarm 0.5.6 β†’ 0.6.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,40 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.6.0
11
+ ### ✨ Features and improvements
12
+ - **Check now, on scheduled sources and on feeds.** A schedule describes ordinary operation, and
13
+ setting one up is not ordinary operation β€” waiting six hours to find out whether a URL template
14
+ is right is how a typo survives a working day. `POST /api/sources/check` is new; the feed
15
+ equivalent existed and had no button. Both check what is *saved* rather than what is on screen,
16
+ since an unsaved row is not a source this node knows about.
17
+ - **Run the completion hook again for one archive**, from its General tab or
18
+ `POST /api/torrents/<infohash>/hooks/complete`. Completion is recorded before the command runs,
19
+ so a build taking six hours is not started six times over β€” but a hook that failed for a reason
20
+ since fixed keeps that record too, and the only way to run it again was to stop the node and
21
+ edit the catalog by hand. Started rather than awaited, since the command may be a planet build;
22
+ refused with 409 if it is already running for that archive, because two builds writing the same
23
+ output is worse than waiting. It does not choose *which* command β€” that is still the config
24
+ file's business, and `allowHooksFromApi` still guards choosing it.
25
+
26
+ ### 🐞 Bug fixes
27
+ - **The node no longer crashes while shutting down.** `child.stdin` had no `error` listener, and
28
+ an unhandled `'error'` event is not a rejected promise β€” it is a throw that takes the process
29
+ with it. systemd's default `KillMode` signals every process in a service's cgroup, so the Python
30
+ sidecar exited first and the shutdown request was written into a dead pipe:
31
+
32
+ ```
33
+ [shutdown] SIGTERM
34
+ Error: write EPIPE ... at #call (src/engines/libtorrent.js)
35
+ Main process exited, code=exited, status=1/FAILURE
36
+ ```
37
+
38
+ Every stop ended that way, and because the crash happened *inside* `destroy()`, the shutdown
39
+ that saves resume data never ran β€” so this was also a third, independent reason resume data
40
+ went missing. `Restart=always` brought the node back five seconds later looking healthy, which
41
+ is why it went unnoticed. A failed write now fails the call that made it; the pipe is checked
42
+ before writing; and there is a test that spawns a sidecar which reports ready and then exits.
43
+
10
44
  ## 0.5.6
11
45
  ### 🐞 Bug fixes
12
46
  - **The Pieces and Peers tabs redraw themselves.** A detail pane is filled once, the first time
package/README.md CHANGED
@@ -604,6 +604,8 @@ matters there is `maxConnections`, since every peer holds a NAT table entry. See
604
604
  | `POST` | `/api/sources/preview` | What a watched web location would take, without taking it |
605
605
  | `POST` | `/api/subscriptions/preview` | Whether a peer is reachable and what it offers |
606
606
  | `POST` | `/api/subscriptions/refresh` | Poll subscribed feeds now |
607
+ | `POST` | `/api/sources/check` | Check scheduled sources now, rather than at the next due time |
608
+ | `POST` | `/api/torrents/:infoHash/hooks/complete` | Run the completion hook again for one archive |
607
609
  | `GET` `POST` `DELETE` | `/api/tokens`, `/api/tokens/:id` | Mint, list and revoke access tokens |
608
610
  | `GET` `POST` | `/api/restart` | What a restart would do, and doing it |
609
611
  | `GET` `PATCH` | `/api/config` | Read and change settings |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.5.6",
3
+ "version": "0.6.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
@@ -63,6 +63,8 @@ export function createApp({
63
63
  catalog,
64
64
  engine,
65
65
  subscriptions,
66
+ sources,
67
+ hooks,
66
68
  tiles,
67
69
  warm,
68
70
  config,
@@ -1386,6 +1388,55 @@ export function createApp({
1386
1388
  }),
1387
1389
  );
1388
1390
 
1391
+ // The same for scheduled sources: ask now rather than at the next due time.
1392
+ // Worth having for the same reason a feed refresh is β€” a schedule is a
1393
+ // statement about ordinary operation, and setting one up is not ordinary
1394
+ // operation. Waiting six hours to find out whether a URL template is right
1395
+ // is how a typo survives a working day.
1396
+ app.post(
1397
+ '/api/sources/check',
1398
+ route(async (_req, res) => {
1399
+ if (!sources?.sweep) {
1400
+ return res.status(501).json({ error: 'no scheduled sources here' });
1401
+ }
1402
+ const taken = await sources.sweep(new Date());
1403
+ res.json({ taken: taken.length, entries: taken });
1404
+ }),
1405
+ );
1406
+
1407
+ // Run the completion hook again for one archive.
1408
+ //
1409
+ // The sweep runs it once and records that it has, so a six-hour build is not
1410
+ // started six times over β€” but a hook that failed for a reason since fixed
1411
+ // keeps that record too, and the only way to run it again was to stop the
1412
+ // node and edit the catalog by hand.
1413
+ //
1414
+ // A POST, and therefore refused to a read-only token: this runs a command as
1415
+ // the service user. It does not choose *which* command β€” that is still the
1416
+ // config file's business, and `allowHooksFromApi` still guards it.
1417
+ app.post(
1418
+ '/api/torrents/:infoHash/hooks/complete',
1419
+ route(async (req, res) => {
1420
+ if (!hooks?.runComplete) {
1421
+ return res.status(501).json({ error: 'hooks are not available here' });
1422
+ }
1423
+ try {
1424
+ const started = await hooks.runComplete(req.params.infoHash);
1425
+ res.json({ started: true, ...started });
1426
+ } catch (error) {
1427
+ // Told apart because they mean different things to whoever pressed
1428
+ // the button: nothing configured, nothing to run it for, or already
1429
+ // going.
1430
+ const status = /unknown archive/.test(error.message)
1431
+ ? 404
1432
+ : /already running/.test(error.message)
1433
+ ? 409
1434
+ : 400;
1435
+ res.status(status).json({ error: error.message });
1436
+ }
1437
+ }),
1438
+ );
1439
+
1389
1440
  /**
1390
1441
  * How many items this feed request should return.
1391
1442
  *
@@ -134,6 +134,24 @@ export class LibtorrentEngine {
134
134
  });
135
135
  this.#child = child;
136
136
 
137
+ // A pipe to a process that has gone raises 'error' on the stream, and an
138
+ // unhandled 'error' event is not a rejected promise β€” it is a throw that
139
+ // takes the whole node down with it. That is what happened on every
140
+ // stop: systemd's default KillMode signals every process in the cgroup,
141
+ // so the sidecar exited first and the shutdown request was written into
142
+ // a dead pipe. The service died with EPIPE and status=1/FAILURE, having
143
+ // never saved its resume data, and no `.catch()` on the call could have
144
+ // caught it.
145
+ child.stdin.on('error', (error) => {
146
+ if (!this.#stopping) {
147
+ console.warn(`[libtorrent] sidecar input failed: ${error.message}`);
148
+ }
149
+ for (const [id, waiter] of this.#pending) {
150
+ this.#pending.delete(id);
151
+ waiter.reject(error);
152
+ }
153
+ });
154
+
137
155
  const timer = setTimeout(() => {
138
156
  reject(
139
157
  new Error(
@@ -481,7 +499,22 @@ export class LibtorrentEngine {
481
499
  },
482
500
  });
483
501
 
484
- child.stdin.write(`${JSON.stringify({ id, op, params })}\n`);
502
+ // Checked and reported rather than thrown at. Writing to a closed pipe
503
+ // is ordinary during shutdown, and a call that cannot be sent should
504
+ // fail as a call rather than as the process.
505
+ if (!child.stdin.writable || child.stdin.destroyed) {
506
+ this.#pending.delete(id);
507
+ clearTimeout(timer);
508
+ reject(new Error(`libtorrent ${op}: the sidecar is no longer running`));
509
+ return;
510
+ }
511
+
512
+ child.stdin.write(`${JSON.stringify({ id, op, params })}\n`, (error) => {
513
+ if (!error) return;
514
+ this.#pending.delete(id);
515
+ clearTimeout(timer);
516
+ reject(new Error(`libtorrent ${op}: ${error.message}`));
517
+ });
485
518
  });
486
519
  }
487
520
 
package/src/hooks.js CHANGED
@@ -193,6 +193,48 @@ export class ProgramHooks {
193
193
  return fired;
194
194
  }
195
195
 
196
+ /**
197
+ * Runs the completion hook for one archive, now, whatever it did before.
198
+ *
199
+ * The sweep runs a hook once and records that it has: a build taking six
200
+ * hours must not be started six times over. But a hook that failed for a
201
+ * reason since fixed β€” a path that was wrong, a directory that was not
202
+ * writable β€” keeps that record too, and until now the only way to run it
203
+ * again was to stop the node and edit the catalog by hand.
204
+ *
205
+ * Started rather than awaited. The command may be a planet build, and a
206
+ * request that waited for it would time out long before it finished.
207
+ * @param {string} infoHash - The archive to run it for.
208
+ * @returns {Promise<object>} - What was started.
209
+ */
210
+ async runComplete(infoHash) {
211
+ if (!this.#config.onComplete?.command) {
212
+ throw new Error('no onComplete hook is configured');
213
+ }
214
+
215
+ const live = await this.#library.listWithStatus().catch(() => []);
216
+ const entry = live.find((candidate) => candidate.infoHash === infoHash);
217
+ if (!entry) throw new Error('unknown archive');
218
+ if (this.#running.has(infoHash)) {
219
+ throw new Error('it is already running for this archive');
220
+ }
221
+
222
+ // Recorded before starting, the same as the sweep does and for the same
223
+ // reason: whatever happens next, this is not a fresh completion to be
224
+ // picked up again a minute later.
225
+ await this.#library.catalog.put({
226
+ infoHash,
227
+ completedAt: new Date().toISOString(),
228
+ });
229
+
230
+ this.#running.add(infoHash);
231
+ this.#fire('onComplete', entry)
232
+ .catch(() => {})
233
+ .finally(() => this.#running.delete(infoHash));
234
+
235
+ return { name: entry.name, command: this.#config.onComplete.command };
236
+ }
237
+
196
238
  /**
197
239
  * Runs one of the configured hooks, if it is configured.
198
240
  * @param {string} which - 'onAdded' or 'onComplete'.
package/src/index.js CHANGED
@@ -262,6 +262,8 @@ PMTILES_SWARM_PUBLIC_URL
262
262
  catalog,
263
263
  engine,
264
264
  subscriptions,
265
+ sources,
266
+ hooks,
265
267
  tiles,
266
268
  warm,
267
269
  config,
@@ -1386,6 +1386,20 @@
1386
1386
  </div>`
1387
1387
  }
1388
1388
 
1389
+ <h2>Completion hook</h2>
1390
+ <div class="actions" id="hook-box">
1391
+ <button id="run-hook">Run it for this archive</button>
1392
+ <span class="sub" id="hook-note"></span>
1393
+ </div>
1394
+ <div class="sub" style="margin-bottom:1rem">
1395
+ Runs the configured <code>onComplete</code> command again for this
1396
+ archive. It normally runs once, when the download finishes, and is
1397
+ recorded as having run so a build taking hours is not started over
1398
+ every minute β€” which also means a hook that failed for a reason
1399
+ since fixed will not retry on its own. What it does is decided in
1400
+ the config file; this only decides when.
1401
+ </div>
1402
+
1389
1403
  <h2>Mode</h2>
1390
1404
  <div class="actions" id="mode-box">
1391
1405
  <button id="mode-mirror">Switch to mirror</button>
@@ -1448,6 +1462,30 @@
1448
1462
  const loaded = new Set(['general']);
1449
1463
 
1450
1464
  /** Shows one tab, loading it the first time. */
1465
+ const runHook = $('run-hook');
1466
+ if (runHook) {
1467
+ runHook.onclick = async () => {
1468
+ const note = $('hook-note');
1469
+ runHook.disabled = true;
1470
+ note.textContent = 'starting…';
1471
+ try {
1472
+ const result = await api(
1473
+ `/api/torrents/${infoHash}/hooks/complete`,
1474
+ { method: 'POST' },
1475
+ );
1476
+ // Started, not finished: the command may be a planet build, and
1477
+ // nothing here waits for one.
1478
+ note.textContent = `started ${result.command ?? 'the hook'}`;
1479
+ toast('completion hook started');
1480
+ } catch (error) {
1481
+ note.textContent = error.message;
1482
+ toast(error.message);
1483
+ } finally {
1484
+ runHook.disabled = false;
1485
+ }
1486
+ };
1487
+ }
1488
+
1451
1489
  const showPane = (name) => {
1452
1490
  activeTab = name;
1453
1491
  for (const other of panel.querySelectorAll('.tabs button')) {
@@ -2363,8 +2401,23 @@
2363
2401
  * @returns {void}
2364
2402
  */
2365
2403
  function renderRowEditor(spec) {
2366
- const { into, key, title, blurb, columns, rows, restart, preview, peerPreview, footnote } =
2367
- spec;
2404
+ const {
2405
+ into,
2406
+ key,
2407
+ title,
2408
+ blurb,
2409
+ columns,
2410
+ rows,
2411
+ restart,
2412
+ preview,
2413
+ peerPreview,
2414
+ footnote,
2415
+ // Where to POST a "do it now". A schedule is a statement about
2416
+ // ordinary operation, and setting one up is not ordinary operation:
2417
+ // waiting six hours to learn whether a URL template is right is how
2418
+ // a typo survives a working day.
2419
+ checkNow,
2420
+ } = spec;
2368
2421
  rowEditorColumns[key] = columns;
2369
2422
  // Kept so a save can put back what this editor never showed. An entry
2370
2423
  // holds more than there are columns for it β€” a watch folder's
@@ -2443,6 +2496,11 @@
2443
2496
  </div>
2444
2497
  <div style="margin-top:0.5rem">
2445
2498
  <button type="button" data-act="add">Add row</button>
2499
+ ${
2500
+ checkNow
2501
+ ? '<button type="button" data-act="check-now">Check now</button>'
2502
+ : ''
2503
+ }
2446
2504
  <span class="sub" data-role="note"></span>
2447
2505
  </div>
2448
2506
  ${footnote ? `<div class="sub" style="margin-top:0.6rem">${escapeHtml(footnote)}</div>` : ''}`;
@@ -2475,6 +2533,29 @@
2475
2533
  tbody.insertAdjacentHTML('beforeend', rowHtml());
2476
2534
  return;
2477
2535
  }
2536
+
2537
+ if (action === 'check-now') {
2538
+ // Against what is saved, not what is on screen. An unsaved row is
2539
+ // not something the node knows about, and pretending otherwise
2540
+ // would report on a source that does not exist yet.
2541
+ const button = event.target;
2542
+ button.disabled = true;
2543
+ note.textContent = 'checking…';
2544
+ try {
2545
+ const result = await api(checkNow, { method: 'POST' });
2546
+ const count = result.added ?? result.taken ?? 0;
2547
+ note.textContent =
2548
+ count > 0
2549
+ ? `took ${count} β€” save first if you have unsaved changes`
2550
+ : 'nothing new';
2551
+ if (count > 0) refresh();
2552
+ } catch (error) {
2553
+ note.textContent = error.message;
2554
+ } finally {
2555
+ button.disabled = false;
2556
+ }
2557
+ return;
2558
+ }
2478
2559
  if (action === 'drop') {
2479
2560
  // Never leave it with nothing to type into.
2480
2561
  if (tbody.children.length === 1) row.querySelectorAll('input').forEach((i) => (i.value = ''));
@@ -3319,6 +3400,7 @@
3319
3400
  },
3320
3401
  ],
3321
3402
  rows: config.sources ?? [],
3403
+ checkNow: '/api/sources/check',
3322
3404
  preview: true,
3323
3405
  footnote:
3324
3406
  'Tokens combine however the upstream spells its filenames β€” ' +
@@ -3450,6 +3532,7 @@
3450
3532
  },
3451
3533
  ],
3452
3534
  rows: config.subscriptions ?? [],
3535
+ checkNow: '/api/subscriptions/refresh',
3453
3536
  peerPreview: true,
3454
3537
  footnote:
3455
3538
  'Take as decides the disk this costs: cache joins the swarm and ' +