@signalk/charts-plugin 3.7.0 → 3.8.1

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.
Files changed (60) hide show
  1. package/.prettierignore +1 -0
  2. package/CHANGELOG.md +109 -0
  3. package/README.md +72 -0
  4. package/docs/screenshots/1_overview.png +0 -0
  5. package/docs/screenshots/2_region.png +0 -0
  6. package/docs/screenshots/3_jobs.png +0 -0
  7. package/package.json +16 -11
  8. package/plugin/cacheLayout.d.ts +72 -0
  9. package/plugin/cacheLayout.d.ts.map +1 -0
  10. package/plugin/cacheLayout.js +243 -0
  11. package/plugin/cacheLayout.js.map +1 -0
  12. package/plugin/chartDownloader.d.ts +122 -29
  13. package/plugin/chartDownloader.d.ts.map +1 -1
  14. package/plugin/chartDownloader.js +491 -314
  15. package/plugin/chartDownloader.js.map +1 -1
  16. package/plugin/chartDownloaderMBTilesHelpers.d.ts +10 -0
  17. package/plugin/chartDownloaderMBTilesHelpers.d.ts.map +1 -0
  18. package/plugin/chartDownloaderMBTilesHelpers.js +202 -0
  19. package/plugin/chartDownloaderMBTilesHelpers.js.map +1 -0
  20. package/plugin/chartDownloaderTileHelpers.d.ts +30 -0
  21. package/plugin/chartDownloaderTileHelpers.d.ts.map +1 -0
  22. package/plugin/chartDownloaderTileHelpers.js +277 -0
  23. package/plugin/chartDownloaderTileHelpers.js.map +1 -0
  24. package/plugin/charts.d.ts.map +1 -1
  25. package/plugin/charts.js +24 -39
  26. package/plugin/charts.js.map +1 -1
  27. package/plugin/constants.d.ts.map +1 -1
  28. package/plugin/index.d.ts.map +1 -1
  29. package/plugin/index.js +564 -47
  30. package/plugin/index.js.map +1 -1
  31. package/plugin/pluginStatus.js.map +1 -1
  32. package/plugin/projection.js.map +1 -1
  33. package/plugin/tileServer.d.ts +4 -2
  34. package/plugin/tileServer.d.ts.map +1 -1
  35. package/plugin/tileServer.js +37 -8
  36. package/plugin/tileServer.js.map +1 -1
  37. package/plugin/tokenProvider.d.ts +63 -0
  38. package/plugin/tokenProvider.d.ts.map +1 -0
  39. package/plugin/tokenProvider.js +258 -0
  40. package/plugin/tokenProvider.js.map +1 -0
  41. package/plugin/types.d.ts +37 -2
  42. package/plugin/types.d.ts.map +1 -1
  43. package/public/images/jobs.png +0 -0
  44. package/public/images/overview.png +0 -0
  45. package/public/images/region.png +0 -0
  46. package/public/index.html +392 -113
  47. package/public/js/index.js +620 -0
  48. package/public/leaflet/images/downloads.png +0 -0
  49. package/public/leaflet/images/layers-2x.png +0 -0
  50. package/public/leaflet/images/layers.png +0 -0
  51. package/public/leaflet/images/lifebuoy.png +0 -0
  52. package/public/leaflet/images/spritesheet-2x.png +0 -0
  53. package/public/leaflet/images/spritesheet.svg +156 -0
  54. package/public/leaflet/leaflet.css +677 -0
  55. package/public/leaflet/leaflet.draw.css +253 -0
  56. package/public/leaflet/leaflet.draw.js +2957 -0
  57. package/public/leaflet/leaflet.js +7467 -0
  58. package/public/style.css +351 -37
  59. package/docs/screenshots/1_placeholder.png +0 -0
  60. package/public/index.js +0 -349
@@ -3,38 +3,97 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.ChartDownloader = exports.ChartSeedingManager = exports.Status = void 0;
7
- const fs_1 = __importDefault(require("fs"));
6
+ exports.ChartDownloader = exports.ChartSeedingManager = exports.FailureKind = exports.TileSource = exports.State = void 0;
8
7
  const path_1 = __importDefault(require("path"));
9
- const p_limit_1 = __importDefault(require("p-limit"));
10
- const geojson_antimeridian_cut_1 = __importDefault(require("geojson-antimeridian-cut"));
11
- const boolean_intersects_1 = __importDefault(require("@turf/boolean-intersects"));
12
- const bbox_1 = require("@turf/bbox");
13
- const helpers_1 = require("@turf/helpers");
14
- const check_disk_space_1 = __importDefault(require("check-disk-space"));
15
8
  const projection_1 = require("./projection");
