pmtiles-swarm 0.33.0 β†’ 0.35.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,89 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.35.0
11
+ ### ✨ Features and improvements
12
+ - **Requires pmtiles-torrent 0.7.0, which stops the libtorrent sidecar going deaf while it works.**
13
+ Its request loop ran each call to completion before reading the next, so a long one starved
14
+ everything behind it. Two of them are long. `create` hashes a whole archive, which for a 698 GiB
15
+ local add is hours β€” reported here as `libtorrent list timed out after 60000ms` every minute, a
16
+ console header stuck at "connecting…", and archive details that never loaded. `read_piece` waits
17
+ up to 60s for a piece to arrive from the swarm, and every tile served from a cache-mode archive
18
+ goes through one, so a serving node spent most of its life unable to answer anything else.
19
+
20
+ Both now run off that loop. The second needed alert delivery reworked to a single pump with
21
+ subscribers first, because every consumer used to drain the session's one alert queue β€” so two
22
+ concurrent reads would have swallowed each other's `read_piece_alert` and both timed out. A read
23
+ also matches its alert on the torrent now, not just the piece number, and a storage fault is
24
+ reported once by the pump rather than only when a read happened to be waiting to notice it.
25
+
26
+ **Upgrade both together.** This release does not itself require the new behaviour, but it is the
27
+ version that asks for it, and a node running a 0.6.x sidecar keeps the stalls.
28
+
29
+ ### 🐞 Bug fixes
30
+
31
+ ## 0.34.0
32
+ ### ✨ Features and improvements
33
+ - **`md5` is a declared setting.** It was already honoured wherever a torrent is created, but it
34
+ appeared in no defaults list and no document, so it could only be written into the config file by
35
+ hand β€” `PATCH /api/config` refused it as an unknown setting, and nothing in the console showed
36
+ whether it was on. It now defaults to `false`, is documented, and can be changed without a
37
+ restart.
38
+ - **`incomingRetentionDays`** sets how long an unfinished download stays resumable. Defaults to 14.
39
+
40
+ ### 🐞 Bug fixes
41
+ - **A large download survives a restart instead of starting again from zero.** A scheduled web
42
+ source fetching a multi-hour archive lost the whole transfer every time the node restarted, and
43
+ began again from nothing on the next poll. Three things had to hold and only one did. The bytes
44
+ were always kept β€” the staging directory is named for a hash of its URL so the next add finds
45
+ it β€” but **shutdown deleted them**, because it stopped in-flight adds through `cancelAdd()`, and
46
+ cancelling discards the partial on purpose: somebody said stop. A restart is not that decision,
47
+ so shutdown now uses `stopAdds()`, which the fetch can tell apart. **Startup then swept whatever
48
+ survived**, on the reasoning that a killed process leaves a partial "nothing will ever look in
49
+ again" β€” true when staging names were random, false since they became a hash of the URL. And
50
+ **the validator did not outlive the process**: the `ETag` a resume is checked against lived in a
51
+ local, so a new process had nothing to compare and refused the resume as "the server offers no
52
+ ETag or Last-Modified", deleting the partial by the very attempt meant to continue it. It is now
53
+ written beside the bytes and removed when the download completes. A restart during a 700 GiB
54
+ transfer now costs the seconds since the last write.
55
+ - **`.incoming` is swept by age rather than emptied.** Only a staging directory nothing has
56
+ written to for `incomingRetentionDays` (default 14) is cleared, so an unfinished download stays
57
+ resumable. The sweep also looks under `cacheSavePath`, which it never did β€” staging lands there
58
+ for cache-mode adds and under a source's own `savePath`, so the one configured `savePath` was
59
+ never the whole of where it could be.
60
+ - **Adding a local archive answers when the file has been checked, not when it has been hashed.**
61
+ `POST /api/torrents` with a `path` held the response open for the whole hash β€” every byte of the
62
+ archive, twice with `md5` on β€” so the console's add dialog sat there for minutes with no sign
63
+ that anything was happening. Worse than the URL case it mirrors, because nothing was downloading
64
+ either: the file was already on the disk and visibly not moving, which reads as a submit button
65
+ that did nothing. It now answers `202` once the path has been identified and accepted, and the
66
+ hash reports itself through `/api/adds` like a download does. A path that is not there or is not
67
+ an archive still fails in the response. An archive already held answers `200` with its entry,
68
+ which the URL branch now does too rather than promising work that was already done. **Scripts
69
+ reading the created entry straight back from a `path` add need `/api/adds` or `/api/torrents`
70
+ instead** β€” magnets and `.torrent` URLs are unchanged and still answer `201`.
71
+ - **A second add of a file already being hashed joins the first rather than starting its own.**
72
+ Only reachable now that the dialog closes quickly enough to submit twice, and two passes over the
73
+ same planet archive is an hour of disk for one result.
74
+ - **The console's MD5 checkbox is now the decision it looks like.** It was only sent when ticked,
75
+ and the server reads an absent `md5` as "unspecified" and falls back to the node's configured
76
+ default β€” so on a node with `md5` on, an unticked box still hashed one, and the log said so while
77
+ the dialog appeared to have turned it off. The value is sent either way, and the box now starts
78
+ from the node's own setting rather than always unticked β€” otherwise making it authoritative would
79
+ have turned a configured default off for every add made from the console, the same disagreement
80
+ the other way round. Omitting `md5` from an API or CLI call still inherits the config default,
81
+ which is what that fallback is for.
82
+ - **The save-location picker is hidden when adding a local file.** It did nothing there: a local
83
+ add registers the file's own directory as the save path whatever was chosen, which is exactly
84
+ what "hashed in place, nothing is copied" says β€” but the picker sat next to that sentence
85
+ implying otherwise, and offered no way to say "leave it where it is" because that is the only
86
+ thing it does.
87
+ - **"What a torrent-aware client does" describes what they now do.** The section predated the swarm
88
+ handles moving into the TileJSON URL's fragment and still had a client learning where to join
89
+ from a TileJSON response β€” the one thing the fragment exists to avoid, since the swarm is the
90
+ part that depends on no server. It contradicted "bootstrapping without the server" two sections
91
+ below it.
92
+
10
93
  ## 0.33.0
