pmtiles-swarm 0.5.0 → 0.5.2

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,36 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.5.2
11
+ ### 🐞 Bug fixes
12
+ - **Asks for the `pmtiles-torrent` that 0.5.1 actually needs.** It shipped declaring `^0.3.2`,
13
+ and a caret on a `0.x` version allows patch updates only — so that range can never install
14
+ 0.4.0, which is where the op behind writing a `.torrent` for a magnet-joined archive lives.
15
+ As published, 0.5.1 could not get the sidecar its own new code depends on. It fails safely
16
+ either way, since an older sidecar answers "unknown op" and the call is caught, but it fails
17
+ silently: the feed keeps serving enclosure URLs that 404 and nothing says why.
18
+
19
+ ## 0.5.1
20
+ ### ✨ Features and improvements
21
+ - **An archive joined by magnet writes its own `.torrent` on a libtorrent node.** The machinery
22
+ was already there — `captureMetadata` writes the metainfo down, and completion sweeps retry it
23
+ for anything still missing one — but it asks the engine for the metainfo, and the libtorrent
24
+ engine had no way to give it. Only WebTorrent did, and WebTorrent only ever receives archives
25
+ that are already complete. So on the engine most people run, a magnet-joined archive could
26
+ never produce a `.torrent`, however long it ran.
27
+
28
+ That is one gap with a long tail: a node with no `.torrent` to publish serves a feed whose
29
+ enclosure URLs 404, so its subscribers join by magnet too, and a magnet carrying no trackers
30
+ has only the DHT to find its first peer with. Needs `pmtiles-torrent` 0.4.0, which added the
31
+ op; against an older sidecar the call is refused and caught, exactly as before.
32
+
33
+ ### 🐞 Bug fixes
34
+ - **A head-warm that was simply too early no longer waits out the full backoff.** An archive
35
+ joined by magnet has no metainfo until BEP 9 finishes, so a read asked for in the same second
36
+ the node started is refused before it reaches the swarm at all. That is a wait, not an
37
+ attempt: it is now retried on the next pass rather than in two minutes, and reported once
38
+ rather than every time.
39
+
10
40
  ## 0.5.0
11
41
  ### ✨ Features and improvements
12
42
  - **The head of a newly joined archive is read without waiting to be asked.** A PMTiles archive
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
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",
@@ -39,7 +39,7 @@
39
39
  "maplibre-gl": "^6.2.0",
40
40
  "parse-torrent": "^11.0.24",
41
41
  "pmtiles": "^4.4.1",
42
- "pmtiles-torrent": "^0.3.2",
42
+ "pmtiles-torrent": "^0.4.0",
43
43
  "webtorrent": "^3.0.21"
44
44
  },
