pmtiles-swarm 0.17.0 → 0.18.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
@@ -2,10 +2,28 @@
2
2
 
3
3
  ## master
4
4
  ### ✨ Features and improvements
5
+ - **A watched folder can ask for its stable name to be a hard link.**
6
+ `latestLinkType: "hard"` reverses the order the two kinds are attempted in,
7
+ for a folder whose archives are read *through* that name rather than followed
8
+ to see where it points. A hard link still resolves after the build it names
9
+ is retired; a symlink is left pointing at nothing, which a tile endpoint
10
+ reports as a missing archive while the bytes are still on the disk. The other
11
+ kind remains the fallback in both directions, and the log now always says
12
+ which was made rather than only mentioning the unexpected one.
13
+ - **The default trackers now include WebSocket ones.** They were two `udp://` entries, so an
14
+ archive created with stock configuration was undiscoverable from a browser — healthy in a
15
+ desktop client, invisible from a page, with nothing in either to say why. A browser speaks
16
+ WebRTC only and has no DHT, PeX or local discovery to fall back on, so `wss://` is not
17
+ redundancy with the rest of the list, it is the whole of that path. Two are listed because it
18
+ has no backstop. Every entry was checked for a completed handshake before being added.
5
19
  - _...Add new stuff here..._
6
20
 
7
21
  ### 🐞 Bug fixes
8
- - _...Add new stuff here..._
22
+ - **Retention no longer reaches across a folder's other entries.** A watched folder's family was
23
+ built from the directory alone, so several entries sharing one — which `match` exists to make
24
+ possible — were treated as a single family. With `keep: 1`, importing this week's `monthly`
25
+ retired `10yrplus` and deleted its data. The family is now scoped by the entry's glob as well
26
+ as its path. Introduced in 0.17.0; anything affected must be regenerated.
9
27
 
10
28
  ## 0.17.0
11
29
  ### ✨ Features and improvements
package/docs/engines.md CHANGED
@@ -285,31 +285,31 @@ load becomes swarm capacity instead of passing through the same pipe.
285
285
  This is a configuration requirement, not an automatic one, and it is easy to miss because
286
286
  everything looks healthy without it.
287
287
 
288
- The default trackers are UDP:
288
+ The default list carries both kinds:
289
289
 
290
290
  ```json
291
291
  "trackers": [
292
292
  "udp://tracker.opentrackr.org:1337/announce",
293
- "udp://tracker.torrent.eu.org:451/announce"
293
+ "udp://tracker.torrent.eu.org:451/announce",
294
+ "udp://tracker-udp.gbitt.info:80/announce",
295
+ "wss://tracker.openwebtorrent.com",
296
+ "wss://tracker.webtorrent.dev"
294
297
  ]
295
298
  ```
296
299
 
297
- **A browser cannot use either of those, and cannot use the DHT.** Both need UDP sockets and raw
300
+ **A browser cannot use the UDP ones, and cannot use the DHT.** Both need UDP sockets and raw
298
301
  TCP, which a page does not have. A browser's only route to discovery is a `wss://` tracker — so a
299
302
  torrent announcing to UDP trackers alone is one no browser can find a peer for, however many
300
303
  WebTorrent nodes are seeding it. WebTorrent's own default WebSocket trackers do not fill this gap
301
304
  either: they are compiled into its **browser** bundle, and a Node client adds nothing.
302
305
 
303
- To actually reach browser peers, announce to at least one WebSocket tracker as well:
306
+ Two `wss://` entries are listed rather than one because that path has no backstop: when the UDP
307
+ side loses a tracker there are still the others, the DHT, PeX and local discovery, and when the
308
+ WebSocket side loses one a browser has nothing left.
304
309
 
305
- ```json
306
- "trackers": [
307
- "udp://tracker.opentrackr.org:1337/announce",
308
- "udp://tracker.torrent.eu.org:451/announce",
309
- "wss://tracker.openwebtorrent.com",
310
- "wss://tracker.webtorrent.dev"
311
- ]
312
- ```
310
+ If you replace this list, keep at least one `wss://` entry — and check it answers, rather than
311
+ copying one from a list. Public WebSocket trackers come and go, and a dead one fails in exactly
312
+ the way this section describes: silently, and only for browsers.
313
313
 
314
314
  Trackers live outside the torrent's `info` dictionary, so adding them **does not change the
315
315
  infohash** — but it only applies to torrents created after the change. An existing archive keeps
@@ -377,6 +377,24 @@ A new `.pmtiles` appearing in a watched folder is imported automatically.
377
377
 
378
378
  Editable in **Settings → Monitored folders** as a table, rather than by hand.
379
379
 
