pmtiles-swarm 0.70.0 → 0.72.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 +66 -0
- package/package.json +1 -1
- package/src/bake-jobs.js +115 -0
- package/src/bake.js +27 -1
- package/src/config.js +13 -0
- package/src/crash-guard.js +94 -0
- package/src/index.js +42 -1
- package/src/web/index.html +36 -8
- package/src/web/preview.html +13 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,72 @@
|
|
|
7
7
|
### 🐞 Bug fixes
|
|
8
8
|
- _...Add new stuff here..._
|
|
9
9
|
|
|
10
|
+
## 0.72.0
|
|
11
|
+
### ✨ Features and improvements
|
|
12
|
+
- **An unfinished export is picked up when the node starts.** The checkpoint was always there;
|
|
13
|
+
finding it again meant somebody remembering to press the button. That is fine for an export
|
|
14
|
+
stopped on purpose and wrong for one a crash took, which is the case that costs the most and
|
|
15
|
+
gives the least warning.
|
|
16
|
+
|
|
17
|
+
The checkpoint now records what the job was - what the archive is called, what the file is
|
|
18
|
+
called, where it was going, what it is filed under - because none of that can be worked out from
|
|
19
|
+
the tiles on disk, and a resumed export has to be the one somebody asked for rather than a new
|
|
20
|
+
one with today's date on it.
|
|
21
|
+
|
|
22
|
+
Only where the recipe still resolves to what it did. `bakeRevision` covers what each source
|
|
23
|
+
became, so a rebuilt source means the checkpoint holds half of a map that no longer exists;
|
|
24
|
+
that is left alone and reported, to be discarded when somebody exports again deliberately.
|
|
25
|
+
`stacks.resumeExports` turns it off for a node where hours of merging should never begin
|
|
26
|
+
without being asked for.
|
|
27
|
+
|
|
28
|
+
### 🐞 Bug fixes
|
|
29
|
+
- **The node was exiting when a peer wire outlived its torrent.** Twice in one evening on a real
|
|
30
|
+
node, both times killing an export that was hours in:
|
|
31
|
+
|
|
32
|
+
torrent.js:2092 this.client._debugId -> reading '_debugId' of null
|
|
33
|
+
peer.js:201 this.swarm.client.dht -> reading 'dht' of null
|
|
34
|
+
|
|
35
|
+
WebTorrent nulls `torrent.client` when a torrent is destroyed and does not always tear down that
|
|
36
|
+
torrent's peer wires with it. One fired a keep-alive timeout afterwards and the other finished a
|
|
37
|
+
handshake, and both reached through the dead reference. They happen inside a timer or a socket
|
|
38
|
+
callback, so there is no promise to reject and no call of ours to wrap - it is an uncaught
|
|
39
|
+
exception, and Node's answer to that is to exit. Under `Restart=always` that reads as a
|
|
40
|
+
mysterious restart rather than as a crash.
|
|
41
|
+
|
|
42
|
+
`src/crash-guard.js` survives exactly that shape and nothing else: a TypeError, about one of a
|
|
43
|
+
named list of properties, raised from a frame inside the torrent libraries. Every other uncaught
|
|
44
|
+
exception still stops the process, because one that survives everything is one that lies about
|
|
45
|
+
its own state. The two stacks from the journal are in the tests verbatim, alongside the near
|
|
46
|
+
misses that must still be fatal - the same error from our own code, a different error from
|
|
47
|
+
theirs.
|
|
48
|
+
|
|
49
|
+
## 0.71.0
|
|
50
|
+
### ✨ Features and improvements
|
|
51
|
+
- **The preview comes back to where it was opened from.** The link out of a preview said
|
|
52
|
+
"console" and went to the archive list, whatever had been previewed - so previewing a stack and
|
|
53
|
+
coming back meant finding the Stacks tab again. The console now names its view in the address
|
|
54
|
+
and honours one it is given, and a preview links to the view it belongs to.
|
|
55
|
+
|
|
56
|
+
- **A category offers terrain where its newest build is terrain.** Archives and stacks got the
|
|
57
|
+
second button; categories did not, on either page. Same rule and the same reason: the raster
|
|
58
|
+
keeps the plain name because it is the view that shows a hole.
|
|
59
|
+
|
|
60
|
+
### 🐞 Bug fixes
|
|
61
|
+
- **A stopped export resumed; a restarted service did not.** Both were called "stopping keeps the
|
|
62
|
+
work", and only one of them did. **Stop export** cancels the job, which writes a checkpoint. A
|
|
63
|
+
service restart tells it nothing at all: the process is torn down, and whatever had been merged
|
|
64
|
+
since the last checkpoint was merged again.
|
|
65
|
+
|
|
66
|
+
Two things were wrong. Nothing asked a running export to stop when the node did, so exports are
|
|
67
|
+
now a shutdown step of their own - cancelled early in the sequence, before the pieces they read
|
|
68
|
+
through are taken away, and waited for so each writes its checkpoint.
|
|
69
|
+
|
|
70
|
+
And a checkpoint fired on a tile count alone, every five thousand. That is the wrong measure for
|
|
71
|
+
a bake merging slowly, which can run for an hour without reaching it - an export that had done a
|
|
72
|
+
few hundred tiles had never checkpointed at all, so a restart lost everything it had done. There
|
|
73
|
+
is a clock now as well, thirty seconds, whichever comes first. A checkpoint costs about eleven
|
|
74
|
+
milliseconds.
|
|
75
|
+
|
|
10
76
|
## 0.70.0
|
|
11
77
|
### ✨ Features and improvements
|
|
12
78
|
- **An export works on the disk the archive is going to.** It worked under the data directory and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmtiles-swarm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.72.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/bake-jobs.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
1
2
|
import path from 'node:path';
|
|
2
3
|
import {
|
|
3
4
|
assertBakeable,
|
|
@@ -108,6 +109,109 @@ export class BakeManager {
|
|
|
108
109
|
return true;
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Picks up exports a previous run did not finish.
|
|
114
|
+
*
|
|
115
|
+
* A checkpoint is the hours already spent, and finding one again used to
|
|
116
|
+
* mean somebody remembering to press the button. That is fine for an export
|
|
117
|
+
* stopped on purpose and wrong for one a crash took -- which is the case
|
|
118
|
+
* that costs the most and gives the least warning.
|
|
119
|
+
*
|
|
120
|
+
* Only where the recipe still resolves to what it did. `bakeRevision` covers
|
|
121
|
+
* what each source became, so a rebuilt source means the checkpoint holds
|
|
122
|
+
* half of a map that no longer exists; that is left alone rather than
|
|
123
|
+
* continued, and discarded when somebody exports again deliberately.
|
|
124
|
+
* @param {Function} resolve - `(stackId) => resolved stack | null`.
|
|
125
|
+
* @returns {Promise<object[]>} - The jobs started.
|
|
126
|
+
*/
|
|
127
|
+
async resumeAll(resolve) {
|
|
128
|
+
const started = [];
|
|
129
|
+
|
|
130
|
+
for (const root of this.#workRoots()) {
|
|
131
|
+
const directory = path.join(root, WORK_DIR);
|
|
132
|
+
const found = await fs.readdir(directory).catch(() => []);
|
|
133
|
+
|
|
134
|
+
for (const stackId of found) {
|
|
135
|
+
if (this.#jobs.has(stackId)) continue;
|
|
136
|
+
const state = await fs
|
|
137
|
+
.readFile(path.join(directory, stackId, 'bake-state.json'), 'utf8')
|
|
138
|
+
.then((raw) => JSON.parse(raw))
|
|
139
|
+
.catch(() => null);
|
|
140
|
+
// Written by a version that did not record what the job was. There is
|
|
141
|
+
// nothing to reproduce it from, so it waits for a person.
|
|
142
|
+
if (!state?.describe) continue;
|
|
143
|
+
|
|
144
|
+
const resolved = resolve(stackId);
|
|
145
|
+
if (!resolved || bakeRevision(resolved) !== state.revision) continue;
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const job = await this.start({ resolved, ...state.describe });
|
|
149
|
+
started.push(job);
|
|
150
|
+
console.log(
|
|
151
|
+
`[bake] picking up ${stackId} where it stopped: ` +
|
|
152
|
+
`${state.written ?? 0} tiles already merged`,
|
|
153
|
+
);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
// A stack that cannot be baked now -- no codec, sources gone -- is
|
|
156
|
+
// said out loud and left. Its checkpoint is still there.
|
|
157
|
+
console.warn(`[bake] could not resume ${stackId}: ${error.message}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return started;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Everywhere an export might have left work.
|
|
167
|
+
*
|
|
168
|
+
* The working directory follows the destination, and a destination is
|
|
169
|
+
* whatever was chosen at the time -- so this is every place one could have
|
|
170
|
+
* been: the named locations, the default save path, and the data directory,
|
|
171
|
+
* where exports worked before the working directory moved.
|
|
172
|
+
* @returns {string[]} - Roots to look under, without repeats.
|
|
173
|
+
*/
|
|
174
|
+
#workRoots() {
|
|
175
|
+
const roots = [
|
|
176
|
+
...(this.#config.locations ?? []).map((one) => one?.path),
|
|
177
|
+
this.#config.savePath,
|
|
178
|
+
this.#config.dataDir,
|
|
179
|
+
].filter(Boolean);
|
|
180
|
+
return [...new Set(roots.map((one) => path.resolve(one)))];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Stops every running bake and waits for each to write its checkpoint.
|
|
185
|
+
*
|
|
186
|
+
* For a service stopping. Without this a restart kills a bake where it
|
|
187
|
+
* stands: the merging half is cancellable and keeps its work, but only if
|
|
188
|
+
* something tells it to stop, and a process being torn down does not. What
|
|
189
|
+
* was merged since the last checkpoint would be merged again.
|
|
190
|
+
* @param {object} [options] - `timeoutMs` to stop waiting.
|
|
191
|
+
* @returns {Promise<number>} - How many were stopped.
|
|
192
|
+
*/
|
|
193
|
+
async stopAll(options = {}) {
|
|
194
|
+
const running = [...this.#jobs.values()].filter((job) => !job.finishedAt);
|
|
195
|
+
if (running.length === 0) return 0;
|
|
196
|
+
|
|
197
|
+
for (const job of running) {
|
|
198
|
+
job.cancelling = true;
|
|
199
|
+
job.controller.abort();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Bounded, because a shutdown that waits for ever is a shutdown that gets
|
|
203
|
+
// killed harder. A checkpoint is milliseconds; what takes time is the tile
|
|
204
|
+
// in flight noticing it has been abandoned.
|
|
205
|
+
const timeout = new Promise((resolve) =>
|
|
206
|
+
setTimeout(resolve, options.timeoutMs ?? 10000).unref?.(),
|
|
207
|
+
);
|
|
208
|
+
await Promise.race([
|
|
209
|
+
Promise.all(running.map((job) => job.promise?.catch(() => {}))),
|
|
210
|
+
timeout,
|
|
211
|
+
]);
|
|
212
|
+
return running.length;
|
|
213
|
+
}
|
|
214
|
+
|
|
111
215
|
/**
|
|
112
216
|
* Starts a bake, and answers as soon as it is running rather than when it
|
|
113
217
|
* finishes.
|
|
@@ -309,6 +413,17 @@ export class BakeManager {
|
|
|
309
413
|
header: { format },
|
|
310
414
|
pauseMs: this.#config.stacks?.bakePauseMs ?? 0,
|
|
311
415
|
concurrency: this.#concurrency(),
|
|
416
|
+
// Written into the checkpoint in the shape `start` takes, so picking one
|
|
417
|
+
// up is handing it back rather than reconstructing it. Nothing here can
|
|
418
|
+
// be worked out from the tiles on disk: what the archive is called, what
|
|
419
|
+
// the file is called, where it was going, what it is filed under.
|
|
420
|
+
describe: {
|
|
421
|
+
name: job.archiveName,
|
|
422
|
+
filename: job.name,
|
|
423
|
+
publishDir: job.publishDir ?? null,
|
|
424
|
+
description: options.description ?? null,
|
|
425
|
+
categories: options.categories ?? null,
|
|
426
|
+
},
|
|
312
427
|
metadata: {
|
|
313
428
|
name: job.archiveName,
|
|
314
429
|
// Only what was asked for. Falling back to the recipe's own
|
package/src/bake.js
CHANGED
|
@@ -25,6 +25,17 @@ import { Compression, PMTilesWriter, TileType } from './pmtiles-write.js';
|
|
|
25
25
|
/** Tiles merged between checkpoints. */
|
|
26
26
|
const DEFAULT_CHECKPOINT_EVERY = 5000;
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Seconds between checkpoints, however few tiles have been done.
|
|
30
|
+
*
|
|
31
|
+
* A count alone is the wrong measure. A bake merging slowly -- a big tile, a
|
|
32
|
+
* cache-mode source, a stack with several layers -- can run for an hour
|
|
33
|
+
* without reaching five thousand, and a process killed before its first
|
|
34
|
+
* checkpoint has nothing to resume from at all. A checkpoint costs about
|
|
35
|
+
* eleven milliseconds, so doing one on the clock is close to free.
|
|
36
|
+
*/
|
|
37
|
+
const DEFAULT_CHECKPOINT_SECONDS = 30;
|
|
38
|
+
|
|
28
39
|
/** Tiles merged at once. */
|
|
29
40
|
const DEFAULT_CONCURRENCY = 4;
|
|
30
41
|
|
|
@@ -454,8 +465,14 @@ export async function bakeStack(options) {
|
|
|
454
465
|
header = {},
|
|
455
466
|
deduplicate = true,
|
|
456
467
|
checkpointEvery = DEFAULT_CHECKPOINT_EVERY,
|
|
468
|
+
checkpointSeconds = DEFAULT_CHECKPOINT_SECONDS,
|
|
457
469
|
pauseMs = 0,
|
|
458
470
|
concurrency = DEFAULT_CONCURRENCY,
|
|
471
|
+
// Written into the checkpoint and handed back by `readCheckpoint`. A
|
|
472
|
+
// resumed export has to reproduce the one somebody asked for -- its name,
|
|
473
|
+
// where it was going, what it was filed under -- and none of that can be
|
|
474
|
+
// worked out from the tiles on disk.
|
|
475
|
+
describe,
|
|
459
476
|
} = options;
|
|
460
477
|
const batchSize = Math.max(1, Math.floor(concurrency));
|
|
461
478
|
|
|
@@ -486,6 +503,7 @@ export async function bakeStack(options) {
|
|
|
486
503
|
let skipped = found?.skipped ?? 0;
|
|
487
504
|
let lastTileId = found?.lastTileId ?? -1;
|
|
488
505
|
let sinceCheckpoint = 0;
|
|
506
|
+
let checkpointedAt = Date.now();
|
|
489
507
|
let persisted = found?.entries.length ?? 0;
|
|
490
508
|
|
|
491
509
|
/**
|
|
@@ -503,11 +521,13 @@ export async function bakeStack(options) {
|
|
|
503
521
|
dataBytes: writer.dataBytes,
|
|
504
522
|
addressed: writer.addressedTiles,
|
|
505
523
|
clustered: writer.clustered,
|
|
524
|
+
...(describe ? { describe } : {}),
|
|
506
525
|
},
|
|
507
526
|
writer.entries,
|
|
508
527
|
persisted,
|
|
509
528
|
);
|
|
510
529
|
sinceCheckpoint = 0;
|
|
530
|
+
checkpointedAt = Date.now();
|
|
511
531
|
};
|
|
512
532
|
|
|
513
533
|
try {
|
|
@@ -563,7 +583,13 @@ export async function bakeStack(options) {
|
|
|
563
583
|
}
|
|
564
584
|
|
|
565
585
|
batch = [];
|
|
566
|
-
|
|
586
|
+
// Whichever comes first. The count keeps a fast bake from checkpointing
|
|
587
|
+
// constantly; the clock keeps a slow one from never checkpointing at
|
|
588
|
+
// all.
|
|
589
|
+
const due =
|
|
590
|
+
sinceCheckpoint >= checkpointEvery ||
|
|
591
|
+
Date.now() - checkpointedAt >= checkpointSeconds * 1000;
|
|
592
|
+
if (due) await checkpoint();
|
|
567
593
|
signal?.throwIfAborted();
|
|
568
594
|
|
|
569
595
|
// Handing time back, where the operator asked for that. A bake on a node
|
package/src/config.js
CHANGED
|
@@ -388,6 +388,19 @@ const DEFAULTS = {
|
|
|
388
388
|
* not clustered is bad at the one thing PMTiles is for.
|
|
389
389
|
*/
|
|
390
390
|
bakeConcurrency: 4,
|
|
391
|
+
/**
|
|
392
|
+
* Whether an unfinished export is picked up when the node starts.
|
|
393
|
+
*
|
|
394
|
+
* A checkpoint is the hours already spent, and the case that costs most is
|
|
395
|
+
* the one nobody chose: a crash, or a restart in the middle of a run. On
|
|
396
|
+
* by default for that reason. Off for a node where hours of merging should
|
|
397
|
+
* never begin without somebody asking for it.
|
|
398
|
+
*
|
|
399
|
+
* Only where the recipe still resolves to what it did. A rebuilt source
|
|
400
|
+
* means the checkpoint holds half of a map that no longer exists, and that
|
|
401
|
+
* is left alone either way.
|
|
402
|
+
*/
|
|
403
|
+
resumeExports: true,
|
|
391
404
|
},
|
|
392
405
|
/**
|
|
393
406
|
* Folders scanned for new archives. Each entry is `{ path, categories,
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surviving the one crash a torrent client raises from a timer.
|
|
3
|
+
*
|
|
4
|
+
* WebTorrent nulls `torrent.client` when a torrent is destroyed, and does not
|
|
5
|
+
* always tear down that torrent's peer wires with it. A wire that outlives its
|
|
6
|
+
* torrent then fires — a keep-alive timeout, or a handshake finishing — and
|
|
7
|
+
* reaches through the dead reference:
|
|
8
|
+
*
|
|
9
|
+
* torrent.js:2092 this.client._debugId → reading '_debugId' of null
|
|
10
|
+
* peer.js:201 this.swarm.client.dht → reading 'dht' of null
|
|
11
|
+
*
|
|
12
|
+
* Both happen inside a `setTimeout` or a socket callback, so there is no
|
|
13
|
+
* promise to reject and no call of ours to wrap: it is an uncaught exception,
|
|
14
|
+
* and Node's answer to that is to exit. Under `Restart=always` the service
|
|
15
|
+
* comes straight back, which is why this reads as a mysterious restart rather
|
|
16
|
+
* than as a crash — and anything the node was in the middle of, an export
|
|
17
|
+
* above all, is simply gone.
|
|
18
|
+
*
|
|
19
|
+
* So this is deliberately narrow. It survives exactly the shape above and
|
|
20
|
+
* nothing else: a TypeError, about a null property this list names, raised
|
|
21
|
+
* from inside the torrent libraries. Every other uncaught exception keeps
|
|
22
|
+
* Node's behaviour, because a process that survives everything is a process
|
|
23
|
+
* that lies about its own state.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Packages whose internals may raise this. Ours is not among them. */
|
|
27
|
+
const LIBRARIES = [
|
|
28
|
+
'/webtorrent/',
|
|
29
|
+
'/bittorrent-protocol/',
|
|
30
|
+
'/torrent-discovery/',
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/** The properties a wire reaches for on a client that has gone. */
|
|
34
|
+
const REACHING_FOR = ['_debugId', 'dht', 'client', 'swarm', 'torrent', 'wires'];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether an uncaught exception is a peer wire touching a destroyed torrent.
|
|
38
|
+
* @param {unknown} error - What was thrown.
|
|
39
|
+
* @returns {boolean} - True when it is safe to carry on.
|
|
40
|
+
*/
|
|
41
|
+
export function isDeadTorrentWire(error) {
|
|
42
|
+
if (!(error instanceof TypeError)) return false;
|
|
43
|
+
|
|
44
|
+
const message = String(error.message ?? '');
|
|
45
|
+
const reading =
|
|
46
|
+
/Cannot read propert(?:y|ies) of (?:null|undefined) \(reading '([^']+)'\)/.exec(
|
|
47
|
+
message,
|
|
48
|
+
);
|
|
49
|
+
// Older phrasings say it the other way round.
|
|
50
|
+
const older = /Cannot read property '([^']+)' of (?:null|undefined)/.exec(
|
|
51
|
+
message,
|
|
52
|
+
);
|
|
53
|
+
const property = reading?.[1] ?? older?.[1];
|
|
54
|
+
if (!property || !REACHING_FOR.includes(property)) return false;
|
|
55
|
+
|
|
56
|
+
// The frame that threw has to be theirs. An error of ours that happens to
|
|
57
|
+
// mention the same property is a bug worth dying on.
|
|
58
|
+
const stack = String(error.stack ?? '').replaceAll('\\', '/');
|
|
59
|
+
return LIBRARIES.some((library) => stack.includes(library));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Installs the guard.
|
|
64
|
+
*
|
|
65
|
+
* Returns what it registered so a test can take it off again, and so nothing
|
|
66
|
+
* has to reach into `process` to find out what is installed.
|
|
67
|
+
* @param {object} [options] - `onSurvived`, and `exit` for testing.
|
|
68
|
+
* @returns {Function} - The handler, for `process.off`.
|
|
69
|
+
*/
|
|
70
|
+
export function installCrashGuard(options = {}) {
|
|
71
|
+
const survived =
|
|
72
|
+
options.onSurvived ??
|
|
73
|
+
((error) => {
|
|
74
|
+
console.warn(
|
|
75
|
+
`[torrent] a peer wire outlived its torrent and threw: ${error.message}. ` +
|
|
76
|
+
'Carried on — this is a fault inside webtorrent, not a state this ' +
|
|
77
|
+
'node cannot continue from.',
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
const exit = options.exit ?? ((code) => process.exit(code));
|
|
81
|
+
|
|
82
|
+
const handler = (error) => {
|
|
83
|
+
if (isDeadTorrentWire(error)) {
|
|
84
|
+
survived(error);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
// Node's own behaviour for everything else: say what happened, and stop.
|
|
88
|
+
console.error(error);
|
|
89
|
+
exit(1);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
process.on('uncaughtException', handler);
|
|
93
|
+
return handler;
|
|
94
|
+
}
|
package/src/index.js
CHANGED
|
@@ -5,8 +5,9 @@ import { parseArgs } from 'node:util';
|
|
|
5
5
|
import { createApp } from './api.js';
|
|
6
6
|
import { assertSafeToListen, createAuth } from './auth.js';
|
|
7
7
|
import { Catalog } from './catalog.js';
|
|
8
|
-
import { StackStore } from './stacks.js';
|
|
8
|
+
import { StackStore, resolveStack } from './stacks.js';
|
|
9
9
|
import { StackCache } from './stack-cache.js';
|
|
10
|
+
import { installCrashGuard } from './crash-guard.js';
|
|
10
11
|
import { CutlineStore } from './cutlines.js';
|
|
11
12
|
import { BakeManager } from './bake-jobs.js';
|
|
12
13
|
import { loadCodec } from './codec.js';
|
|
@@ -212,6 +213,11 @@ PMTILES_SWARM_PUBLIC_URL
|
|
|
212
213
|
/** @type {Array<{label: string, stop: () => unknown, ms?: number}>} */
|
|
213
214
|
const stoppers = [];
|
|
214
215
|
installSignalHandlers(stoppers);
|
|
216
|
+
// Before anything opens a socket. A peer wire that outlives its torrent
|
|
217
|
+
// throws from a timer, which is an uncaught exception, which is an exit --
|
|
218
|
+
// and under Restart=always that reads as a mysterious restart rather than a
|
|
219
|
+
// crash, taking whatever was running with it.
|
|
220
|
+
installCrashGuard();
|
|
215
221
|
|
|
216
222
|
// Before anything is created or any port is bound: an unauthenticated node
|
|
217
223
|
// on a reachable address fails silently, working perfectly right up until
|
|
@@ -474,6 +480,41 @@ PMTILES_SWARM_PUBLIC_URL
|
|
|
474
480
|
cutlines,
|
|
475
481
|
});
|
|
476
482
|
|
|
483
|
+
// Anything a previous run did not finish. Deliberately after the stacks and
|
|
484
|
+
// the catalog are loaded, since a checkpoint is only worth picking up where
|
|
485
|
+
// the recipe still resolves to what it did when the work was done.
|
|
486
|
+
//
|
|
487
|
+
// Not awaited: an export is hours, and the node should be answering requests
|
|
488
|
+
// while it runs rather than after it.
|
|
489
|
+
if (config.stacks?.resumeExports !== false) {
|
|
490
|
+
bakes
|
|
491
|
+
.resumeAll((stackId) => {
|
|
492
|
+
const stack = stacks?.list().find((one) => one.id === stackId);
|
|
493
|
+
if (!stack) return null;
|
|
494
|
+
return resolveStack(stack, {
|
|
495
|
+
archive: (hash) => catalog.get(hash),
|
|
496
|
+
category: (name) => catalog.byCategory(name)[0] ?? null,
|
|
497
|
+
});
|
|
498
|
+
})
|
|
499
|
+
.catch((error) => console.warn(`[bake] resume failed: ${error.message}`));
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Early in the sequence, so an export is told to stop before the pieces it
|
|
503
|
+
// reads through are taken away. Its checkpoint is the hours already spent.
|
|
504
|
+
stoppers.unshift({
|
|
505
|
+
label: 'stack exports',
|
|
506
|
+
stop: async () => {
|
|
507
|
+
const stopped = await bakes.stopAll();
|
|
508
|
+
if (stopped > 0) {
|
|
509
|
+
console.log(
|
|
510
|
+
`[shutdown] stopped ${stopped} stack export(s); each kept its ` +
|
|
511
|
+
'checkpoint, so exporting again carries on from there',
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
},
|
|
515
|
+
ms: 12000,
|
|
516
|
+
});
|
|
517
|
+
|
|
477
518
|
// Reads the head of anything joined but not yet understood — the header,
|
|
478
519
|
// then the root directory and metadata it points at. Without this an archive
|
|
479
520
|
// being mirrored is unservable until the download happens to reach byte
|
package/src/web/index.html
CHANGED
|
@@ -4019,7 +4019,12 @@ Every piece is hashed against the ` +
|
|
|
4019
4019
|
? `<div class="links">
|
|
4020
4020
|
${copyable(ends.tileJson, 'TileJSON')}
|
|
4021
4021
|
${copyable(ends.xyz, 'XYZ')}
|
|
4022
|
-
${
|
|
4022
|
+
${
|
|
4023
|
+
drawsAsTerrain(newest)
|
|
4024
|
+
? `${link(`${ends.preview}?raw=1`, 'preview')}
|
|
4025
|
+
${link(ends.preview, 'terrain')}`
|
|
4026
|
+
: link(ends.preview, 'preview')
|
|
4027
|
+
}
|
|
4023
4028
|
${link(ends.torrent, '.torrent', true)}
|
|
4024
4029
|
${copyable(ends.magnet, 'magnet', true)}
|
|
4025
4030
|
${copyable(ends.feed, 'RSS')}
|
|
@@ -5223,6 +5228,16 @@ Every piece is hashed against the ` +
|
|
|
5223
5228
|
'restores the old behaviour. They are written in order ' +
|
|
5224
5229
|
'whatever it is set to.',
|
|
5225
5230
|
},
|
|
5231
|
+
{
|
|
5232
|
+
key: 'stacks.resumeExports',
|
|
5233
|
+
label: 'Pick up an unfinished export when the node starts',
|
|
5234
|
+
type: 'boolean',
|
|
5235
|
+
restart: true,
|
|
5236
|
+
help:
|
|
5237
|
+
'A checkpoint is the hours already spent, and the case that ' +
|
|
5238
|
+
'costs most is the one nobody chose - a crash, or a restart ' +
|
|
5239
|
+
'mid-run. Only where the recipe still resolves to what it did.',
|
|
5240
|
+
},
|
|
5226
5241
|
{
|
|
5227
5242
|
key: 'stacks.bakePauseMs',
|
|
5228
5243
|
label: 'Pause between batches while exporting',
|
|
@@ -6995,17 +7010,19 @@ Every piece is hashed against the ` +
|
|
|
6995
7010
|
}
|
|
6996
7011
|
|
|
6997
7012
|
// ── Tabs ──────────────────────────────────────────────────────────────
|
|
7013
|
+
/** Views a URL may name, so a link can come back to the right one. */
|
|
7014
|
+
const TABS = ['archives', 'categories', 'stacks', 'traffic', 'settings'];
|
|
7015
|
+
|
|
6998
7016
|
const showTab = (name) => {
|
|
6999
|
-
for (const tab of
|
|
7000
|
-
'archives',
|
|
7001
|
-
'categories',
|
|
7002
|
-
'stacks',
|
|
7003
|
-
'traffic',
|
|
7004
|
-
'settings',
|
|
7005
|
-
]) {
|
|
7017
|
+
for (const tab of TABS) {
|
|
7006
7018
|
$(`view-${tab}`).hidden = tab !== name;
|
|
7007
7019
|
$(`tab-${tab}`).classList.toggle('on', tab === name);
|
|
7008
7020
|
}
|
|
7021
|
+
// Written into the address, so a page that sends somebody here can say
|
|
7022
|
+
// where to land -- a preview of a stack belongs back at Stacks, not at
|
|
7023
|
+
// whatever the console opens on. `replaceState` rather than assigning
|
|
7024
|
+
// the hash, which would scroll and add a history entry per tab.
|
|
7025
|
+
history.replaceState(null, '', name === 'archives' ? '#' : `#${name}`);
|
|
7009
7026
|
if (name === 'settings') loadSettings().catch((e) => toast(e.message));
|
|
7010
7027
|
if (name === 'categories') loadCategories().catch((e) => toast(e.message));
|
|
7011
7028
|
if (name === 'stacks') loadStacks().catch((e) => toast(e.message));
|
|
@@ -7014,6 +7031,13 @@ Every piece is hashed against the ` +
|
|
|
7014
7031
|
loadSwarmTraffic();
|
|
7015
7032
|
}
|
|
7016
7033
|
};
|
|
7034
|
+
// A view named in the address wins over the one this opens on. Ignored
|
|
7035
|
+
// when it names nothing, so an old bookmark is not a blank console.
|
|
7036
|
+
const tabFromUrl = () => {
|
|
7037
|
+
const named = location.hash.replace('#', '');
|
|
7038
|
+
return TABS.includes(named) ? named : null;
|
|
7039
|
+
};
|
|
7040
|
+
|
|
7017
7041
|
// The footer's year, set from the clock rather than typed into a file
|
|
7018
7042
|
// nobody will remember to edit. The version beside it arrives with the
|
|
7019
7043
|
// first status, and reads "…" until then.
|
|
@@ -8312,6 +8336,10 @@ Every piece is hashed against the ` +
|
|
|
8312
8336
|
// An unreachable server is reported by refresh() in the status line.
|
|
8313
8337
|
}
|
|
8314
8338
|
refresh();
|
|
8339
|
+
// After the archives have been asked for, so the tab that opens is the
|
|
8340
|
+
// one asked for and not the one this happens to start on.
|
|
8341
|
+
const named = tabFromUrl();
|
|
8342
|
+
if (named && named !== 'archives') showTab(named);
|
|
8315
8343
|
}
|
|
8316
8344
|
|
|
8317
8345
|
start();
|
package/src/web/preview.html
CHANGED
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
<code id="endpoint"></code>
|
|
70
70
|
<span class="links">
|
|
71
71
|
<a id="mode" href="#" hidden></a>
|
|
72
|
-
<a href="/">← console</a>
|
|
72
|
+
<a id="back" href="/">← console</a>
|
|
73
73
|
</span>
|
|
74
74
|
</header>
|
|
75
75
|
<div id="note" hidden></div>
|
|
@@ -96,6 +96,18 @@
|
|
|
96
96
|
);
|
|
97
97
|
const $ = (id) => document.getElementById(id);
|
|
98
98
|
|
|
99
|
+
// Back to the view this was opened from rather than to whatever the
|
|
100
|
+
// console opens on. The path already says which: a stack was reached
|
|
101
|
+
// from Stacks, a category from Categories, an archive from the list.
|
|
102
|
+
const back = $('back');
|
|
103
|
+
if (back) {
|
|
104
|
+
back.href = location.pathname.startsWith('/stacks/')
|
|
105
|
+
? '/#stacks'
|
|
106
|
+
: location.pathname.startsWith('/latest/')
|
|
107
|
+
? '/#categories'
|
|
108
|
+
: '/';
|
|
109
|
+
}
|
|
110
|
+
|
|
99
111
|
const fail = (message) => {
|
|
100
112
|
document.body.innerHTML = `<div class="banner">${message}</div>`;
|
|
101
113
|
};
|