11
94
  ### ✨ Features and improvements
12
95
  - **Requires pmtiles-torrent 0.6.1, which is what finally makes a downloading archive servable.**
@@ -21,7 +104,6 @@
21
104
 
22
105
  ## 0.32.3
23
106
  ### ✨ Features and improvements
24
- - _...Add new stuff here..._
25
107
 
26
108
  ### 🐞 Bug fixes
27
109
  - **Requires pmtiles-torrent 0.5.2, which deletes a torrent's resume file with its data.** Resume
@@ -33,11 +115,9 @@
33
115
  the data goes too: a removal that keeps the files is how a pause is expressed for an engine with
34
116
  no pause of its own, and discarding resume data there would turn every pause into a full re-hash.
35
117
 
36
- - _...Add new stuff here..._
37
118
 
38
119
  ## 0.32.2
39
120
  ### ✨ Features and improvements
40
- - _...Add new stuff here..._
41
121
 
42
122
  ### 🐞 Bug fixes
43
123
  - **The head warmer no longer skips every archive it was built for.** It decided an archive was done
@@ -56,11 +136,9 @@
56
136
  header off local disk for nothing, while assuming the opposite would leave every existing
57
137
  subscription stuck exactly as it is.
58
138
 
59
- - _...Add new stuff here..._
60
139
 
61
140
  ## 0.32.1
62
141
  ### ✨ Features and improvements
63
- - _...Add new stuff here..._
64
142
 
65
143
  ### 🐞 Bug fixes
66
144
  - **Requires pmtiles-torrent 0.5.1, so the connection indicator and Recheck files actually work.**
