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