16
- const tileServer_1 = require("./tileServer");
17
- var Status;
18
- (function (Status) {
19
- Status[Status["Stopped"] = 0] = "Stopped";
20
- Status[Status["Running"] = 1] = "Running";
21
- })(Status || (exports.Status = Status = {}));
9
+ const cacheLayout_1 = require("./cacheLayout");
10
+ // All DB-touching helpers (delete / purge / vacuum / region-tile listing) need
11
+ // the raw node:sqlite handle exposed by @signalk/mbtiles. Reaching through
12
+ // `provider._mbtilesHandle!._db!` worked but lied about the contract: a proxy
13
+ // provider whose mbtiles file failed to open at startup has neither, and the
14
+ // non-null assertions papered over the resulting TypeError. Centralising here
15
+ // gives a single descriptive failure surface and removes seven `!` operators
16
+ // from call sites.
17
+ function requireMbtilesDb(provider) {
18
+ const db = provider._mbtilesHandle?._db;
19
+ if (!db) {
20
+ throw new Error(`Provider "${provider.identifier}": mbtiles cache not open. ` +
21
+ `Configure the provider with proxy:true and ensure the cache path ` +
22
+ `is writable; this operation needs the raw SQLite handle.`);
23
+ }
24
+ return db;
25
+ }
26
+ const chartDownloaderTileHelpers_1 = require("./chartDownloaderTileHelpers");
27
+ const chartDownloaderMBTilesHelpers_1 = require("./chartDownloaderMBTilesHelpers");
28
+ var State;
29
+ (function (State) {
30
+ State[State["Stopped"] = 0] = "Stopped";
31
+ State[State["Running"] = 1] = "Running";
32
+ })(State || (exports.State = State = {}));
33
+ var JobType;
34
+ (function (JobType) {
35
+ JobType[JobType["None"] = 0] = "None";
36
+ JobType[JobType["Seed"] = 1] = "Seed";
37
+ JobType[JobType["Delete"] = 2] = "Delete";
38
+ })(JobType || (JobType = {}));
39
+ var TileSource;
40
+ (function (TileSource) {
41
+ TileSource[TileSource["None"] = 0] = "None";
42
+ TileSource[TileSource["FetchedFromCache"] = 1] = "FetchedFromCache";
43
+ TileSource[TileSource["FetchedFromRemote"] = 2] = "FetchedFromRemote";
44
+ })(TileSource || (exports.TileSource = TileSource = {}));
45
+ // Categorised failure kinds for tile fetches. Lets the worker keep
46
+ // per-job counts so an admin staring at "500000 failed" can tell whether
47
+ // the upstream is timing out, the auth is wrong, or the network is gone.
48
+ // Used in TileFetchResult.failure and surfaced via job.info().failures.
49
+ var FailureKind;
50
+ (function (FailureKind) {
51
+ FailureKind["None"] = "none";
52
+ FailureKind["Timeout"] = "timeout";
53
+ FailureKind["HttpClient"] = "http_4xx";
54
+ FailureKind["HttpServer"] = "http_5xx";
55
+ FailureKind["Network"] = "network";
56
+ FailureKind["NoRemoteUrl"] = "no_remote_url";
57
+ })(FailureKind || (exports.FailureKind = FailureKind = {}));
58
+ // Default tile-fetch timeout. Bumped from 5s to 10s — some legitimate
59
+ // tile providers (NOAA WMS, custom inland-water sources) take 5-15s on
60
+ // a cold-cache backend tile and a 5s default marked them as failures.
61
+ // Per-provider override available via OnlineChartProvider.timeoutMs and
62
+ // TokenProviderConfig.tile.timeoutMs (added on the same change).
63
+ const TILE_FETCH_TIMEOUT_MS = 10000;
64
+ // Default backoff before a single retry on a transient 5xx (502/503).
65
+ // Picked to be long enough that an upstream momentary blip clears, short
66
+ // enough that 32 workers don't all stall on a hard outage. Configurable
67
+ // at runtime via ChartDownloader.tileRetryBackoffMs (test harness sets
68
+ // it to 0 to keep mocked-503 tests fast).
69
+ const DEFAULT_TILE_RETRY_BACKOFF_MS = 1500;
22
70
  class ChartSeedingManager {
23
71
  static ActiveJobs = {};
24
- static async createJob(resourcesApi, chartsPath, provider, maxZoom, regionGUID = undefined, bbox = undefined, tile = undefined) {
25
- const downloader = new ChartDownloader(resourcesApi, chartsPath, provider);
26
- // Init must complete before the job is usable; callers get back a job that
27
- // knows its tile set and totalTiles. Without awaiting, a follow-up "start"
28
- // action would race the init reads of this.tiles.
29
- if (regionGUID)
30
- await downloader.initializeJobFromRegion(regionGUID, maxZoom);
31
- else if (bbox)
32
- await downloader.initializeJobFromBBox(bbox, maxZoom);
33
- else if (tile)
34
- await downloader.initializeJobFromTile(tile, maxZoom);
35
- else
36
- throw new Error('createJob requires regionGUID, bbox, or tile');
72
+ /**
73
+ * Reset all module-level static state owned by ChartSeedingManager and
74
+ * ChartDownloader so a test harness can run from a known baseline.
75
+ *
76
+ * The plugin's static fields outlive a `stop()` / `start()` cycle in
77
+ * production (which is intended — process-global gates and ID sequences),
78
+ * but in a test process running 200+ tests in sequence the residue
79
+ * shows up as cross-test interference: nextJobId drifts upward, an
80
+ * earlier low-disk-space probe leaves CachingEnabled=false for 2s,
81
+ * stale CacheStatistics from one provider get seen by an unrelated
82
+ * test asserting "stats are an object".
83
+ *
84
+ * Production code never calls this. The test app's beforeEach does.
85
+ */
86
+ static resetForTests() {
87
+ this.ActiveJobs = {};
88
+ ChartDownloader.resetForTests();
89
+ }
90
+ static async createJob(cachePath, provider, options, minZoom, maxZoom, feature = undefined, bbox = undefined) {
91
+ const downloader = new ChartDownloader(cachePath, provider, options);
37
92
  this.ActiveJobs[downloader.ID] = downloader;
93
+ if (feature)
94
+ downloader.initalizeJobFromFeature(feature, minZoom, maxZoom);
95
+ else if (bbox)
96
+ downloader.initializeJobFromBBox(bbox, minZoom, maxZoom);
38
97
  return downloader;
39
98
  }
40
99
  // Cancel all running jobs and clear the registry. Used when the plugin stops
@@ -48,206 +107,389 @@ class ChartSeedingManager {
48
107
  }
49
108
  exports.ChartSeedingManager = ChartSeedingManager;
50
109
  class ChartDownloader {
51
- resourcesApi;
52
- chartsPath;
110
+ cachePath;
53
111
  provider;
112
+ options;
54
113
  static MINIMUM_FREE_DISK_SPACE = 1024 * 1024 * 1024; // 1 GB
55
114
  static nextJobId = 1;
115
+ static CachingEnabled = true;
116
+ static CacheStatistics = new Map();
117
+ static TilesCached = 0;
118
+ static lastDiskSpaceCheck = 0;
119
+ static lastDiskSpaceResult = true;
120
+ // See DEFAULT_TILE_RETRY_BACKOFF_MS. Public so the test harness can drop
121
+ // it to 0; production code should treat it as read-only.
122
+ static tileRetryBackoffMs = DEFAULT_TILE_RETRY_BACKOFF_MS;
56
123
  id = ChartDownloader.nextJobId++;
57
- status = Status.Stopped;
124
+ type = JobType.None;
125
+ state = State.Stopped;
126
+ status = 'Idle';
58
127
  totalTiles = 0;
59
128
  downloadedTiles = 0;
60
129
  failedTiles = 0;
61
130
  cachedTiles = 0;
62
- concurrentDownloadsLimit = 20;
131
+ deletedTiles = 0;
132
+ // Per-job failure histogram, keyed by FailureKind. Surfaced via
133
+ // info().failures so the UI / admin can tell timeouts apart from
134
+ // 5xx apart from auth failures without grepping logs. Only the
135
+ // first failure of each kind is logged in detail (sampleLogged)
136
+ // so a job with 500k failed tiles doesn't drown the log buffer.
137
+ failureCounts = {};
138
+ sampleLogged = new Set();
63
139
  areaDescription = '';
64
140
  cancelRequested = false;
65
- tiles = [];
66
- tilesToDownload = [];
67
- constructor(resourcesApi, chartsPath, provider) {
68
- this.resourcesApi = resourcesApi;
69
- this.chartsPath = chartsPath;
141
+ regionName = 'Unnamed';
142
+ tiles;
143
+ tilesInDB;
144
+ constructor(cachePath, provider, options) {
145
+ this.cachePath = cachePath;
70
146
  this.provider = provider;
147
+ this.options = options;
71
148
  }
72
149
  get ID() {
73
150
  return this.id;
74
151
  }
75
- async initializeJobFromRegion(regionGUID, maxZoom) {
76
- const region = (await this.resourcesApi.getResource('regions', regionGUID));
77
- const geojson = this.convertRegionToGeoJSON(region);
78
- this.tiles = this.getTilesForGeoJSON(geojson, this.provider.minzoom, maxZoom);
79
- this.tilesToDownload = await this.filterCachedTiles(this.tiles);
80
- this.status = Status.Stopped;
81
- this.totalTiles = this.tiles.length;
82
- this.cachedTiles = this.totalTiles - this.tilesToDownload.length;
83
- this.areaDescription = `Region: ${region?.name ?? ''}`;
84
- }
85
- async initializeJobFromBBox(bbox, maxZoom) {
86
- this.tiles = this.getTilesForBBox(bbox, maxZoom);
87
- this.tilesToDownload = await this.filterCachedTiles(this.tiles);
88
- this.status = Status.Stopped;
89
- this.totalTiles = this.tiles.length;
90
- this.cachedTiles = this.totalTiles - this.tilesToDownload.length;
91
- this.areaDescription = `BBox: [${bbox.join(', ')}]`;
152
+ async initalizeJobFromFeature(feature, minZoom, maxZoom) {
153
+ const geojson = (0, chartDownloaderTileHelpers_1.convertFeatureToGeoJSON)(feature);
154
+ this.tiles = () => (0, chartDownloaderTileHelpers_1.getTilesForGeoJSON)(geojson, minZoom, maxZoom);
155
+ // Defer the requireMbtilesDb call to factory invocation: the handle may
156
+ // not be open yet when initialize* runs (e.g. createJob before the
157
+ // proxy mbtiles is reconciled), but it must be open by the time the
158
+ // generator is iterated.
159
+ this.tilesInDB = () => (0, chartDownloaderMBTilesHelpers_1.getMBTilesForPolygon)(requireMbtilesDb(this.provider), geojson, minZoom, maxZoom);
160
+ this.totalTiles = (0, chartDownloaderTileHelpers_1.countTilesAdaptiveIterative)(geojson, minZoom, maxZoom);
161
+ this.state = State.Stopped;
162
+ this.regionName = feature?.properties?.name ?? 'Unnamed';
163
+ this.areaDescription = `Region: ${this.regionName}`;
92
164
  }
93
- async initializeJobFromTile(tile, maxZoom) {
94
- this.tiles = this.getSubTiles(tile, maxZoom);
95
- this.tilesToDownload = await this.filterCachedTiles(this.tiles);
96
- this.status = Status.Stopped;
97
- this.totalTiles = this.tiles.length;
98
- this.cachedTiles = this.totalTiles - this.tilesToDownload.length;
99
- this.areaDescription = `Tile: [${tile.x}, ${tile.y}, ${tile.z}]`;
165
+ async initializeJobFromBBox(bbox, minZoom, maxZoom) {
166
+ const geojson = (0, chartDownloaderTileHelpers_1.convertBboxToGeoJSON)(bbox);
167
+ this.tiles = () => (0, chartDownloaderTileHelpers_1.getTilesForGeoJSON)(geojson, minZoom, maxZoom);
168
+ this.tilesInDB = () => (0, chartDownloaderMBTilesHelpers_1.getMBTilesForPolygon)(requireMbtilesDb(this.provider), geojson, minZoom, maxZoom);
169
+ this.totalTiles = (0, chartDownloaderTileHelpers_1.countTilesAdaptiveIterative)(geojson, minZoom, maxZoom);
170
+ this.state = State.Stopped;
171
+ this.areaDescription = `BBox: [${bbox
172
+ .map((v) => Number(v).toFixed(3))
173
+ .join(', ')}]`;
100
174
  }
101
- static DISK_CHECK_INTERVAL_MS = 30_000;
102
175
  /**
103
176
  * Download map tiles for a specific area.
104
177
  */
105
178
  async seedCache() {
106
179
  // Guard against double-start: a second call while Running would share
107
180
  // counters and concurrency slots with the first and corrupt progress.
108
- if (this.status === Status.Running)
181
+ if (this.state === State.Running)
109
182
  return;
183
+ const concurrency = 32;
110
184
  this.cancelRequested = false;
111
- this.status = Status.Running;
112
- this.tilesToDownload = await this.filterCachedTiles(this.tiles);
185
+ this.state = State.Running;
113
186
  this.downloadedTiles = 0;
187
+ this.cachedTiles = 0;
114
188
  this.failedTiles = 0;
115
- this.cachedTiles = this.totalTiles - this.tilesToDownload.length;
116
- const limit = (0, p_limit_1.default)(this.concurrentDownloadsLimit); // concurrent download limit
117
- let lastDiskCheck = 0;
118
- const tasks = this.tilesToDownload.map((tile) => limit(async () => {
119
- if (this.cancelRequested)
120
- return;
121
- // Time-based (rather than tile-count-based) disk-space probing: a
122
- // tight per-1000-tile cadence fired hundreds of times on a large
123
- // bbox, whereas real disk consumption grows with wall-clock time.
124
- const now = Date.now();
125
- if (now - lastDiskCheck >= ChartDownloader.DISK_CHECK_INTERVAL_MS) {
126
- lastDiskCheck = now;
189
+ this.failureCounts = {};
190
+ this.sampleLogged = new Set();
191
+ this.type = JobType.Seed;
192
+ console.log(`[charts-plugin] job ${this.id} started seeding ` +
193
+ `provider=${this.provider.identifier} ` +
194
+ `area="${this.areaDescription}" totalTiles=${this.totalTiles}`);
195
+ const tileIterator = this.tiles();
196
+ let generatorDone = false;
197
+ const nextTile = () => {
198
+ if (generatorDone || this.cancelRequested)
199
+ return null;
200
+ const { value, done } = tileIterator.next();
201
+ if (done) {
202
+ generatorDone = true;
203
+ return null;
204
+ }
205
+ return value;
206
+ };
207
+ const worker = async () => {
208
+ while (!this.cancelRequested) {
209
+ const tile = nextTile();
210
+ if (!tile)
211
+ return;
212
+ if (!ChartDownloader.CachingEnabled) {
213
+ this.cancelRequested = true;
214
+ this.status = 'Insufficient disk space, canceling job';
215
+ return;
216
+ }
127
217
  try {
128
- const { free } = await (0, check_disk_space_1.default)(this.chartsPath);
129
- if (free < ChartDownloader.MINIMUM_FREE_DISK_SPACE) {
130
- console.warn(`Low disk space. Stopping download.`);
131
- this.cancelRequested = true;
132
- return;
218
+ const result = await ChartDownloader.getTileFromCacheOrRemote(this.cachePath, this.provider, tile, this.options.refetch);
219
+ if (result.source === TileSource.None) {
220
+ this.failedTiles++;
221
+ const kind = result.failure?.kind ?? FailureKind.Network;
222
+ this.failureCounts[kind] = (this.failureCounts[kind] ?? 0) + 1;
223
+ // Log the first instance of each failure kind in this job —
224
+ // gives an operator something to grep for ("job 42 timeout
225
+ // first at z=12 x=2057 y=1364 status=undefined") without
226
+ // 500k log lines if every tile fails.
227
+ if (!this.sampleLogged.has(kind)) {
228
+ this.sampleLogged.add(kind);
229
+ console.warn(`[charts-plugin] job ${this.id} ` +
230
+ `provider=${this.provider.identifier} ` +
231
+ `first ${kind} failure at z=${tile.z} x=${tile.x} y=${tile.y}` +
232
+ (result.failure?.status
233
+ ? ` status=${result.failure.status}`
234
+ : ''));
235
+ }
236
+ }
237
+ else if (result.source === TileSource.FetchedFromCache) {
238
+ this.cachedTiles++;
239
+ }
240
+ else {
241
+ this.downloadedTiles++;
133
242
  }
243
+ this.totalTiles = Math.max(this.totalTiles, this.downloadedTiles + this.failedTiles + this.cachedTiles);
134
244
  }
135
245
  catch (err) {
136
- console.error(`Error checking disk space:`, err);
137
- this.cancelRequested = true;
138
- return;
246
+ this.failedTiles++;
247
+ const msg = err.message ?? String(err);
248
+ this.failureCounts[FailureKind.Network] =
249
+ (this.failureCounts[FailureKind.Network] ?? 0) + 1;
250
+ if (!this.sampleLogged.has(FailureKind.Network)) {
251
+ this.sampleLogged.add(FailureKind.Network);
252
+ console.warn(`[charts-plugin] job ${this.id} ` +
253
+ `provider=${this.provider.identifier} ` +
254
+ `first uncaught failure at z=${tile.z} x=${tile.x} y=${tile.y}: ${msg}`);
255
+ }
139
256
  }
140
257
  }
141
- const buffer = await ChartDownloader.getTileFromCacheOrRemote(this.chartsPath, this.provider, tile);
142
- // Re-check after the await: the job may have been cancelled while the
143
- // fetch was in flight. Still-running fetches would otherwise keep
144
- // mutating counters after status flips to Stopped.
145
- if (this.cancelRequested)
146
- return;
147
- if (buffer === null) {
148
- this.failedTiles++;
149
- }
150
- else {
151
- this.downloadedTiles++;
258
+ };
259
+ this.status = 'Seeding';
260
+ const workers = Array.from({ length: concurrency }, () => worker());
261
+ await Promise.all(workers);
262
+ this.state = State.Stopped;
263
+ if (this.options.mbtiles) {
264
+ try {
265
+ this.status = 'Creating MBTiles';
266
+ const safeRegionName = this.regionName
267
+ .normalize('NFKC') // normalize unicode
268
+ .replace(/[^a-zA-Z0-9_-]/g, '_') // allow only safe chars
269
+ .replace(/_+/g, '_') // collapse repeats
270
+ .replace(/^_+|_+$/g, '') // trim underscores
271
+ .slice(0, 100); // limit length
272
+ // Snapshot exports go to ${cachePath}/exports/, which is excluded from
273
+ // the chart scanner — the file is a build artifact for offline transfer
274
+ // (USB stick to another SK server), not a chart the running plugin
275
+ // serves. Re-running the job overwrites the file freely; no scanner
276
+ // handle holds it open.
277
+ const baseDir = path_1.default.resolve(this.cachePath, cacheLayout_1.EXPORTS_DIR);
278
+ const filePath = path_1.default.resolve((0, cacheLayout_1.exportMbtilesPath)(this.cachePath, safeRegionName, this.provider.identifier));
279
+ // Same separator-aware check as the migrate endpoint: a naive
280
+ // startsWith would miss sibling-directory prefix collisions.
281
+ if (filePath !== baseDir && !filePath.startsWith(baseDir + path_1.default.sep)) {
282
+ throw new Error('Invalid path detected');
283
+ }
284
+ // The export copies the whole seeded set into a second mbtiles, so it
285
+ // needs roughly as much free space again. Bail before opening the file
286
+ // rather than filling the disk and corrupting the working cache.
287
+ if (!(await ChartDownloader.hasDiskSpace(baseDir))) {
288
+ throw new Error('Insufficient disk space for mbtiles export');
289
+ }
290
+ const mbtiles = await (0, chartDownloaderMBTilesHelpers_1.openOrCreateMbtiles)(filePath, this.provider);
291
+ const iterator = this.tiles();
292
+ for (const tile of iterator) {
293
+ const buffer = await ChartDownloader.getTileFromMbTiles(this.provider._mbtilesHandle, tile);
294
+ if (buffer) {
295
+ await ChartDownloader.cacheTileToMbTiles(mbtiles, tile, buffer);
296
+ }
297
+ }
298
+ mbtiles._db?.exec('PRAGMA wal_checkpoint(TRUNCATE);');
152
299
  }
153
- }));
154
- // allSettled ensures every in-flight task completes before we flip back
155
- // to Stopped; Promise.all would resolve on the first rejection while
156
- // other tasks still incremented counters in the background.
157
- const results = await Promise.allSettled(tasks);
158
- for (const r of results) {
159
- if (r.status === 'rejected') {
160
- console.error('Error downloading tile:', r.reason);
300
+ catch (err) {
301
+ // The tile seed itself already succeeded (state is Stopped); only the
302
+ // optional export failed. Record it and rethrow so the HTTP handler's
303
+ // .catch() can log it without an unhandled rejection crashing the
304
+ // server.
305
+ this.status = `Failed: ${err.message}`;
306
+ console.error(`[charts-plugin] job ${this.id} mbtiles export failed: ${err.message}`);
307
+ throw err;
161
308
  }
162
309
  }
163
- this.status = Status.Stopped;
310
+ this.status = 'Completed';
311
+ console.log(`[charts-plugin] job ${this.id} completed ` +
312
+ `provider=${this.provider.identifier} ` +
313
+ `downloaded=${this.downloadedTiles} cached=${this.cachedTiles} ` +
314
+ `failed=${this.failedTiles} ` +
315
+ `failures=${JSON.stringify(this.failureCounts)}`);
164
316
  }
165
317
  async deleteCache() {
166
- this.status = Status.Running;
167
- for (const tile of this.tiles) {
168
- if (this.cancelRequested)
169
- break;
170
- const tilePath = path_1.default.join(this.chartsPath, `${this.provider.name}`, `${tile.z}`, `${tile.x}`, `${tile.y}.${this.provider.format}`);
171
- try {
172
- await fs_1.default.promises.unlink(tilePath);
173
- this.cachedTiles = Math.max(this.cachedTiles - 1, 0);
174
- }
175
- catch (err) {
176
- if (err.code !== 'ENOENT') {
177
- console.error(`Error deleting cached tile ${tilePath}:`, err);
178
- }
318
+ this.state = State.Running;
319
+ this.deletedTiles = 0;
320
+ this.type = JobType.Delete;
321
+ this.status = 'Deleting tiles';
322
+ console.log(`[charts-plugin] job ${this.id} delete started ` +
323
+ `provider=${this.provider.identifier} totalTiles=${this.totalTiles}`);
324
+ try {
325
+ const db = requireMbtilesDb(this.provider);
326
+ await (0, chartDownloaderMBTilesHelpers_1.deleteTilesInChunks)(db, this.tilesInDB(), 1000, (deleted) => {
327
+ console.log(`[charts-plugin] job ${this.id} deleted ${deleted} / ${this.totalTiles}`);
328
+ });
329
+ this.status = 'Purging orphaned images';
330
+ await (0, chartDownloaderMBTilesHelpers_1.purgeAllOrphanImages)(db, 1000, (deleted, totalDeleted) => {
331
+ this.deletedTiles += deleted;
332
+ console.log(`[charts-plugin] job ${this.id} purged ${totalDeleted} orphans ` +
333
+ `(last chunk ${deleted})`);
334
+ });
335
+ if (this.options.vacuum) {
336
+ this.status = 'Vacuuming MBTiles database';
337
+ (0, chartDownloaderMBTilesHelpers_1.vacuumMbtiles)(db);
179
338
  }
339
+ this.status = 'Completed';
340
+ this.state = State.Stopped;
341
+ console.log(`[charts-plugin] job ${this.id} delete completed ` +
342
+ `provider=${this.provider.identifier} ` +
343
+ `deletedTiles=${this.deletedTiles}`);
344
+ }
345
+ catch (err) {
346
+ // Record the failure on the job and reset state so the job doesn't sit
347
+ // wedged in 'Running' forever. Rethrow so callers / tests still observe
348
+ // it — the HTTP handler attaches a .catch() to keep the rejection from
349
+ // becoming an unhandled rejection that crashes the server.
350
+ this.state = State.Stopped;
351
+ this.status = `Failed: ${err.message}`;
352
+ console.error(`[charts-plugin] job ${this.id} delete failed: ${err.message}`);
353
+ throw err;
180
354
  }
181
- this.status = Status.Stopped;
182
355
  }
183
356
  cancelJob() {
184
357
  this.cancelRequested = true;
185
358
  }
186
- async filterCachedTiles(allTiles) {
187
- // Bound the concurrent fs.access calls. 100k+ tiles in a large bbox would
188
- // otherwise fire all accesses at once, risking EMFILE on default rlimit
189
- // and spiking the event loop.
190
- const limit = (0, p_limit_1.default)(64);
191
- const checks = allTiles.map((tile) => limit(async () => {
192
- const tilePath = path_1.default.join(this.chartsPath, this.provider.name, `${tile.z}`, `${tile.x}`, `${tile.y}.${this.provider.format}`);
193
- try {
194
- await fs_1.default.promises.access(tilePath); // file exists
195
- return null; // filter out cached tile
196
- }
197
- catch (err) {
198
- if (err.code === 'ENOENT') {
199
- return tile; // file does not exist → uncached
359
+ info() {
360
+ const progress = () => {
361
+ if (this.totalTiles > 0) {
362
+ if (this.type === JobType.Seed) {
363
+ return ((this.downloadedTiles + this.cachedTiles + this.failedTiles) /
364
+ this.totalTiles);
365
+ }
366
+ else if (this.type === JobType.Delete) {
367
+ return this.deletedTiles / this.totalTiles;
200
368
  }
201
- console.error('Unexpected fs error:', err);
202
- return tile; // treat unknown errors as uncached
203
369
  }
204
- }));
205
- const results = await Promise.all(checks);
206
- return results.filter((t) => t !== null);
207
- }
208
- info() {
370
+ return 0;
371
+ };
209
372
  return {
210
373
  id: this.id,
374
+ type: this.type,
211
375
  chartName: this.provider.name,
212
376
  regionName: this.areaDescription,
213
377
  totalTiles: this.totalTiles,
214
378
  downloadedTiles: this.downloadedTiles,
215
379
  cachedTiles: this.cachedTiles,
216
380
  failedTiles: this.failedTiles,
217
- progress: this.totalTiles > 0
218
- ? (this.downloadedTiles + this.cachedTiles + this.failedTiles) /
219
- this.totalTiles
220
- : 0,
221
- status: this.status
381
+ // Per-failure-kind histogram. Empty {} for jobs with no failures.
382
+ // Surfaced so the UI / admin can see "1234 timeouts, 5 auth, 200 5xx"
383
+ // instead of an undifferentiated failedTiles count.
384
+ failures: { ...this.failureCounts },
385
+ deletedTiles: this.deletedTiles,
386
+ progress: progress(),
387
+ status: this.status,
388
+ state: this.state
222
389
  };
223
390
  }
224
- static async getTileFromCacheOrRemote(chartsPath, provider, tile) {
225
- const tilePath = path_1.default.join(chartsPath, `${provider.name}`, `${tile.z}`, `${tile.x}`, `${tile.y}.${provider.format}`);
391
+ static async getTileFromCacheOrRemote(chartsPath, provider, tile, overwrite = false) {
392
+ let stats = ChartDownloader.CacheStatistics.get(provider.identifier);
393
+ if (!stats) {
394
+ stats = { requests: 0, hits: 0, misses: 0, failures: 0 };
395
+ ChartDownloader.CacheStatistics.set(provider.identifier, stats);
396
+ }
397
+ stats.requests++;
398
+ // Try MBTiles cache first
399
+ if (!overwrite) {
400
+ const mbtilesBuffer = await ChartDownloader.getTileFromMbTiles(provider._mbtilesHandle, tile);
401
+ if (mbtilesBuffer) {
402
+ stats.hits++;
403
+ return { buffer: mbtilesBuffer, source: TileSource.FetchedFromCache };
404
+ }
405
+ }
406
+ // Cache miss: fetch from remote
407
+ const { buffer, failure } = await ChartDownloader.fetchTileFromRemoteDetailed(provider, tile);
408
+ ChartDownloader.CachingEnabled =
409
+ await ChartDownloader.hasDiskSpace(chartsPath);
410
+ if (ChartDownloader.CachingEnabled && buffer) {
411
+ stats.misses++;
412
+ //Writing to mbtiles must be awaited
413
+ await ChartDownloader.cacheTileToMbTiles(provider._mbtilesHandle, tile, buffer);
414
+ return {
415
+ buffer,
416
+ source: TileSource.FetchedFromRemote,
417
+ failure: { kind: FailureKind.None }
418
+ };
419
+ }
420
+ stats.failures++;
421
+ return { buffer: null, source: TileSource.None, failure };
422
+ }
423
+ static async getTileFromMbTiles(mbtilesHandle, tile) {
424
+ if (!mbtilesHandle) {
425
+ return null;
426
+ }
226
427
  try {
227
- const data = await fs_1.default.promises.readFile(tilePath);
228
- return data;
428
+ const cachedBuffer = await new Promise((resolve, reject) => {
429
+ mbtilesHandle.getTile(tile.z, tile.x, tile.y, (err, data) => {
430
+ if (err) {
431
+ // MBTiles returns an error when tile does not exist
432
+ if (err.message?.includes('Tile does not exist')) {
433
+ return resolve(null);
434
+ }
435
+ return reject(err);
436
+ }
437
+ resolve(data);
438
+ });
439
+ });
440
+ if (cachedBuffer) {
441
+ return cachedBuffer;
442
+ }
229
443
  }
230
444
  catch (err) {
231
- //Cache miss, proceed to fetch from remote
445
+ console.error('Failed to read tile from MBTiles cache:', err);
232
446
  }
233
- const buffer = await this.fetchTileFromRemote(provider, tile);
234
- if (buffer) {
447
+ return null;
448
+ }
449
+ static async cacheTileToMbTiles(mbtilesHandle, tile, buffer) {
450
+ if (buffer && ChartDownloader.CachingEnabled) {
235
451
  try {
236
- await fs_1.default.promises.mkdir(path_1.default.dirname(tilePath), { recursive: true });
237
- await fs_1.default.promises.writeFile(tilePath, buffer);
452
+ await new Promise((resolve, reject) => {
453
+ mbtilesHandle.putTile(tile.z, tile.x, tile.y, buffer, (err) => {
454
+ if (err)
455
+ reject(err);
456
+ else
457
+ resolve();
458
+ });
459
+ });
460
+ ChartDownloader.TilesCached++;
238
461
  }
239
462
  catch (err) {
240
- console.error(`Error writing tile ${tilePath}:`, err);
463
+ console.error('Failed to write tile to mbtiles cache:', err);
241
464
  }
242
465
  }
243
- return buffer;
244
466
  }
245
- static async fetchTileFromRemote(provider, tile, timeoutMs = 5000) {
467
+ /**
468
+ * Detailed-result variant of fetchTileFromRemote. Returns the buffer on
469
+ * success and a `failure` discriminator on every other path so the
470
+ * caller can attribute "failed" tiles to a specific cause (timeout vs
471
+ * 5xx vs network) for diagnostic logs and per-job counters.
472
+ *
473
+ * Retries once on 502/503 with TILE_RETRY_BACKOFF_MS backoff: those are
474
+ * the response codes that genuinely benefit from one more try (LB hiccup,
475
+ * backend cold-start). 504 is excluded because it means the upstream
476
+ * already waited; another wait makes the per-tile latency intolerable.
477
+ * 4xx are not retried (auth or "tile genuinely doesn't exist" — neither
478
+ * gets better by trying again).
479
+ */
480
+ static async fetchTileFromRemoteDetailed(provider, tile, timeoutMs = TILE_FETCH_TIMEOUT_MS) {
481
+ // Token providers fetch their token lazily; awaiting here ensures
482
+ // remoteUrl/headers below template against a non-stale token. Fast-path
483
+ // when the cached token is still inside its TTL: the call returns
484
+ // synchronously without I/O.
485
+ if (provider._tokenProvider) {
486
+ await provider._tokenProvider.ensureFreshToken();
487
+ }
246
488
  // Local (non-proxy) providers have no remoteUrl; the POST /cache endpoint
247
489
  // is open to any provider, so callers can still land here and should get
248
490
  // a well-defined null rather than a crash.
249
491
  if (!provider.remoteUrl) {
250
- return null;
492
+ return { buffer: null, failure: { kind: FailureKind.NoRemoteUrl } };
251
493
  }
252
494
  let url = provider.remoteUrl
253
495
  .replace('{z}', tile.z.toString())
@@ -272,175 +514,110 @@ class ChartDownloader {
272
514
  url = url.replace('{bbox_3857}', `${mx1},${my1},${mx2},${my2}`);
273
515
  }
274
516
  }
275
- const controller = new AbortController();
276
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
277
- try {
278
- const response = await fetch(url, {
279
- headers: provider.headers,
280
- signal: controller.signal
281
- });
282
- // Clear the abort timer as soon as the response head is in. A long body
283
- // read was otherwise racing the timeout and could be aborted mid-stream
284
- // while the caller waited on arrayBuffer().
285
- clearTimeout(timeoutId);
286
- if (!response.ok) {
287
- return null;
517
+ let lastFailure = { kind: FailureKind.Network };
518
+ for (let attempt = 0; attempt < 2; attempt++) {
519
+ if (attempt > 0) {
520
+ await new Promise((r) => setTimeout(r, ChartDownloader.tileRetryBackoffMs));
288
521
  }
289
- const arrayBuffer = await response.arrayBuffer();
290
- return Buffer.from(arrayBuffer);
291
- }
292
- catch (_err) {
293
- clearTimeout(timeoutId);
294
- return null;
295
- }
296
- }
297
- getSubTiles(tile, maxZoom) {
298
- const tiles = [tile];
299
- for (let z = tile.z + 1; z <= maxZoom; z++) {
300
- const zoomDiff = z - tile.z;
301
- const factor = Math.pow(2, zoomDiff);
302
- const startX = tile.x * factor;
303
- const startY = tile.y * factor;
304
- for (let x = startX; x < startX + factor; x++) {
305
- for (let y = startY; y < startY + factor; y++) {
306
- tiles.push({ x, y, z });
522
+ const controller = new AbortController();
523
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
524
+ try {
525
+ const response = await fetch(url, {
526
+ headers: provider.headers,
527
+ signal: controller.signal
528
+ });
529
+ // Clear the abort timer as soon as the response head is in. A long
530
+ // body read was otherwise racing the timeout and could be aborted
531
+ // mid-stream while the caller waited on arrayBuffer().
532
+ clearTimeout(timeoutId);
533
+ if (response.ok) {
534
+ const arrayBuffer = await response.arrayBuffer();
535
+ return {
536
+ buffer: Buffer.from(arrayBuffer),
537
+ failure: { kind: FailureKind.None }
538
+ };
539
+ }
540
+ // For token providers, an upstream 401/403 most likely means the
541
+ // cached token has expired or been revoked. Invalidate so the next
542
+ // fetch refreshes it. 5xx and other non-auth errors don't trigger
543
+ // this — they're not about credentials.
544
+ if ((response.status === 401 || response.status === 403) &&
545
+ provider._tokenProvider) {
546
+ provider._tokenProvider.invalidateToken();
307
547
  }
548
+ const kind = response.status >= 500
549
+ ? FailureKind.HttpServer
550
+ : FailureKind.HttpClient;
551
+ lastFailure = { kind, status: response.status };
552
+ // Retry only on 502/503 — those are the LB/backend-blip codes
553
+ // worth a second try. 504 already implies a long wait upstream;
554
+ // 4xx won't change on retry.
555
+ if (response.status === 502 || response.status === 503)
556
+ continue;
557
+ return { buffer: null, failure: lastFailure };
558
+ }
559
+ catch (err) {
560
+ clearTimeout(timeoutId);
561
+ // AbortError vs other network errors: the timeout's controller.abort
562
+ // throws an AbortError. Treat any other thrown error as Network.
563
+ const isAbort = err.name === 'AbortError' ||
564
+ controller.signal.aborted;
565
+ lastFailure = {
566
+ kind: isAbort ? FailureKind.Timeout : FailureKind.Network
567
+ };
568
+ // Don't retry on timeout (already slow) or generic network error
569
+ // (likely DNS/route failure — won't recover in 1.5s).
570
+ return { buffer: null, failure: lastFailure };
308
571
  }
309
572
  }
310
- return tiles;
573
+ return { buffer: null, failure: lastFailure };
311
574
  }
312
575
  /**
313
- * Get all tiles that intersect a bounding box up to a maximum zoom level.
314
- * bbox = [minLon, minLat, maxLon, maxLat]
576
+ * Backwards-compatible wrapper for callers that only need the buffer.
577
+ * Internal callers should prefer fetchTileFromRemoteDetailed for the
578
+ * categorised failure information.
315
579
  */
316
- getTilesForBBox(bbox, maxZoom) {
317
- const tiles = [];
318
- const [minLon, minLat, maxLon, maxLat] = bbox;
319
- const crossesAntiMeridian = minLon > maxLon;
320
- // Respect the provider's minzoom: low zooms outside the provider's range
321
- // would 404 from the remote and just inflate totalTiles.
322
- const minZoom = Math.max(tileServer_1.MIN_ZOOM, this.provider.minzoom ?? tileServer_1.MIN_ZOOM);
323
- // Helper to process a lon/lat box normally. lonLatToTileXY returns
324
- // tile-Y increasing southward, so for a box with minLat < maxLat the
325
- // south edge yields the larger tile-Y.
326
- const processBBox = (lo1, la1, lo2, la2) => {
327
- for (let z = minZoom; z <= maxZoom; z++) {
328
- const [minX, maxY] = (0, projection_1.lonLatToTile)(lo1, la1, z); // SW corner
329
- const [maxX, minY] = (0, projection_1.lonLatToTile)(lo2, la2, z); // NE corner
330
- for (let x = minX; x <= maxX; x++) {
331
- for (let y = minY; y <= maxY; y++) {
332
- tiles.push({ x, y, z });
333
- }
334
- }
335
- }
336
- };
337
- if (!crossesAntiMeridian) {
338
- // normal
339
- processBBox(minLon, minLat, maxLon, maxLat);
340
- }
341
- else {
342
- // crosses antimeridian — split into two boxes:
343
- // [minLon -> 180] and [-180 -> maxLon]
344
- processBBox(minLon, minLat, 180, maxLat);
345
- processBBox(-180, minLat, maxLon, maxLat);
346
- }
347
- return tiles;
348
- }
349
- getTilesForGeoJSON(geojson, zoomMin = 1, zoomMax = 14) {
350
- const tiles = [];
351
- for (const feature of geojson.features) {
352
- if (feature.geometry.type !== 'Polygon' &&
353
- feature.geometry.type !== 'MultiPolygon') {
354
- console.warn('Skipping non-polygon feature');
355
- continue;
356
- }
357
- const boundingBox = (0, bbox_1.bbox)(feature.geometry); // [minX, minY, maxX, maxY]
358
- for (let z = zoomMin; z <= zoomMax; z++) {
359
- const [minX, minY] = (0, projection_1.lonLatToTile)(boundingBox[0], boundingBox[3], z); // top-left
360
- const [maxX, maxY] = (0, projection_1.lonLatToTile)(boundingBox[2], boundingBox[1], z); // bottom-right
361
- for (let x = minX; x <= maxX; x++) {
362
- for (let y = minY; y <= maxY; y++) {
363
- // Cheap AABB pre-filter avoids allocating a turf polygon and
364
- // running booleanIntersects for tiles that can't possibly
365
- // overlap the feature's bbox. Saves 90%+ of the turf work on
366
- // concave regions.
367
- const [tMinLon, tMinLat, tMaxLon, tMaxLat] = (0, projection_1.tileToBBox)(x, y, z);
368
- if (tMaxLon < boundingBox[0] ||
369
- tMinLon > boundingBox[2] ||
370
- tMaxLat < boundingBox[1] ||
371
- tMinLat > boundingBox[3]) {
372
- continue;
373
- }
374
- const tilePoly = this.bboxPolygon([
375
- tMinLon,
376
- tMinLat,
377
- tMaxLon,
378
- tMaxLat
379
- ]);
380
- if ((0, boolean_intersects_1.default)(feature, tilePoly)) {
381
- tiles.push({ x, y, z });
382
- }
383
- }
384
- }
385
- }
386
- }
387
- return tiles;
580
+ static async fetchTileFromRemote(provider, tile, timeoutMs = TILE_FETCH_TIMEOUT_MS) {
581
+ const { buffer } = await ChartDownloader.fetchTileFromRemoteDetailed(provider, tile, timeoutMs);
582
+ return buffer;
388
583
  }
389
- convertRegionToGeoJSON(region) {
390
- const feature = region.feature;
391
- if (!feature || feature.type !== 'Feature' || !feature.geometry) {
392
- throw new Error('Invalid region: missing feature or geometry');
584
+ static async hasDiskSpace(path) {
585
+ const now = Date.now();
586
+ // cache for 2 seconds
587
+ if (now - ChartDownloader.lastDiskSpaceCheck < 2000) {
588
+ return ChartDownloader.lastDiskSpaceResult;
393
589
  }
394
- const geoFeature = {
395
- type: 'Feature',
396
- id: feature.id || undefined,
397
- geometry: feature.geometry,
398
- properties: {
399
- name: region.name || '',
400
- description: region.description || '',
401
- timestamp: region.timestamp || '',
402
- source: region.$source || '',
403
- ...(feature.properties || {})
404
- }
405
- };
406
- const splitGeoFeature = (0, geojson_antimeridian_cut_1.default)(geoFeature);
407
- const features = [];
408
- const pushFeaturePolygon = (orig, coords, idx) => {
409
- const poly = {
410
- type: 'Feature',
411
- id: idx != null && orig.id ? `${orig.id}-${idx}` : orig.id,
412
- geometry: {
413
- type: 'Polygon',
414
- coordinates: coords
415
- },
416
- properties: orig.properties || {}
417
- };
418
- features.push(poly);
419
- };
420
- const f = splitGeoFeature;
421
- if (f.geometry && f.geometry.type === 'MultiPolygon') {
422
- const coords = f.geometry.coordinates;
423
- coords.forEach((ring, i) => pushFeaturePolygon(f, ring, i));
590
+ try {
591
+ // check-disk-space is ESM-only and exports no require entry, so this
592
+ // CommonJS build can only reach it through a real dynamic import.
593
+ const { checkDiskSpace } = await import('check-disk-space');
594
+ const { free } = await checkDiskSpace(path);
595
+ ChartDownloader.lastDiskSpaceResult =
596
+ free >= ChartDownloader.MINIMUM_FREE_DISK_SPACE;
424
597
  }
425
- else if (f.geometry && f.geometry.type === 'Polygon') {
426
- features.push(f);
598
+ catch (err) {
599
+ ChartDownloader.lastDiskSpaceResult = false;
427
600
  }
428
- return {
429
- type: 'FeatureCollection',
430
- features
431
- };
601
+ ChartDownloader.lastDiskSpaceCheck = now;
602
+ return ChartDownloader.lastDiskSpaceResult;
432
603
  }
433
- bboxPolygon(boundingBox) {
434
- const [minLon, minLat, maxLon, maxLat] = boundingBox;
435
- return (0, helpers_1.polygon)([
436
- [
437
- [minLon, minLat],
438
- [maxLon, minLat],
439
- [maxLon, maxLat],
440
- [minLon, maxLat],
441
- [minLon, minLat]
442
- ]
443
- ]);
604
+ static getStatistics() {
605
+ return Object.fromEntries(ChartDownloader.CacheStatistics);
606
+ }
607
+ /**
608
+ * Reset the static fields owned by ChartDownloader. See
609
+ * ChartSeedingManager.resetForTests for why this exists.
610
+ */
611
+ static resetForTests() {
612
+ ChartDownloader.nextJobId = 1;
613
+ ChartDownloader.CachingEnabled = true;
614
+ ChartDownloader.CacheStatistics = new Map();
615
+ ChartDownloader.TilesCached = 0;
616
+ ChartDownloader.lastDiskSpaceCheck = 0;
617
+ ChartDownloader.lastDiskSpaceResult = true;
618
+ // Skip the production 1.5s backoff for mocked-503 tests; production
619
+ // value is restored per the constant on plugin reload.
620
+ ChartDownloader.tileRetryBackoffMs = 0;
444
621
  }
445
622
  }
446
623
  exports.ChartDownloader = ChartDownloader;