@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
package/plugin/index.js CHANGED
@@ -42,12 +42,17 @@ const charts_1 = require("./charts");
42
42
  const constants_1 = require("./constants");
43
43
  const pluginStatus_1 = require("./pluginStatus");
44
44
  const chartDownloader_1 = require("./chartDownloader");
45
+ const chartDownloaderMBTilesHelpers_1 = require("./chartDownloaderMBTilesHelpers");
46
+ const tokenProvider_1 = require("./tokenProvider");
47
+ const cacheLayout_1 = require("./cacheLayout");
45
48
  const tileServer_1 = require("./tileServer");
46
49
  const chartTilesPath = '/signalk/chart-tiles';
47
50
  // Debounce window used to collapse FSWatcher bursts during rename / atomic-save
48
51
  // sequences into one reload. Overridable via env var so tests don't have to
49
- // wait the full 5s on every watcher assertion.
50
- const RELOAD_DEBOUNCE_MS = Number(process.env.SK_CHARTS_RELOAD_DEBOUNCE_MS) || 5000;
52
+ // wait the full 5s on every watcher assertion. Read at call time rather than
53
+ // captured at module load so a test can set the env var after importing this
54
+ // module.
55
+ const reloadDebounceMs = () => Number(process.env.SK_CHARTS_RELOAD_DEBOUNCE_MS) || 5000;
51
56
  const createPlugin = (app) => {
52
57
  let chartProviders = {};
53
58
  // Pre-computed per-version views of chartProviders, rebuilt on every reload.
@@ -56,6 +61,13 @@ const createPlugin = (app) => {
56
61
  let sanitizedV1 = {};
57
62
  let sanitizedV2 = {};
58
63
  let pluginStarted = false;
64
+ // pluginStarted vs pluginActive: pluginStarted is "registerRoutes has run"
65
+ // (sticky for the lifetime of the plugin closure — Express routes
66
+ // shouldn't double-register on a stop/start cycle). pluginActive is "is
67
+ // the plugin currently running" — flipped to true at the end of
68
+ // doStartup, false at the start of stop(). Watcher reloads check the
69
+ // latter so a queued reload from before stop() is harmless after stop().
70
+ let pluginActive = false;
59
71
  let providerRegistered = false;
60
72
  let props = {
61
73
  chartPaths: [],
@@ -76,17 +88,29 @@ const createPlugin = (app) => {
76
88
  let reloadTimer;
77
89
  let activeChartPaths = [];
78
90
  let activeOnlineProviders = {};
91
+ // Built from props.tokenProviders during doStartup; merged into
92
+ // chartProviders alongside the online providers. The ChartProvider value
93
+ // here holds an `_tokenProvider` handle so the proxy fetch path can call
94
+ // ensureFreshToken() before reading remoteUrl / headers.
95
+ let activeTokenProviders = {};
96
+ const migrationStatuses = new Map();
97
+ // Cooldown after a migration completes — protects against an admin
98
+ // client polling `migrate` and re-firing on completion. Re-running
99
+ // before the cooldown returns 429.
100
+ const MIGRATION_COOLDOWN_MS = 60_000;
79
101
  // Last scan result, surfaced in the config schema description so the admin
80
- // UI shows per-path counts when the user reopens the plugin config. Issue #8.
102
+ // UI shows per-path counts when the user reopens the plugin config.
81
103
  let lastChartPathCounts = [];
82
104
  // Check Node version for schema
83
105
  const nodeVersion = process.versions.node;
84
106
  const nodeMajorVersion = parseInt(nodeVersion.split('.')[0] ?? '0');
85
- // Builds the `chartPaths` description, appending the latest per-path chart
86
- // counts when available. The admin UI re-fetches the schema every time the
87
- // plugin config page opens, so this text refreshes on reload. Issue #8.
88
- const chartPathsDescription = () => {
89
- const base = `Add one or more paths to find charts. Defaults to "${defaultChartsPath}"`;
107
+ // Builds the chart-path field description, appending the latest per-path
108
+ // chart counts when available. The admin UI re-fetches the schema every time
109
+ // the plugin config page opens, so this text refreshes on reload. Applied to
110
+ // the array's items (not the array) because the admin UI renders an array
111
+ // field's own description twice.
112
+ const chartPathItemDescription = () => {
113
+ const base = `Path to find charts, relative to "${configBasePath}". Defaults to "${defaultChartsPath}"`;
90
114
  if (lastChartPathCounts.length === 0)
91
115
  return base;
92
116
  const parts = lastChartPathCounts.map((p) => `${p.chartPath} (${p.count} ${p.count === 1 ? 'chart' : 'charts'})`);
@@ -111,11 +135,14 @@ const createPlugin = (app) => {
111
135
  chartPaths: {
112
136
  type: 'array',
113
137
  title: 'Chart paths',
114
- description: chartPathsDescription(),
138
+ // Description on the items, not the array: the admin UI renders an
139
+ // array field's own description twice. The per-path scan counts that
140
+ // used to live here are already surfaced in the plugin status banner
141
+ // (see composeStatus), so nothing is lost.
115
142
  items: {
116
143
  type: 'string',
117
144
  title: 'Path',
118
- description: `Path for chart files, relative to "${configBasePath}"`
145
+ description: chartPathItemDescription()
119
146
  }
120
147
  },
121
148
  cachePath: {
@@ -171,8 +198,8 @@ const createPlugin = (app) => {
171
198
  type: 'string',
172
199
  title: 'Format',
173
200
  default: 'png',
174
- enum: ['png', 'jpg', 'pbf'],
175
- description: 'Format of map tiles: raster (png, jpg, etc.) / vector (pbf).'
201
+ enum: ['png', 'jpg', 'pbf', 'mvt', 'webp'],
202
+ description: 'Format of map tiles: raster (png, jpg, webp, etc.) / vector (pbf, mvt).'
176
203
  },
177
204
  url: {
178
205
  type: 'string',
@@ -182,16 +209,18 @@ const createPlugin = (app) => {
182
209
  proxy: {
183
210
  type: 'boolean',
184
211
  title: 'Proxy through signalk server',
185
- description: 'Create a proxy to serve remote tiles and cache fetched tiles from the remote server, to serve them locally on subsequent requests. Use webapp to configure seeding jobs to prefetch tiles to local cache.',
212
+ description: 'Create a proxy to serve remote tiles and cache fetched tiles from the remote server, to serve them locally on subsequent requests. Use webapp to configure seeding jobs to prefetch tiles to local cache. Only meaningful for tilelayer / WMS / WMTS providers; mapstyleJSON and tileJSON providers describe style/source manifests and have no per-tile cache to seed.',
186
213
  default: false
187
214
  },
188
215
  headers: {
189
216
  type: 'array',
190
217
  title: 'Headers',
191
- description: 'List of http headers to be sent to the remote server when requesting map tiles through proxy.',
192
218
  items: {
193
219
  title: 'Header Name: Value',
194
- description: 'Name and Value of the HTTP header separated by colon',
220
+ // Help text on the items, not the array: the admin UI renders
221
+ // an array field's own description twice.
222
+ description: 'HTTP header sent to the remote server when proxying tiles, ' +
223
+ 'as Name: Value (e.g. "User-Agent: MyApp/1.0").',
195
224
  type: 'string'
196
225
  }
197
226
  },
@@ -203,15 +232,118 @@ const createPlugin = (app) => {
203
232
  layers: {
204
233
  type: 'array',
205
234
  title: 'Layers',
206
- description: 'List of map layer ids to display. (Use with WMS / WMTS types.)',
207
235
  items: {
208
236
  title: 'Layer Name',
209
- description: 'Name of layer to display',
237
+ // Help text on the items, not the array: the admin UI renders
238
+ // an array field's own description twice.
239
+ description: 'Map layer id to display (use with WMS / WMTS types).',
210
240
  type: 'string'
211
241
  }
212
242
  }
213
243
  }
214
244
  }
245
+ },
246
+ tokenProviders: {
247
+ type: 'array',
248
+ title: 'Token providers',
249
+ items: {
250
+ type: 'object',
251
+ title: 'Token provider',
252
+ // The array's items carry the help text rather than the array field
253
+ // itself: the admin UI's form renderer prints an array-of-objects
254
+ // `description` twice (once for the array, once for the object
255
+ // items), so keeping it only here renders it once.
256
+ description: 'Declarative configs for chart providers that need a token ' +
257
+ 'fetched from a separate URL. The token is cached for ttlSeconds ' +
258
+ 'and templated into the tile URL/headers as {token.<field>}. ' +
259
+ 'Use {a-b} in URLs for sharded hostnames (random integer in [a,b]).',
260
+ required: ['identifier', 'name', 'tokenEndpoint', 'tile'],
261
+ properties: {
262
+ identifier: {
263
+ type: 'string',
264
+ title: 'Identifier',
265
+ description: 'Stable ID used in tile URLs (URL-safe; e.g. "navionics").'
266
+ },
267
+ name: { type: 'string', title: 'Name' },
268
+ description: { type: 'string', title: 'Description' },
269
+ type: {
270
+ type: 'string',
271
+ title: 'Map source type',
272
+ default: 'tilelayer',
273
+ enum: ['tilelayer', 'WMTS']
274
+ },
275
+ format: {
276
+ type: 'string',
277
+ title: 'Format',
278
+ default: 'png',
279
+ enum: ['png', 'jpg', 'webp', 'pbf', 'mvt']
280
+ },
281
+ scale: { type: 'number', title: 'Scale', default: 250000 },
282
+ minzoom: {
283
+ type: 'number',
284
+ title: 'Min zoom',
285
+ minimum: tileServer_1.MIN_ZOOM,
286
+ maximum: tileServer_1.MAX_ZOOM
287
+ },
288
+ maxzoom: {
289
+ type: 'number',
290
+ title: 'Max zoom',
291
+ minimum: tileServer_1.MIN_ZOOM,
292
+ maximum: tileServer_1.MAX_ZOOM
293
+ },
294
+ bounds: {
295
+ type: 'array',
296
+ title: 'Bounds [minLon, minLat, maxLon, maxLat]',
297
+ items: { type: 'number' },
298
+ minItems: 4,
299
+ maxItems: 4
300
+ },
301
+ tokenEndpoint: {
302
+ type: 'object',
303
+ title: 'Token endpoint',
304
+ required: ['url', 'ttlSeconds'],
305
+ properties: {
306
+ url: { type: 'string', title: 'URL' },
307
+ method: {
308
+ type: 'string',
309
+ title: 'Method',
310
+ default: 'GET',
311
+ enum: ['GET', 'POST']
312
+ },
313
+ headers: {
314
+ type: 'object',
315
+ title: 'Request headers',
316
+ additionalProperties: { type: 'string' }
317
+ },
318
+ body: { type: 'string', title: 'Request body' },
319
+ ttlSeconds: {
320
+ type: 'number',
321
+ title: 'TTL (seconds)',
322
+ description: 'How long a fetched token is reused before re-fetching.',
323
+ minimum: 1
324
+ }
325
+ }
326
+ },
327
+ tile: {
328
+ type: 'object',
329
+ title: 'Tile request template',
330
+ required: ['url'],
331
+ properties: {
332
+ url: {
333
+ type: 'string',
334
+ title: 'URL template',
335
+ description: 'Tile URL with {z}/{x}/{y}, {token.<field>}, and {a-b}.'
336
+ },
337
+ headers: {
338
+ type: 'object',
339
+ title: 'Request headers',
340
+ description: 'Header template (values may reference {token.<field>}).',
341
+ additionalProperties: { type: 'string' }
342
+ }
343
+ }
344
+ }
345
+ }
346
+ }
215
347
  }
216
348
  }
217
349
  });
@@ -230,10 +362,20 @@ const createPlugin = (app) => {
230
362
  // (config reload, server shutdown, error cascade) is visible in logs
231
363
  // instead of silently emptying chartProviders.
232
364
  console.log(`Signal K Charts: stop() called (${Object.keys(chartProviders).length} provider(s) will be released)`);
365
+ pluginActive = false;
233
366
  stopWatchers();
234
367
  // Cancel any running seeding jobs so a disabled plugin doesn't keep
235
368
  // pulling tiles from remote providers in the background.
236
369
  chartDownloader_1.ChartSeedingManager.cancelAll();
370
+ // Cancel any in-flight legacy migration. Without this, the walk
371
+ // keeps calling putTile against a handle we're about to close —
372
+ // every call errors-and-counts-as-failed for thousands of remaining
373
+ // tiles, and a quick restart can race the orphan loop against a
374
+ // freshly opened connection on the same file.
375
+ for (const status of migrationStatuses.values()) {
376
+ if (status.state === 'running')
377
+ status.controller?.abort();
378
+ }
237
379
  // Close open SQLite connections so the user can move or delete chart
238
380
  // files while the plugin is stopped (Windows blocks deletion on open
239
381
  // handles). Restart re-opens fresh via findCharts.
@@ -244,6 +386,10 @@ const createPlugin = (app) => {
244
386
  chartProviders = {};
245
387
  sanitizedV1 = {};
246
388
  sanitizedV2 = {};
389
+ // Signal K unregisters a plugin's resource providers when it stops and
390
+ // may hand the `charts` default to another plugin. Re-register on the
391
+ // next start; the resource registry replaces by plugin id.
392
+ providerRegistered = false;
247
393
  app.setPluginStatus('stopped');
248
394
  }
249
395
  };
@@ -272,6 +418,10 @@ const createPlugin = (app) => {
272
418
  if (cachePath !== defaultChartsPath) {
273
419
  await ensureDirectoryExists(cachePath);
274
420
  }
421
+ // Migrate any flat-layout proxy / export files into the .working/
422
+ // and exports/ subdirs the scanner expects. Idempotent and
423
+ // failure-tolerant: a file we can't move is logged and the rest carry on.
424
+ await (0, cacheLayout_1.migrateLegacyCacheLayout)(cachePath, app);
275
425
  activeOnlineProviders = {};
276
426
  for (const data of props.onlineChartProviders ?? []) {
277
427
  const provider = convertOnlineProviderConfig(data);
@@ -282,13 +432,33 @@ const createPlugin = (app) => {
282
432
  }
283
433
  activeOnlineProviders[provider.identifier] = provider;
284
434
  }
285
- app.debug(`Start charts plugin. Chart paths: ${activeChartPaths.join(', ')}, online charts: ${Object.keys(activeOnlineProviders).length}`);
435
+ // Build token providers. A bad config entry shouldn't take the whole
436
+ // plugin out — log and skip the offender so the rest still load.
437
+ activeTokenProviders = {};
438
+ for (let i = 0; i < (props.tokenProviders ?? []).length; i++) {
439
+ const raw = (props.tokenProviders ?? [])[i];
440
+ try {
441
+ const cfg = (0, tokenProvider_1.validateTokenProviderConfig)(raw, i);
442
+ const provider = (0, tokenProvider_1.chartProviderFromTokenConfig)(cfg);
443
+ if (activeTokenProviders[provider.identifier]) {
444
+ app.debug(`Duplicate token provider identifier "${provider.identifier}"; ` +
445
+ `the later entry wins. Rename one to avoid the collision.`);
446
+ }
447
+ activeTokenProviders[provider.identifier] = provider;
448
+ }
449
+ catch (err) {
450
+ console.warn(`Skipping ${err.message}`);
451
+ }
452
+ }
453
+ app.debug(`Start charts plugin. Chart paths: ${activeChartPaths.join(', ')}, online charts: ${Object.keys(activeOnlineProviders).length}, ` +
454
+ `token providers: ${Object.keys(activeTokenProviders).length}`);
286
455
  // Routes and the v2 provider registration are idempotent — Signal K can
287
456
  // call start() again after a config change, but re-registering would
288
457
  // either throw or duplicate the handler.
289
458
  if (!pluginStarted)
290
459
  registerRoutes();
291
460
  pluginStarted = true;
461
+ pluginActive = true;
292
462
  if (serverMajorVersion === 2 && !providerRegistered) {
293
463
  app.debug('** Registering v2 API paths **');
294
464
  registerAsProvider();
@@ -357,17 +527,51 @@ const createPlugin = (app) => {
357
527
  app.setPluginStatus((0, pluginStatus_1.composeStatus)(perPath, Object.keys(activeOnlineProviders).length));
358
528
  return;
359
529
  }
360
- reconcileMbtilesHandles(chartProviders);
361
- // Shallow assign is enough: newCharts and activeOnlineProviders both
362
- // have unique ids per entry; the values themselves are used by reference.
363
- chartProviders = { ...newCharts };
530
+ // Build the next chartProviders dict locally first so request handlers
531
+ // never see a half-populated state. Atomic publish at the end of the
532
+ // function keeps `_mbtilesHandle` invariants intact: a request reading
533
+ // chartProviders[id] for a proxy provider should always find a handle
534
+ // open, never a transient null between the dict-swap and the handle-open.
535
+ const nextChartProviders = {
536
+ ...newCharts
537
+ };
364
538
  for (const [id, provider] of Object.entries(activeOnlineProviders)) {
365
- if (chartProviders[id]) {
539
+ if (nextChartProviders[id]) {
366
540
  app.debug(`Online provider identifier "${id}" collides with a local chart; ` +
367
541
  `the online provider wins.`);
368
542
  }
369
- chartProviders[id] = provider;
543
+ nextChartProviders[id] = provider;
370
544
  }
545
+ // Token providers (declarative token-rotating configs) merge in last
546
+ // and win over a colliding online or local entry: the admin took the
547
+ // explicit step of configuring one, so a stale auto-discovered chart
548
+ // shouldn't shadow it.
549
+ for (const [id, provider] of Object.entries(activeTokenProviders)) {
550
+ if (nextChartProviders[id]) {
551
+ app.debug(`Token provider identifier "${id}" collides with another ` +
552
+ `provider; the token provider wins.`);
553
+ }
554
+ nextChartProviders[id] = provider;
555
+ }
556
+ // Open / create the working mbtiles cache for every proxy provider.
557
+ // Path lives under `.working/<id>/cache.mbtiles` (excluded from the
558
+ // scanner so it isn't read-opened concurrently with our writes).
559
+ // Failure to open doesn't take the provider out — tile requests will
560
+ // just always go remote.
561
+ for (const provider of Object.values(nextChartProviders)) {
562
+ if (provider.proxy === true) {
563
+ try {
564
+ provider._mbtilesHandle = await (0, chartDownloaderMBTilesHelpers_1.openOrCreateMbtiles)((0, cacheLayout_1.workingMbtilesPath)(cachePath, provider.identifier), provider);
565
+ }
566
+ catch (err) {
567
+ app.debug(`Could not open mbtiles cache for ${provider.identifier}: ${err.message}`);
568
+ }
569
+ }
570
+ }
571
+ // Now publish: close handles on the previous set we're replacing, then
572
+ // swap in the fully-populated next set in one assignment.
573
+ reconcileMbtilesHandles(chartProviders);
574
+ chartProviders = nextChartProviders;
371
575
  buildSanitizedCache();
372
576
  app.setPluginStatus((0, pluginStatus_1.composeStatus)(perPath, Object.keys(activeOnlineProviders).length));
373
577
  };
@@ -392,6 +596,13 @@ const createPlugin = (app) => {
392
596
  const MBTILES_CLOSE_DELAY_MS = 1000;
393
597
  const reconcileMbtilesHandles = (oldSet) => {
394
598
  for (const old of Object.values(oldSet)) {
599
+ // Skip providers that were only in the old set if they were proxy providers
600
+ // we want to keep serving tiles from their existing mbtiles handle until
601
+ // the new one is ready, and a missing handle means the new config failed
602
+ // to open the file, so there's no new handle to wait for.
603
+ if (old.proxy === true) {
604
+ continue;
605
+ }
395
606
  if (old?._mbtilesHandle) {
396
607
  const handle = old._mbtilesHandle;
397
608
  setTimeout(() => closeMbtilesHandle(handle), MBTILES_CLOSE_DELAY_MS);
@@ -414,14 +625,37 @@ const createPlugin = (app) => {
414
625
  // Chart folders are watched so new/renamed/deleted files become visible
415
626
  // without a plugin restart. FSWatcher bursts events during rename and
416
627
  // atomic-save sequences; the debounce collapses a burst into one reload.
628
+ // Set while loadChartProviders is running so a watcher event during a
629
+ // reload doesn't kick off a parallel one. Combined with the debounce
630
+ // and the pluginActive check, this means at most one
631
+ // loadChartProviders is in-flight at a time.
632
+ let loadInFlight = false;
417
633
  const scheduleReload = () => {
418
634
  if (reloadTimer)
419
635
  clearTimeout(reloadTimer);
420
636
  reloadTimer = setTimeout(() => {
421
637
  reloadTimer = undefined;
638
+ // Guards against the stop()-during-debounce race: a watcher event
639
+ // queues a reload, then the admin disables the plugin during the
640
+ // debounce window. Without this check the reload fires after stop()
641
+ // has cleared chartProviders, opens fresh mbtiles handles, and the
642
+ // next stop() doesn't know about them.
643
+ if (!pluginActive)
644
+ return;
645
+ if (loadInFlight) {
646
+ // Reschedule rather than drop: the in-flight reload may have
647
+ // captured pre-event state.
648
+ scheduleReload();
649
+ return;
650
+ }
422
651
  app.debug('Reloading charts after filesystem change');
423
- loadChartProviders();
424
- }, RELOAD_DEBOUNCE_MS);
652
+ loadInFlight = true;
653
+ loadChartProviders()
654
+ .catch((e) => console.warn(`[charts-plugin] reload failed: ${e.message}`))
655
+ .finally(() => {
656
+ loadInFlight = false;
657
+ });
658
+ }, reloadDebounceMs());
425
659
  };
426
660
  const startWatchers = () => {
427
661
  stopWatchers();
@@ -496,28 +730,236 @@ const createPlugin = (app) => {
496
730
  }
497
731
  }
498
732
  });
733
+ // Hard cap on the persisted regions.json size. The webapp posts the
734
+ // full FeatureCollection on every save; allowing arbitrary growth
735
+ // would let an admin (or a misbehaving client) fill the data dir.
736
+ const MAX_REGIONS_BYTES = 1024 * 1024; // 1 MB; thousands of detailed polygons
737
+ app.post(`${chartTilesPath}/cache/regions`, async (req, res) => {
738
+ // Validate the body shape: we expect a GeoJSON FeatureCollection.
739
+ // Without this, anything JSON-shaped lands in regions.json and
740
+ // breaks the GET handler / the webapp on next read.
741
+ const body = req.body;
742
+ if (typeof body !== 'object' ||
743
+ body === null ||
744
+ body.type !== 'FeatureCollection' ||
745
+ !Array.isArray(body.features)) {
746
+ return res
747
+ .status(400)
748
+ .send('expected a GeoJSON FeatureCollection with a features array');
749
+ }
750
+ const serialised = JSON.stringify(body, null, 2);
751
+ if (Buffer.byteLength(serialised, 'utf8') > MAX_REGIONS_BYTES) {
752
+ return res
753
+ .status(413)
754
+ .send(`regions payload exceeds ${MAX_REGIONS_BYTES} bytes`);
755
+ }
756
+ // Async write so a slow SD card on a Pi doesn't stall the event
757
+ // loop, and a try/catch so an EACCES / ENOSPC produces a useful
758
+ // 500 instead of an uncaught throw.
759
+ const p = path_1.default.join(app.getDataDirPath(), 'regions.json');
760
+ try {
761
+ await fs_1.default.promises.writeFile(p, serialised, 'utf8');
762
+ }
763
+ catch (err) {
764
+ console.warn(`[charts-plugin] failed to write ${p}: ${err.message}`);
765
+ return res
766
+ .status(500)
767
+ .send(`failed to persist regions: ${err.message}`);
768
+ }
769
+ return res.send({ status: 'ok' });
770
+ });
771
+ app.get(`${chartTilesPath}/cache/regions`, async (_req, res) => {
772
+ const p = path_1.default.join(app.getDataDirPath(), 'regions.json');
773
+ try {
774
+ const raw = await fs_1.default.promises.readFile(p, 'utf8');
775
+ const data = JSON.parse(raw);
776
+ return res.json(data);
777
+ }
778
+ catch (_e) {
779
+ return res.json({ type: 'FeatureCollection', features: [] });
780
+ }
781
+ });
782
+ app.get(`${chartTilesPath}/cache/stats`, (_req, res) => {
783
+ const data = chartDownloader_1.ChartDownloader.getStatistics();
784
+ return res.json(data);
785
+ });
786
+ // Migrate a per-file PNG cache (`<sourceName>/<z>/<x>/<y>.<ext>`) into
787
+ // the provider's working mbtiles. Body fields (all optional):
788
+ // sourceName legacy directory name (default: provider.name)
789
+ // deleteSource remove tile files after successful insert
790
+ // Returns 202 immediately and runs the walk in the background — large
791
+ // caches (>100k tiles) can take minutes and would time out a sync
792
+ // response. State is recorded in `migrationStatuses` so the admin can
793
+ // poll GET /cache/:id/migrate for progress and the final counts. The
794
+ // migration itself is idempotent so re-running is safe.
795
+ app.post(`${chartTilesPath}/cache/:identifier/migrate`, async (req, res) => {
796
+ const { identifier } = req.params;
797
+ const provider = chartProviders[identifier];
798
+ if (!provider)
799
+ return res.status(404).send('Provider not found');
800
+ if (!provider._mbtilesHandle) {
801
+ return res
802
+ .status(400)
803
+ .send(`Provider "${identifier}" has no working mbtiles cache. ` +
804
+ `Configure proxy:true and ensure cachePath is writable.`);
805
+ }
806
+ const existing = migrationStatuses.get(identifier);
807
+ if (existing && existing.state === 'running') {
808
+ return res
809
+ .status(409)
810
+ .send(`Migration for "${identifier}" is already running. ` +
811
+ `Wait for it to complete or check GET /cache/${identifier}/migrate.`);
812
+ }
813
+ // Cooldown after completion: a flapping admin client polling
814
+ // GET .../migrate and re-firing on completion would otherwise
815
+ // walk the directory continuously.
816
+ if (existing &&
817
+ existing.state !== 'running' &&
818
+ existing.completedAt &&
819
+ Date.now() - existing.completedAt < MIGRATION_COOLDOWN_MS) {
820
+ return res
821
+ .status(429)
822
+ .header('Retry-After', String(Math.ceil((MIGRATION_COOLDOWN_MS -
823
+ (Date.now() - existing.completedAt)) /
824
+ 1000)))
825
+ .send(`Migration for "${identifier}" recently ${existing.state}; ` +
826
+ `wait ${Math.ceil(MIGRATION_COOLDOWN_MS / 1000)}s before re-running. ` +
827
+ `Previous result: ${JSON.stringify(existing.counts)}`);
828
+ }
829
+ const body = (req.body ?? {});
830
+ const sourceName = typeof body.sourceName === 'string' && body.sourceName.length > 0
831
+ ? body.sourceName
832
+ : provider.name;
833
+ const legacyDir = path_1.default.resolve(cachePath, sourceName);
834
+ // path.resolve guards against `../` escape, but a naive prefix
835
+ // startsWith check has a false-negative when a sibling directory
836
+ // happens to share the cachePath prefix (e.g. cachePath
837
+ // `/var/lib/sk/charts` + sourceName `../charts-shared` resolves
838
+ // to `/var/lib/sk/charts-shared` which startsWith `/var/lib/sk/charts`).
839
+ // Require the resolved path to equal cachePath OR start with
840
+ // `cachePath + sep`.
841
+ const cacheBase = path_1.default.resolve(cachePath);
842
+ if (legacyDir !== cacheBase &&
843
+ !legacyDir.startsWith(cacheBase + path_1.default.sep)) {
844
+ return res.status(400).send('sourceName must not escape cachePath');
845
+ }
846
+ const handle = provider._mbtilesHandle;
847
+ const deleteSource = body.deleteSource === true;
848
+ // The default cachePath IS the chart-scan path, and a directory-format
849
+ // chart is stored as the same <z>/<x>/<y>.<fmt> tree this migration
850
+ // walks (and, with deleteSource, unlinks). Without this guard a request
851
+ // like { sourceName: "<installed-chart-dir>", deleteSource: true }
852
+ // would drain and delete a user's installed chart. Refuse when the
853
+ // source resolves onto an installed chart.
854
+ const installedChart = resolveInstalledChartCollision(legacyDir);
855
+ if (installedChart) {
856
+ return res
857
+ .status(409)
858
+ .send(`sourceName "${sourceName}" resolves onto installed chart data ` +
859
+ `(${installedChart}). Refusing to migrate it as a legacy cache.`);
860
+ }
861
+ const controller = new AbortController();
862
+ const status = {
863
+ state: 'running',
864
+ startedAt: Date.now(),
865
+ legacyDir,
866
+ deleteSource,
867
+ counts: { migrated: 0, skipped: 0, failed: 0 },
868
+ controller
869
+ };
870
+ migrationStatuses.set(identifier, status);
871
+ console.log(`[charts-plugin] migration for "${identifier}" started ` +
872
+ `legacyDir=${legacyDir} deleteSource=${deleteSource}`);
873
+ // Fire and forget: log on completion / failure and update the
874
+ // shared status entry so GET ../migrate can surface progress.
875
+ // The onProgress callback updates counts every 500 processed
876
+ // entries, giving the admin a way to confirm forward progress
877
+ // without grepping logs.
878
+ const promise = (0, cacheLayout_1.migrateLegacyTileCache)(legacyDir, handle, {
879
+ deleteSource,
880
+ signal: controller.signal,
881
+ onProgress: (counts) => {
882
+ status.counts = { ...counts };
883
+ // Surface progress in the SignalK plugin list so an admin
884
+ // who started a multi-hour migration before bed can see it
885
+ // running on the dashboard without grepping logs.
886
+ const total = counts.migrated + counts.skipped + counts.failed;
887
+ app.setPluginStatus(`Migrating "${identifier}": ${counts.migrated} migrated, ` +
888
+ `${total} processed`);
889
+ }
890
+ })
891
+ .then((counts) => {
892
+ status.counts = counts;
893
+ status.state = controller.signal.aborted ? 'cancelled' : 'completed';
894
+ status.completedAt = Date.now();
895
+ console.log(`[charts-plugin] migration for "${identifier}" ${status.state}: ` +
896
+ `${counts.migrated} migrated, ${counts.skipped} skipped, ` +
897
+ `${counts.failed} failed`);
898
+ app.setPluginStatus(`Migration for "${identifier}" ${status.state}: ` +
899
+ `${counts.migrated} migrated, ${counts.skipped} skipped, ` +
900
+ `${counts.failed} failed`);
901
+ })
902
+ .catch((err) => {
903
+ status.state = 'failed';
904
+ status.completedAt = Date.now();
905
+ status.error = err.message;
906
+ console.warn(`[charts-plugin] migration for "${identifier}" failed: ${err.message}`);
907
+ app.setPluginStatus(`Migration for "${identifier}" failed: ${err.message}`);
908
+ });
909
+ status.promise = promise;
910
+ return res.status(202).json({
911
+ status: 'started',
912
+ provider: identifier,
913
+ legacyDir,
914
+ deleteSource
915
+ });
916
+ });
917
+ // Poll the state of a previously-started legacy migration. Returns 404
918
+ // if no migration has been kicked off for this provider in this plugin
919
+ // session — the state is plugin-scoped, so a stop()/start() cycle
920
+ // clears it.
921
+ app.get(`${chartTilesPath}/cache/:identifier/migrate`, (req, res) => {
922
+ const { identifier } = req.params;
923
+ const status = migrationStatuses.get(identifier);
924
+ if (!status) {
925
+ return res
926
+ .status(404)
927
+ .send(`No migration recorded for "${identifier}" in this session.`);
928
+ }
929
+ return res.status(200).json({ provider: identifier, ...status });
930
+ });
499
931
  app.post(`${chartTilesPath}/cache/:identifier`, async (req, res) => {
500
932
  const { identifier } = req.params;
501
933
  if (!identifier) {
502
934
  return res.sendStatus(404);
503
935
  }
504
- const { regionGUID, tile, bbox, maxZoom } = req.body;
936
+ const { feature, bbox, minZoom, maxZoom, action, options } = req.body;
505
937
  const provider = chartProviders[identifier];
506
938
  if (!provider) {
507
939
  return res.status(404).send('Provider not found');
508
940
  }
941
+ // Reject providers that can't be tile-seeded (non-proxy, or a style /
942
+ // vector type such as mapstyleJSON) with a clear 400 rather than
943
+ // creating a job that fails every tile. The webapp already hides these
944
+ // from the picker; this guards direct API callers.
945
+ const seedableError = (0, tileServer_1.validateSeedableProvider)(provider);
946
+ if (seedableError) {
947
+ return res.status(400).send(seedableError);
948
+ }
509
949
  if (!maxZoom) {
510
950
  return res.status(400).send('maxZoom parameter is required');
511
951
  }
512
- if (!regionGUID && !bbox && !tile) {
513
- return res
514
- .status(400)
515
- .send('Request must include regionGUID, bbox, or tile');
952
+ if (!feature && !bbox) {
953
+ return res.status(400).send('Request must include feature or bbox');
516
954
  }
955
+ const minZoomParsed = parseInt(minZoom ?? '1');
517
956
  const maxZoomParsed = parseInt(maxZoom);
518
- const zoomError = (0, tileServer_1.validateMaxZoom)(maxZoomParsed);
519
- if (zoomError) {
520
- return res.status(400).send(zoomError);
957
+ const maxZoomError = (0, tileServer_1.validateMaxZoom)(maxZoomParsed);
958
+ if (maxZoomError) {
959
+ return res.status(400).send(maxZoomError);
960
+ }
961
+ if (Number.isFinite(minZoomParsed) && (0, tileServer_1.validateMaxZoom)(minZoomParsed)) {
962
+ return res.status(400).send(`Invalid minZoom ${minZoomParsed}`);
521
963
  }
522
964
  if (bbox) {
523
965
  const bboxError = (0, tileServer_1.validateBBox)(bbox);
@@ -525,16 +967,16 @@ const createPlugin = (app) => {
525
967
  return res.status(400).send(bboxError);
526
968
  }
527
969
  }
528
- if (tile) {
529
- const tileError = (0, tileServer_1.validateTileCoords)(tile.z, tile.x, tile.y);
530
- if (tileError) {
531
- return res.status(400).send(tileError);
532
- }
533
- }
534
970
  try {
535
- const job = await chartDownloader_1.ChartSeedingManager.createJob(app.resourcesApi, cachePath, provider, maxZoomParsed, regionGUID, bbox
971
+ const job = await chartDownloader_1.ChartSeedingManager.createJob(cachePath, provider, options ?? { refetch: false, mbtiles: false, vacuum: false }, minZoomParsed, maxZoomParsed, feature, bbox
536
972
  ? [bbox.minLon, bbox.minLat, bbox.maxLon, bbox.maxLat]
537
- : undefined, tile);
973
+ : undefined);
974
+ if (action === 'start') {
975
+ runJobInBackground(job, job.seedCache());
976
+ }
977
+ else if (action === 'delete') {
978
+ runJobInBackground(job, job.deleteCache());
979
+ }
538
980
  // Job is registered and its tile set is known, but nothing has been
539
981
  // downloaded yet — the caller starts it with
540
982
  // POST /cache/jobs/:id { action: 'start' }.
@@ -570,15 +1012,30 @@ const createPlugin = (app) => {
570
1012
  return res.status(400).send('action parameter is required');
571
1013
  }
572
1014
  if (action === 'start') {
573
- job.seedCache();
1015
+ // Reject restart of jobs that already ran. seedCache itself is a
1016
+ // no-op while Running, but a Completed (or Cancelled) job would
1017
+ // happily reset its counters and run again. The admin should
1018
+ // remove the completed job and create a fresh one to refresh
1019
+ // tiles, so the intent is explicit and doesn't get lost in a
1020
+ // counter reset.
1021
+ if (job.info().status !== 'Idle') {
1022
+ return res
1023
+ .status(409)
1024
+ .send(`Job ${parsedId} cannot be started (status: ${job.info().status}). ` +
1025
+ `Remove and recreate the job to seed again.`);
1026
+ }
1027
+ runJobInBackground(job, job.seedCache());
574
1028
  }
575
1029
  else if (action === 'stop') {
576
1030
  job.cancelJob();
577
1031
  }
578
1032
  else if (action === 'delete') {
579
- job.deleteCache();
1033
+ runJobInBackground(job, job.deleteCache());
580
1034
  }
581
1035
  else if (action === 'remove') {
1036
+ // Cancel first so an in-flight seed doesn't mutate the entry
1037
+ // after we drop it from the registry.
1038
+ job.cancelJob();
582
1039
  delete chartDownloader_1.ChartSeedingManager.ActiveJobs[parsedId];
583
1040
  }
584
1041
  else {
@@ -645,6 +1102,37 @@ const resolveUniqueChartPaths = (chartPaths, configBasePath) => {
645
1102
  const paths = chartPaths.map((chartPath) => path_1.default.resolve(configBasePath, chartPath));
646
1103
  return [...new Set(paths)];
647
1104
  };
1105
+ // Returns the candidate path if it is an installed directory-format chart, or
1106
+ // null if it is safe to migrate as a legacy proxy cache. The discriminator is
1107
+ // the same marker the scanner uses (tilemapresource.xml / metadata.json): a
1108
+ // legacy PNG cache is a bare <z>/<x>/<y> tree with no marker, whereas an
1109
+ // installed TMS/XYZ chart has one. This stops the legacy-cache migration —
1110
+ // which walks and (with deleteSource) unlinks a <z>/<x>/<y> tree — from
1111
+ // cannibalising a user's installed chart, a real risk because the default
1112
+ // cachePath and the default chart path are the same directory and a directory
1113
+ // chart has the identical on-disk tile layout.
1114
+ const INSTALLED_CHART_MARKERS = ['tilemapresource.xml', 'metadata.json'];
1115
+ // seedCache() and deleteCache() are fire-and-forget: the HTTP handler returns
1116
+ // 202 immediately and the work continues in the background. Both methods record
1117
+ // a failure on the job and reset its state internally, but the returned promise
1118
+ // still rejects on error — and an un-awaited rejected promise is an unhandled
1119
+ // rejection, which on Node 22+ terminates the whole Signal K server. This
1120
+ // attaches the terminal .catch() so a failed seed/delete degrades the job, not
1121
+ // the process.
1122
+ const runJobInBackground = (job, work) => {
1123
+ work.catch((err) => {
1124
+ console.error(`[charts-plugin] background job ${job.info().id} failed: ${err.message}`);
1125
+ });
1126
+ };
1127
+ const resolveInstalledChartCollision = (candidate) => {
1128
+ const resolved = path_1.default.resolve(candidate);
1129
+ for (const marker of INSTALLED_CHART_MARKERS) {
1130
+ if (fs_1.default.existsSync(path_1.default.join(resolved, marker))) {
1131
+ return resolved;
1132
+ }
1133
+ }
1134
+ return null;
1135
+ };
648
1136
  const convertOnlineProviderConfig = (provider) => {
649
1137
  const id = _.kebabCase(_.deburr(provider.name));
650
1138
  const parseHeaders = (arr) => {
@@ -693,14 +1181,43 @@ const convertOnlineProviderConfig = (provider) => {
693
1181
  // Builds the outward-facing view of a provider for either the v1 or v2 API.
694
1182
  // Copies non-private top-level fields, then overlays the version-specific
695
1183
  // block (v1 has tilemapUrl/chartLayers, v2 has url/layers), rewriting the
696
- // tile-path placeholder. Intentionally a single shallow walk — the previous
697
- // implementation did three deep clones per call and ran per metadata request.
1184
+ // tile-path placeholder. Intentionally a single shallow walk to keep
1185
+ // per-metadata-request cost bounded.
1186
+ //
1187
+ // `name` and `description` go through stripHtml first: the webapp's
1188
+ // Leaflet L.Control.Layers writes them into innerHTML, so a malicious
1189
+ // .mbtiles shipping a metadata.name like
1190
+ // `<img src=x onerror="..."> ` would execute in the admin's session.
1191
+ // Server-side sanitisation lets the (vendored) UI keep its rendering
1192
+ // path without per-call escaping.
1193
+ const stripHtml = (s) => s
1194
+ // Drop tags entirely. We don't try to preserve any HTML — this is
1195
+ // chart metadata from a SQLite blob, not rich text.
1196
+ .replace(/<[^>]*>/g, '')
1197
+ // Belt-and-braces: collapse any standalone & < > so an unbalanced
1198
+ // input doesn't produce something the browser still treats as markup.
1199
+ .replace(/[<>]/g, '');
698
1200
  const sanitizeProvider = (provider, version = 1) => {
699
1201
  const out = {};
700
1202
  for (const [key, value] of Object.entries(provider)) {
701
1203
  if (key.startsWith('_') || key === 'v1' || key === 'v2')
702
1204
  continue;
703
- out[key] = value;
1205
+ // remoteUrl / headers are the upstream proxy target and its request
1206
+ // headers — for token providers these are getters that resolve a live
1207
+ // bearer token. Clients fetch tiles through the proxy path (~tilePath~),
1208
+ // never the upstream directly, so these must not appear in the public
1209
+ // resources view; emitting them would leak the rotating credential.
1210
+ if (key === 'remoteUrl' || key === 'headers')
1211
+ continue;
1212
+ // Defang any string field that the webapp might later render via
1213
+ // innerHTML (Leaflet L.Control.Layers, custom tooltips, etc).
1214
+ if ((key === 'name' || key === 'description') &&
1215
+ typeof value === 'string') {
1216
+ out[key] = stripHtml(value);
1217
+ }
1218
+ else {
1219
+ out[key] = value;
1220
+ }
704
1221
  }
705
1222
  const v = version === 1 ? provider.v1 : provider.v2;
706
1223
  if (v) {