@@ -70,11 +148,9 @@
70
148
  cannot answer is deliberately not reported as unreachable, so there was nothing to see and
71
149
  nothing to explain why.
72
150
 
73
- - _...Add new stuff here..._
74
151
 
75
152
  ## 0.32.0
76
153
  ### ✨ Features and improvements
77
- - _...Add new stuff here..._
78
154
 
79
155
  ### 🐞 Bug fixes
80
156
  - **A mutable magnet no longer carries a web seed.** A `ws=` URL names one build; a BEP 46 magnet
@@ -100,7 +176,6 @@
100
176
  **Restyle anything holding one.** A style carrying an older mutable magnet keeps working, but
101
177
  carries the stale web seed until it is regenerated.
102
178
 
103
- - _...Add new stuff here..._
104
179
 
105
180
  ## 0.31.0
106
181
  ### ✨ Features and improvements
@@ -127,10 +202,8 @@
127
202
  With two engines both are asked, since each keeps its own belief about the same file and a stale
128
203
  one on the secondary is why a browser peer would find nothing while the primary seeds happily.
129
204
 
130
- - _...Add new stuff here..._
131
205
 
132
206
  ### 🐞 Bug fixes
133
- - _...Add new stuff here..._
134
207
 
135
208
  ## 0.30.0
136
209
  ### ✨ Features and improvements
@@ -160,10 +233,8 @@
160
233
  makes the eye stop to work out which it is looking at. Seconds are dropped rather than the date,
161
234
  since nothing here is sorted finely enough for them to matter; hovering still gives them.
162
235
 
163
- - _...Add new stuff here..._
164
236
 
165
237
  ### 🐞 Bug fixes
166
- - _...Add new stuff here..._
167
238
 
168
239
  ## 0.29.0
169
240
  ### ✨ Features and improvements
package/README.md CHANGED
@@ -761,7 +761,7 @@ which the endpoint answers 501.
761
761
  | `POST` | `/api/torrents/:infoHash/check` | Has the source changed since the torrent was made? |
762
762
  | `POST` | `/api/torrents/:infoHash/rebuild` | Rebuild from the current source (mints a new infohash) |
763
763
  | `POST` | `/api/check-origins` | Check every archive with a watchable source |
764
- | `GET` `DELETE` | `/api/adds` | Downloads still in flight, and cancelling one by URL |
764
+ | `GET` `DELETE` | `/api/adds` | Adds still in flight β€” downloads, and local files being hashed β€” and cancelling a download by URL. A hash cannot be stopped part-way, so it is listed but not cancellable |
765
765
  | `GET` `POST` | `/api/speed` | Which speed limits are in force, and the manual switch between the two sets |
766
766
  | `GET` | `/api/categories` | Every category, with the endpoints resolving to its newest build |
767
767
  | `POST` | `/api/adopt`, `/api/adopt/candidates` | Take over what an engine or another node holds |
@@ -154,6 +154,15 @@ stops needing the tile endpoint at all.
154
154
  The important part is that it does both at once β€” HTTP for the first paint, swarm
155
155
  in the background β€” so there is never a blank map waiting for metadata.
156
156
 