380
+ ### What kind of link the stable name is
381
+
382
+ `latestLink` is made as a symlink by default. Set `latestLinkType: "hard"` on
383
+ the folder when something reads the archive *through* that name — a tile
384
+ server, or anything else that opens it rather than following it to see where it
385
+ points.
386
+
387
+ The difference only shows when the build the name refers to goes away. A hard
388
+ link is another name for the same bytes, so it still resolves; a symlink is
389
+ left pointing at nothing. Retention repoints the name as it retires a build, so
390
+ neither case arises from ordinary housekeeping — but a rebuild that fails
391
+ between the two steps leaves a reader working in one arrangement and broken in
392
+ the other.
393
+
394
+ The other kind stays the fallback either way: Windows refuses symlinks without
395
+ elevation, and a hard link cannot cross a filesystem, so the name is still made
396
+ whichever was asked for. The log says which it got.
397
+
380
398
  ### One folder, several kinds of build
381
399
 
382
400
  Categories and retention are decided per entry, not per directory. A generator
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.17.0",
3
+ "version": "0.18.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/config.js CHANGED
@@ -236,10 +236,27 @@ const DEFAULTS = {
236
236
  *
237
237
  * Only applies to torrents created here. Joining an existing one uses the
238
238
  * trackers that torrent already carries.
239
+ *
240
+ * The `wss://` entries are not redundancy with the others — they are the
241
+ * only ones a browser can use. A page speaks WebRTC and nothing else: it
242
+ * cannot reach a UDP or HTTP tracker, and it has no DHT, PeX or local
243
+ * discovery to fall back on. An archive announced only to `udp://` trackers
244
+ * is perfectly healthy in a desktop client and invisible from a browser,
245
+ * with nothing in either to say why. Two are listed because that single
246
+ * path has no backstop.
247
+ *
248
+ * Keep this list short and answering. A tracker that no longer resolves
249
+ * costs an announce attempt per interval per torrent and finds nobody —
250
+ * unlike one that is merely unreachable from here, which can still
251
+ * introduce peers on networks that can reach it, since the list travels
252
+ * with the torrent.
239
253
  */
240
254
  trackers: [
241
255
  'udp://tracker.opentrackr.org:1337/announce',
242
256
  'udp://tracker.torrent.eu.org:451/announce',
257
+ 'udp://tracker-udp.gbitt.info:80/announce',
258
+ 'wss://tracker.openwebtorrent.com',
259
+ 'wss://tracker.webtorrent.dev',
243
260
  ],
244
261
  /**
245
262
  * Copy every .torrent we create into this directory as well.
@@ -596,6 +613,15 @@ const DEFAULTS = {
596
613
  * ever touch archives this same folder imported, and never remove the
597
614
  * newest build however old it gets.
598
615
  *
616
+ * `latestLinkType` picks how `latestLink` is made: `symbolic` (the default),
617
+ * or `hard` for a name something reads the archive through. A hard link
618
+ * still resolves after the build it names is retired, where a symlink is
619
+ * left pointing at nothing — so a tile endpoint opening that name keeps
620
+ * working across a rebuild either way, and only one of them keeps working if
621
+ * the rebuild fails halfway. The other kind stays the fallback in both
622
+ * directions, since Windows refuses symlinks without elevation and a hard
623
+ * link cannot cross a filesystem.
624
+ *
599
625
  * `match` is a glob tested against the filename, for a folder holding more
600
626
  * than one kind of build. Categories are decided per entry, so several
601
627
  * entries can watch one folder and each take only its own archives:
@@ -20,21 +20,35 @@ import path from 'node:path';
20
20
  * this names is eventually deleted: a symlink is left dangling, while a hard
21
21
  * link keeps the bytes alive until it too is gone. Retention never removes the
22
22
  * newest build, so neither case arises from this node's own housekeeping.
23
+ *
24
+ * `type` chooses which is preferred, because which one is better depends on
25
+ * what opens the name. Something reading the archive through this link — a
26
+ * tile endpoint, say — is better served by a hard link: if the build it names
27
+ * is removed before the name is repointed, a hard link still resolves to the
28
+ * bytes while a symlink resolves to nothing. The other order is the default
29
+ * only because it is what has always happened here.
23
30
  * @param {object} options - What to link and how to say so.
24
31
  * @param {string} options.target - The build the name should resolve to.
25
32
  * @param {string} options.name - The stable name, absolute or beside the target.
26
33
  * @param {string} options.label - How to name the caller in the log.
34
+ * @param {string} [options.type] - 'symbolic' (default), 'hard', or 'auto'.
27
35
  * @returns {Promise<string|undefined>} - The link made, or undefined on failure.
28
36
  */
29
- export async function linkLatest({ target, name, label }) {
37
+ export async function linkLatest({ target, name, label, type = 'symbolic' }) {
30
38
  const link = linkPathFor(target, name);
31
39
 
32
- const attempts = [
40
+ const symbolic = [
33
41
  // The type is autodetected from the target on Windows and ignored
34
42
  // everywhere else, which is right: the target is always a file here.
35
- ['symlink', () => fs.symlink(target, link)],
36
- ['hard link', () => fs.link(target, link)],
43
+ 'symlink',
44
+ () => fs.symlink(target, link),
37
45
  ];
46
+ const hard = ['hard link', () => fs.link(target, link)];
47
+
48
+ // Both orders keep the other as a fallback rather than failing outright:
49
+ // Windows refuses symlinks without elevation, and a hard link cannot cross a
50
+ // filesystem. Whichever is asked for, the name still gets made.
51
+ const attempts = type === 'hard' ? [hard, symbolic] : [symbolic, hard];
38
52
 
39
53
  for (const [kind, make] of attempts) {
40
54
  try {
@@ -42,10 +56,10 @@ export async function linkLatest({ target, name, label }) {
42
56
  // per build and the name by definition already exists after the first.
43
57
  await fs.rm(link, { force: true });
44
58
  await make();
45
- console.log(
46
- `${label} latest -> ${path.basename(target)}` +
47
- (kind === 'symlink' ? '' : ` (${kind})`),
48
- );
59
+ // Always says which kind. Reporting only the unexpected one meant the
60
+ // common case was silent, and the only way to learn what you had was to
61
+ // list the directory and read the mode bits.
62
+ console.log(`${label} latest -> ${path.basename(target)} (${kind})`);
49
63
  return link;
50
64
  } catch (error) {
51
65
  // Try the next kind rather than giving up on the first refusal; only the
package/src/watch.js CHANGED
@@ -134,7 +134,7 @@ export class WatchManager {
134
134
  return !this.#isLatestLink(file, entry);
135
135
  });
136
136
 
137
- if (owner) this.#import(file, owner.entry);
137
+ if (owner) this.#import(file, owner.entry, owner.match);
138
138
  });
139
139
  watcher.on('error', (error) => {
140
140
  console.error(`[watch] ${directory}: ${error.message}`);
@@ -172,9 +172,10 @@ export class WatchManager {
172
172
  * Imports one archive, guarding against overlapping imports of the same file.
173
173
  * @param {string} file - Path to the archive.
174
174
  * @param {object} folder - The watch-folder configuration.
175
+ * @param {RegExp|null} [match] - The entry's filename filter, if it has one.
175
176
  * @returns {Promise<void>} - Resolves once imported or skipped.
176
177
  */
177
- async #import(file, folder) {
178
+ async #import(file, folder, match = null) {
178
179
  if (this.#importing.has(file)) return;
179
180
  this.#importing.add(file);
180
181
  try {
@@ -213,6 +214,10 @@ export class WatchManager {
213
214
  target: entry.retainedAt ?? path.join(entry.savePath, entry.name),
214
215
  name: folder.latestLink,
215
216
  label: `[watch] ${folder.path}`,
217
+ // Per folder: a folder whose archives are opened through this
218
+ // name wants a hard link, one that is only a convenience for a
219
+ // person browsing the directory does not care.
220
+ type: folder.latestLinkType,
216
221
  });
217
222
  }
218
223
 
@@ -223,9 +228,20 @@ export class WatchManager {
223
228
  if (!retains(folder)) return;
224
229
  await retire({
225
230
  library: this.#library,
231
+ // Scoped by the entry, not just by the directory. Several entries
232
+ // describing one directory is the whole point of `match`, and a family
233
+ // built from the path alone puts every bucket in one: importing
234
+ // this week's `monthly` would retire `10yrplus` under keep:1, deleting
235
+ // an archive that has nothing to do with it. Re-applying the glob is
236
+ // what splits one directory back into the families the config
237
+ // describes.
226
238
  family: this.#library.catalog
227
239
  .list()
228
- .filter((candidate) => candidate.source?.watch === folder.path),
240
+ .filter(
241
+ (candidate) =>
242
+ candidate.source?.watch === folder.path &&
243
+ (!match || match.test(path.basename(candidate.name ?? ''))),
244
+ ),
229
245
  entry,
230
246
  keep: folder.keep,
231
247
  keepDays: folder.keepDays,
@@ -3427,6 +3427,14 @@
3427
3427
  label: 'Stable name',
3428
3428
  placeholder: 'off',
3429
3429
  },
3430
+ {
3431
+ field: 'latestLinkType',
3432
+ label: 'Stable name is a',
3433
+ options: [
3434
+ ['', 'symlink'],
3435
+ ['hard', 'hard link'],
3436
+ ],
3437
+ },
3430
3438
  {
3431
3439
  field: 'keep',
3432
3440
  label: 'Builds to keep',