45
45
  "engines": {
@@ -324,16 +324,32 @@ export class LibtorrentEngine {
324
324
  return this.#call('peers', { infoHash });
325
325
  }
326
326
 
327
+ /**
328
+ * The metainfo of a torrent, so a magnet can stop being a magnet.
329
+ * @param {string} infoHash - The archive.
330
+ * @returns {Promise<Uint8Array|null>} - The .torrent bytes, or null.
331
+ */
332
+ async metadata(infoHash) {
333
+ // The same thing a torrent client's "export .torrent" offers, and for the
334
+ // same reason: once BEP 9 has delivered the info dictionary, this node
335
+ // holds everything a .torrent contains — whether or not a byte of the
336
+ // archive itself has arrived.
337
+ //
338
+ // Without it a node that joined by magnet has no .torrent to publish, so
339
+ // every subscriber following its feed also joins by magnet; and a magnet
340
+ // that carries no trackers has only the DHT to find its first peer with,
341
+ // which is minutes of waiting per archive rather than none. libtorrent
342
+ // could always do this. Nothing had asked it to.
343
+ const result = await this.#call('metadata', { infoHash });
344
+ if (!result?.torrentFile) return null;
345
+ return new Uint8Array(Buffer.from(result.torrentFile, 'base64'));
346
+ }
347
+
327
348
  /**
328
349
  * Creates a torrent from a local file.
329
- *
330
- * Defaults to hybrid v1+v2, which is the capability that justifies this
331
- * engine: v2 gives per-file merkle trees with 16 KiB leaf blocks, so a peer
332
- * can verify a small block without holding the whole hash list, while the v1
333
- * half keeps every existing client working.
334
- * @param {string} filePath - Path to the archive.
350
+ * @param {string} filePath - The file to hash.
335
351
  * @param {object} [options] - Piece length, trackers, web seeds, format.
336
- * @returns {Promise<object>} - The created torrent, torrentFile as bytes.
352
+ * @returns {Promise<object>} - The torrent file and what it describes.
337
353
  */
338
354
  async createTorrent(filePath, options = {}) {
339
355
  const result = await this.#call(
package/src/prewarm.js CHANGED
@@ -24,6 +24,24 @@
24
24
  /** How long to leave an archive alone after a failed attempt. */
25
25
  const DEFAULT_BACKOFF_MS = 120000;
26
26
 
27
+ /**
28
+ * Whether a failure means "not yet" rather than "not working".
29
+ *
30
+ * An archive joined from a magnet has no metainfo until BEP 9 has finished, and
31
+ * until then the engine cannot say where a byte range even falls — so a read
32
+ * asked for in the same second the node started is refused before it reaches
33
+ * the swarm at all. That is a wait, not an attempt, and treating it as one cost
34
+ * the full backoff for something that resolves in seconds.
35
+ *
36
+ * Matched on the message because that is what the engine gives us; both the
37
+ * sidecar and the WebTorrent engine word it the same way.
38
+ * @param {Error} error - What the read threw.
39
+ * @returns {boolean} - True when it is worth trying again shortly.
40
+ */
41
+ function tooEarly(error) {
42
+ return /metadata has not arrived|not held here/i.test(error?.message ?? '');
43
+ }
44
+
27
45
  export class HeadWarmer {
28
46
  #tiles;
29
47
  #catalog;
@@ -31,6 +49,7 @@ export class HeadWarmer {
31
49
  #now;
32
50
  #timer;
33
51
  #tried = new Map();
52
+ #waiting = new Set();
34
53
  #running = false;
35
54
 
36
55
  /**
@@ -95,7 +114,6 @@ export class HeadWarmer {
95
114
  if (!entry) return null;
96
115
 
97
116
  this.#running = true;
98
- this.#tried.set(entry.infoHash, this.#now());
99
117
  try {
100
118
  // The long timeout, not the interactive one: this is a byte range from
101
119
  // an archive nobody has asked for a piece of yet.
@@ -103,6 +121,9 @@ export class HeadWarmer {
103
121
  timeoutMs: this.#config.tiles?.metadataTimeoutMs ?? 120000,
104
122
  });
105
123
 
124
+ this.#tried.set(entry.infoHash, this.#now());
125
+ this.#waiting.delete(entry.infoHash);
126
+
106
127
  const stored = await this.#catalog.put({
107
128
  infoHash: entry.infoHash,
108
129
  pmtiles: { ...entry.pmtiles, ...summary },
@@ -115,10 +136,26 @@ export class HeadWarmer {
115
136
  );
116
137
  return stored;
117
138
  } catch (error) {
118
- // Ordinary while an archive is young — the piece holding the header may
119
- // not exist anywhere reachable yet. Worth saying once per attempt, since
120
- // it is the answer to "why is my preview blank", and the backoff keeps
121
- // it from becoming noise.
139
+ if (tooEarly(error)) {
140
+ // Not stamped, so the next pass tries again in seconds rather than in
141
+ // minutes and said once, because a node that has just started will
142
+ // answer this way until the metainfo lands.
143
+ if (!this.#waiting.has(entry.infoHash)) {
144
+ this.#waiting.add(entry.infoHash);
145
+ console.log(
146
+ `[warm] ${entry.name}: waiting for the torrent metadata before ` +
147
+ 'reading its head',
148
+ );
149
+ }
150
+ return null;
151
+ }
152
+
153
+ // A real attempt: it reached the swarm and found nothing. Ordinary while
154
+ // an archive is young, since the piece holding the header may not exist
155
+ // anywhere reachable yet. Worth saying, because it is the answer to "why
156
+ // is my preview blank", and the backoff keeps it from becoming noise.
157
+ this.#tried.set(entry.infoHash, this.#now());
158
+ this.#waiting.delete(entry.infoHash);
122
159
  console.warn(`[warm] ${entry.name}: ${error.message}`);
123
160
  return null;
124
161
  } finally {