157
+ Discovery comes first, and it comes from the style rather than from a response.
158
+ The source URL carries its handles in a fragment
159
+ (`…/tiles.json#torrent=…&magnet=…`, described under
160
+ [bootstrapping](#bootstrapping-without-the-server)), so a client knows there is a
161
+ swarm behind a source before it makes a single request β€” which is the point, as
162
+ that is what still works when the server is down. The TileJSON is fetched anyway,
163
+ because it carries the tile endpoint for the first paint and a fuller `torrent`
164
+ block than a fragment can, but it is no longer how the swarm is _found_.
165
+
157
166
  ```mermaid
158
167
  sequenceDiagram
159
168
  autonumber
@@ -162,24 +171,28 @@ sequenceDiagram
162
171
  participant HTTP as pmtiles-swarm<br/>(via CDN)
163
172
  participant BT as BitTorrent swarm
164
173
 
165
- App->>P: load style β†’ tiles.json
166
- P->>HTTP: GET /archives/{hash}/tiles.json
167
- HTTP-->>P: TileJSON + torrent block
168
-
169
- Note over P: claims the /archives/{hash}/ prefix,<br/>so only these URLs come to it
174
+ App->>P: load style
175
+ Note over P: reads the fragment on each source URL:<br/>torrent= and magnet=. No request yet.
170
176
 
171
177
  par Map is usable immediately
178
+ P->>HTTP: GET /latest/{category}/tiles.json
179
+ Note over P,HTTP: the fragment is never sent
180
+ HTTP-->>P: TileJSON + torrent block
172
181
  App->>P: tile 12/2145/1436
173
182
  P->>HTTP: GET …/12/2145/1436.pbf
174
183
  HTTP-->>App: tile bytes
175
184
  and Swarm warms up in the background
176
- P->>BT: join (.torrent β€” metadata already in hand)
185
+ P->>HTTP: GET the .torrent
186
+ HTTP-->>P: metainfo (piece hashes, trackers, web seeds)
187
+ P->>BT: join β€” metadata already in hand
177
188
  BT-->>P: connected
178
189
  P->>BT: fetch PMTiles header + root directory
179
190
  BT-->>P: those pieces
180
191
  Note over P: now able to resolve any tile<br/>to a byte range locally
181
192
  end
182
193
 
194
+ Note over P: claims the TileJSON URL's prefix (without<br/>the fragment), so only these URLs come to it
195
+
183
196
  App->>P: tile 12/2146/1436
184
197
  Note over P: tile β†’ byte range (PMTiles directory)<br/>β†’ piece index
185
198
  P->>BT: fetch that piece
@@ -201,18 +214,43 @@ is why the `torrent` block carries the archive's `.torrent` rather than per-tile
201
214
  URLs: **there is nothing tile-specific in the swarm.** The swarm holds one file,
202
215
  and both ends know how to read tiles out of it.
203
216
 
204
- Three consequences worth being clear about:
217
+ Consequences worth being clear about:
205
218
 
206
219
  - **HTTP is never fully abandoned.** It is the fallback for anything the swarm
207
220
  cannot answer quickly, and the only path until the swarm is connected.
208
221
  - **The client becomes a seeder.** Every piece it pulls, it serves β€” so a popular
209
222
  region gets _faster_ as more clients view it, which is the opposite of how a
210
223
  tile server behaves under load.
211
- - **Prefer the `.torrent` over the magnet.** A magnet carries only an infohash, so
212
- the client must find peers and complete a metadata exchange before it knows
213
- anything about the archive β€” measured at 90 to 240 seconds against a 72 GiB
214
- archive. The `.torrent` served alongside the TileJSON already contains the
215
- metadata and is ready immediately.
224
+ - **The document is preferred, the fragment is the fallback.** Where the TileJSON
225
+ is reachable and carries a `torrent` block, that block wins: it is the archive's
226
+ own account of itself, and it has web seeds, size and piece length that two
227
+ handles in a URL do not. The fragment is what answers when the document does not
228
+ β€” unreachable, not JSON, or carrying no block β€” which is exactly the case the
229
+ handles were put in the URL for. A client that used only one of the two would be
230
+ either poorly informed or dependent on the server it is meant to survive.
231
+ - **Prefer the `.torrent` over the magnet, and for a browser this is not a
232
+ preference.** A magnet carries only an infohash, so the client must find peers
233
+ and complete a metadata exchange before it knows anything about the archive β€”
234
+ measured at 90 to 240 seconds against a 72 GiB archive. That is the cost for a
235
+ client with a DHT. A browser has neither DHT nor UDP, and piece hashes reach a
236
+ BitTorrent client only from a **peer**, over BEP 9 β€” a web seed serves file
237
+ payload and never metainfo. So a page that cannot reach a peer cannot use the
238
+ web seed either, however reachable that web seed is: it has bytes it is not
239
+ allowed to trust. Fetching the `.torrent` over the same HTTPS the TileJSON came
240
+ from takes the peer off the critical path entirely, which is the difference
241
+ between working on a restricted network and not working at all.
242
+ - **A client with a DHT can join on a magnet alone**, and one without cannot. That
243
+ is the one place the two kinds of client genuinely diverge, and it is why the
244
+ convention names `torrent=` first: a browser treats a source carrying only a
245
+ magnet as no candidate at all, while a native client is happy with it.
246
+ - **Holding the metainfo is not evidence that anything will serve it.** It makes
247
+ the engine ready with no peer involved, which removes the very thing that used
248
+ to prove an archive was worth binding a source to β€” waiting for metadata was
249
+ never only a wait. A client that registers on metainfo alone can bind a source
250
+ to a swarm with nothing behind it, and that does not fall back, it _stalls_, and
251
+ the tiles never draw. One `Range: bytes=0-0` against the web seed restores the
252
+ evidence: a 206 proves the host is reachable, serves ranges and permits the
253
+ origin, which is all the engine needs from it.
216
254
 
217
255
  ---
218
256
 
@@ -215,11 +215,13 @@ seeding.
215
215
 
216
216
  ## Creating torrents
217
217
 
218
- | setting | default | |
219
- | ---------------------- | ---------- | -------------------------------------------- |
220
- | `pieceLength` | `4194304` | 4 MiB |
221
- | `torrentFormat` | `'hybrid'` | `'hybrid'`, `'v1'` or `'v2'` |
222
- | `allowUnknownArchives` | `false` | publish files not recognised as map archives |
218
+ | setting | default | |
219
+ | ----------------------- | ---------- | ----------------------------------------------- |
220
+ | `pieceLength` | `4194304` | 4 MiB |
221
+ | `torrentFormat` | `'hybrid'` | `'hybrid'`, `'v1'` or `'v2'` |
222
+ | `allowUnknownArchives` | `false` | publish files not recognised as map archives |
223
+ | `md5` | `false` | also compute an MD5 of each archive created |
224
+ | `incomingRetentionDays` | `14` | how long an unfinished download stays resumable |
223
225
 
224
226
  ### `pieceLength`
225
227
 
@@ -260,6 +262,31 @@ reading over a swarm does not work the way it does for a flat, Hilbert-ordered
260
262
  file; a finished local copy has no such problem. See
261
263
  [serving-tiles.md](serving-tiles.md#what-can-be-served).
262
264
 
265
+ ### `incomingRetentionDays`
266
+
267
+ An unfinished download is kept in `.incoming` for this long, and adding the same
268
+ URL again resumes it β€” which is what makes a restart during a multi-hour
269
+ transfer cost minutes rather than the whole download. A scheduled source picks
270
+ its own back up on the next poll without being asked.
271
+
272
+ Only what nothing has written to for this many days is cleared at startup, since
273
+ whether a URL is still wanted is a question about configuration that the sweep
274
+ cannot see. Set it lower on a small disk, or higher if an upstream can be
275
+ unreachable for weeks.
276
+
277
+ ### `md5`
278
+
279
+ Off by default, because it costs a second full read of the archive: with it on,
280
+ adding a 700 GiB file takes twice as long and produces one convenience digest
281
+ that nothing in BitTorrent uses. The torrent already verifies the content, and
282
+ per piece rather than as a whole β€” this is for the manual check somebody wants to
283
+ run against a published checksum, and it is carried in the feed for them.
284
+
285
+ The console's **Also compute an MD5** box starts from this setting and is sent
286
+ with the add either way, so unticking it applies to that add alone. An API or CLI
287
+ call that omits `md5` inherits this; one that passes `true` or `false` decides
288
+ for itself.
289
+
263
290
  ## Trackers
264
291
 
265
292
  `trackers` is baked into every torrent this node creates. It defaults to the
package/docs/internals.md CHANGED
@@ -556,6 +556,35 @@ assumed:
556
556
 
557
557
  Any of those failing restarts the download rather than guessing.
558
558
 
559
+ ### Across a restart, not only across a stall
560
+
561
+ All of that worked within one process and none of it survived leaving one, which
562
+ made a restart during a long download cost the whole download. Three separate
563
+ things had to be true, and only the first was:
564
+
565
+ 1. **The bytes are kept.** They always were β€” the staging directory is named for
566
+ `sha256(url)`, so the next add of the same URL finds it.
567
+ 2. **Nothing deletes them on the way past.** Two things did. Shutdown expressed
568
+ itself through `cancelAdd()`, and cancelling deletes the partial on purpose β€”
569
+ somebody said stop. A restart is not that decision, so shutdown now calls
570
+ `stopAdds()`, which aborts with a reason the fetch can tell apart. Startup
571
+ then swept `.incoming` unconditionally, on the reasoning that a killed
572
+ process leaves a partial "nothing will ever look in again" β€” true when
573
+ staging names were random, false since they became a hash of the URL. It now
574
+ reaps only what nothing has written to for `incomingRetentionDays`.
575
+ 3. **The validator outlives the process.** `stillTheSameFile` compares the
576
+ `ETag` seen when the download began against the one offered now, and that
577
+ header lived in a local. A new process had nothing to compare, so the resume
578
+ was refused for the one reason that cannot be recovered from β€” "the server
579
+ offers no ETag or Last-Modified" β€” and the partial was deleted by the very
580
+ attempt meant to continue it. It is now written to `<partial>.resume` beside
581
+ the bytes, carrying the URL it belongs to, and removed when the download
582
+ finishes so it cannot keep the staging directory from being cleared.
583
+
584
+ The URL is recorded in the sidecar as well as implied by the directory name, so
585
+ a staging directory that has been reused for something else is refused rather
586
+ than spliced.
587
+
559
588
  ## Retiring and pruning a subscription
560
589
 
561
590
  Two different questions, which is why an RSS feed can have the first and not the
@@ -54,6 +54,31 @@ curl -X POST localhost:8090/api/torrents \
54
54
  curl -X POST localhost:8090/api/adopt
55
55
  ```
56
56
 
57
+ The first two of those answer differently, and a script should know which it is
58
+ reading. A local path and a URL both take as long as it takes to read every byte
59
+ of the archive β€” minutes for a local file, hours for a planet download, doubled
60
+ again if `md5` is on β€” so they answer **`202 Accepted`** the moment the source
61
+ has been checked, and the work carries on behind it:
62
+
63
+ ```json
64
+ {
65
+ "accepted": true,
66
+ "path": "/mnt/maps/planet.pmtiles",
67
+ "message": "hashing; progress is reported by /api/adds"
68
+ }
69
+ ```
70
+
71
+ There is no infohash in that, because there is not one yet. `GET /api/adds`
72
+ lists what is still running, and the archive appears in `GET /api/torrents` once
73
+ it finishes. A source that fails its checks β€” a path that is not there, a URL
74
+ that does not answer, a file that is not an archive β€” fails in the response
75
+ instead, since that is what somebody can do something about. An archive already
76
+ in the catalog answers `200` with the existing entry.
77
+
78
+ A magnet, a `.torrent` URL and an uploaded `.torrent` are metadata rather than
79
+ data, so there is nothing slow to wait for: those still answer `201` with the
80
+ entry.
81
+
57
82
  ### When the source URL is not published
58
83
 
59
84
  Adding from a URL registers that URL as a web seed by default, because it is by
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.33.0",
3
+ "version": "0.35.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",
@@ -46,7 +46,7 @@
46
46
  "maplibre-gl": "^6.2.0",
47
47
  "parse-torrent": "^11.0.24",
48
48
  "pmtiles": "^4.4.1",
49
- "pmtiles-torrent": "^0.6.1",
49
+ "pmtiles-torrent": "^0.7.0",
50
50
  "webtorrent": "^3.0.21"
51
51
  },
52
52
  "engines": {
package/src/api.js CHANGED
@@ -53,6 +53,55 @@ function route(handler) {
53
53
  return (req, res, next) => Promise.resolve(handler(req, res)).catch(next);
54
54
  }
55
55
 
56
+ /**
57
+ * Starts an add that takes minutes to hours, and answers as soon as it is safe
58
+ * to say it has been accepted.
59
+ *
60
+ * Everything a caller can do something about is known long before the work is
61
+ * finished: the source answers, and it is an archive of a kind this will
62
+ * publish. What remains is transfer and hashing. Awaiting all of it held the
63
+ * response open for the whole thing, so the console's add dialog stayed on
64
+ * screen for the duration β€” over an archive that was visibly appearing behind
65
+ * it in the URL case, and over a file that had never moved in the local one,
66
+ * where it read as a submit button that had done nothing at all.
67
+ *
68
+ * Progress has its own route already: `runningAdds()` feeds `/api/adds`, the
69
+ * console polls it, and `DELETE /api/adds` cancels the ones that can be.
70
+ * @param {object} res - The response to answer.
71
+ * @param {Function} start - Called with `{onValidated}`; returns the add's promise.
72
+ * @param {object} accepted - Fields describing the source, for the 202 body.
73
+ * @param {string} message - What the 202 tells the caller is now happening.
74
+ * @param {string} what - Prefixed log tag and source, for a failure nobody is waiting on.
75
+ * @returns {Promise<void>} - Resolves once the response has been sent.
76
+ */
77
+ async function acceptAdd(res, { start, accepted, message, what }) {
78
+ const validated = Promise.withResolvers();
79
+ const running = start({ onValidated: validated.resolve })
80
+ // A failure after validation has nowhere to be reported: the response has
81
+ // gone. It is logged where the rest of the work is, and swallowed here so
82
+ // it cannot take the process down as an unhandled rejection.
83
+ .catch((error) => {
84
+ validated.reject(error);
85
+ console.error(`${what}: ${error.message}`);
86
+ });
87
+
88
+ // Whichever comes first: the checks passing, or the whole attempt failing. A
89
+ // source that does not answer, or is not an archive, still reports itself in
90
+ // the dialog where somebody can correct it.
91
+ const outcome = await validated.promise;
92
+
93
+ // Already held, so there is no work to wait on and the entry itself is the
94
+ // better answer. Deliberately not extended to `joined`, where the promise
95
+ // belongs to somebody else's transfer and awaiting it would hold the
96
+ // response open for exactly as long as this exists to avoid.
97
+ if (outcome?.held) {
98
+ return void res.status(200).json(await running);
99
+ }
100
+
101
+ void running;
102
+ res.status(202).json({ accepted: true, ...accepted, message });
103
+ }
104
+
56
105
  /**
57
106
  * Builds the HTTP application: JSON API, RSS feeds and the web UI.
58
107
  * @param {object} deps - Collaborators.
@@ -1481,41 +1530,24 @@ export function createApp({
1481
1530
 
1482
1531
  let entry;
1483
1532
  if (body.path) {
1484
- entry = await library.addLocalArchive(body.path, options);
1533
+ // Nothing is copied and nothing is downloaded, but every byte is still
1534
+ // read to compute the piece hashes β€” twice with md5 on. That is the
1535
+ // whole wait for a local add, and it is no shorter for the file being
1536
+ // on this disk already. See acceptAdd().
1537
+ return await acceptAdd(res, {
1538
+ start: (hooks) =>
1539
+ library.addLocalArchive(body.path, { ...options, ...hooks }),
1540
+ accepted: { path: body.path },
1541
+ message: 'hashing; progress is reported by /api/adds',
1542
+ what: `[hash] ${body.path}`,
1543
+ });
1485
1544
  } else if (body.url) {
1486
- // Answered as soon as the URL has been checked, not when the transfer
1487
- // finishes. Awaiting the whole thing held the response open for the
1488
- // length of the download -- hours for a planet archive -- so the
1489
- // console's add dialog stayed on screen throughout, over an archive
1490
- // that was visibly appearing behind it.
1491
- //
1492
- // Progress has its own route already: runningAdds() feeds /api/adds,
1493
- // the console polls it, and DELETE /api/adds cancels one. This is the
1494
- // piece that was missing rather than a new mechanism.
1495
- const validated = Promise.withResolvers();
1496
- const running = library
1497
- .addRemoteArchive(body.url, {
1498
- ...options,
1499
- onValidated: validated.resolve,
1500
- })
1501
- // A failure after validation has nowhere to be reported: the
1502
- // response has gone. It is logged where the rest of the fetch is,
1503
- // and swallowed here so it cannot take the process down as an
1504
- // unhandled rejection.
1505
- .catch((error) => {
1506
- validated.reject(error);
1507
- console.error(`[fetch] ${body.url}: ${error.message}`);
1508
- });
1509
-
1510
- // Whichever comes first: the checks passing, or the whole attempt
1511
- // failing. A URL that does not answer, or is not an archive, still
1512
- // reports itself in the dialog where somebody can correct it.
1513
- await validated.promise;
1514
- void running;
1515
- return res.status(202).json({
1516
- accepted: true,
1517
- url: body.url,
1545
+ return await acceptAdd(res, {
1546
+ start: (hooks) =>
1547
+ library.addRemoteArchive(body.url, { ...options, ...hooks }),
1548
+ accepted: { url: body.url },
1518
1549
  message: 'fetching; progress is reported by /api/adds',
1550
+ what: `[fetch] ${body.url}`,
1519
1551
  });
1520
1552
  } else if (body.magnet) {
1521
1553
  entry = await library.addExistingTorrent(
package/src/config.js CHANGED
@@ -155,6 +155,17 @@ const DEFAULTS = {
155
155
  * publish any readable file to a public swarm.
156
156
  */
157
157
  allowUnknownArchives: false,
158
+ /**
159
+ * Also compute an MD5 of each archive created here. Costs a second full read
160
+ * of the file. Already honoured wherever a torrent is created; declared so it
161
+ * can be seen and set rather than only written into the file by hand.
162
+ */
163
+ md5: false,
164
+ /**
165
+ * How long an unfinished download is kept before startup treats it as
166
+ * abandoned. Until then, re-adding the same URL resumes it.
167
+ */
168
+ incomingRetentionDays: 14,
158
169
  /**
159
170
  * Who may administer this node. Tiles, TileJSON and the feed are always
160
171
  * public; everything under /api/ is gated whenever anything here is set. See
package/src/index.js CHANGED
@@ -237,9 +237,17 @@ PMTILES_SWARM_PUBLIC_URL
237
237
  stoppers.unshift({
238
238
  label: 'downloads in progress',
239
239
  stop: () => {
240
- const cancelled = library.cancelAdd();
241
- if (cancelled.length > 0) {
242
- console.log(`[shutdown] cancelled ${cancelled.length} download(s)`);
240
+ // stopAdds, not cancelAdd: a restart is not a decision to stop wanting
241
+ // the archive, and cancelling deletes the partial download. Through
242
+ // cancelAdd every restart threw away whatever was in flight, and the
243
+ // scheduled source that asked for it began again from zero on the next
244
+ // poll β€” which for a planet build is hours of transfer per restart.
245
+ const stopped = library.stopAdds();
246
+ if (stopped.length > 0) {
247
+ console.log(
248
+ `[shutdown] stopped ${stopped.length} download(s); their bytes are ` +
249
+ 'kept and resume when the source is next polled',
250
+ );
243
251
  }
244
252
  },
245
253
  ms: 1000,