@signalk/charts-plugin 3.2.0 → 3.5.0-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/plugin/index.js CHANGED
@@ -15,18 +15,26 @@ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (
15
15
  }) : function(o, v) {
16
16
  o["default"] = v;
17
17
  });
18
- var __importStar = (this && this.__importStar) || function (mod) {
19
- if (mod && mod.__esModule) return mod;
20
- var result = {};
21
- if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
- __setModuleDefault(result, mod);
23
- return result;
24
- };
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
25
35
  var __importDefault = (this && this.__importDefault) || function (mod) {
26
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
27
37
  };
28
- Object.defineProperty(exports, "__esModule", { value: true });
29
- const bluebird = __importStar(require("bluebird"));
30
38
  const path_1 = __importDefault(require("path"));
31
39
  const fs_1 = __importDefault(require("fs"));
32
40
  const _ = __importStar(require("lodash"));
@@ -36,25 +44,54 @@ const chartDownloader_1 = require("./chartDownloader");
36
44
  const MIN_ZOOM = 1;
37
45
  const MAX_ZOOM = 24;
38
46
  const chartTilesPath = '/signalk/chart-tiles';
39
- module.exports = (app) => {
47
+ // Debounce window used to collapse FSWatcher bursts during rename / atomic-save
48
+ // 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;
51
+ const createPlugin = (app) => {
40
52
  let chartProviders = {};
53
+ // Pre-computed per-version views of chartProviders, rebuilt on every reload.
54
+ // The HTTP handlers serve directly from here so tile-list requests don't pay
55
+ // a deep clone per provider on the hot path.
56
+ let sanitizedV1 = {};
57
+ let sanitizedV2 = {};
41
58
  let pluginStarted = false;
59
+ let providerRegistered = false;
42
60
  let props = {
43
61
  chartPaths: [],
62
+ cachePath: '',
44
63
  onlineChartProviders: []
45
64
  };
46
65
  let urlBase = '';
47
66
  const configBasePath = app.config.configPath;
48
67
  const defaultChartsPath = path_1.default.join(configBasePath, '/charts');
49
68
  const serverMajorVersion = app.config.version
50
- ? parseInt(app.config.version.split('.')[0])
51
- : '1';
52
- ensureDirectoryExists(defaultChartsPath);
69
+ ? parseInt(app.config.version.split('.')[0] ?? '0')
70
+ : 1;
71
+ let cachePath = defaultChartsPath;
72
+ // Chart-folder watcher state, plugin-scoped so stop()/start() cycles reset cleanly.
73
+ // activeChartPaths / activeOnlineProviders hold the last-known config so a
74
+ // watcher-triggered reload can reuse it without re-running doStartup.
75
+ const watchers = [];
76
+ let reloadTimer;
77
+ let activeChartPaths = [];
78
+ let activeOnlineProviders = {};
79
+ // Check Node version for schema
80
+ const nodeVersion = process.versions.node;
81
+ const nodeMajorVersion = parseInt(nodeVersion.split('.')[0] ?? '0');
53
82
  // ******** REQUIRED PLUGIN DEFINITION *******
54
83
  const CONFIG_SCHEMA = {
55
84
  title: 'Signal K Charts',
56
85
  type: 'object',
57
86
  properties: {
87
+ ...(nodeMajorVersion < 22 && {
88
+ versionWarning: {
89
+ type: 'string',
90
+ title: 'REQUIRES NODE VERSION >=22',
91
+ description: 'Starting with version 4 this plugin will not work with Node versions older than 22. You can install an older plugin version from the App store.',
92
+ default: ''
93
+ }
94
+ }),
58
95
  chartPaths: {
59
96
  type: 'array',
60
97
  title: 'Chart paths',
@@ -65,6 +102,11 @@ module.exports = (app) => {
65
102
  description: `Path for chart files, relative to "${configBasePath}"`
66
103
  }
67
104
  },
105
+ cachePath: {
106
+ type: 'string',
107
+ title: 'Cache path',
108
+ description: `Directory for cached tiles. Defaults to "${defaultChartsPath}"`
109
+ },
68
110
  onlineChartProviders: {
69
111
  type: 'array',
70
112
  title: 'Online chart providers',
@@ -168,50 +210,220 @@ module.exports = (app) => {
168
210
  return doStartup(settings); // return required for tests
169
211
  },
170
212
  stop: () => {
213
+ stopWatchers();
214
+ // Cancel any running seeding jobs so a disabled plugin doesn't keep
215
+ // pulling tiles from remote providers in the background.
216
+ chartDownloader_1.ChartSeedingManager.cancelAll();
217
+ // Close open SQLite connections so the user can move or delete chart
218
+ // files while the plugin is stopped (Windows blocks deletion on open
219
+ // handles). Restart re-opens fresh via findCharts.
220
+ for (const p of Object.values(chartProviders)) {
221
+ if (p?._mbtilesHandle)
222
+ closeMbtilesHandle(p._mbtilesHandle);
223
+ }
224
+ chartProviders = {};
225
+ sanitizedV1 = {};
226
+ sanitizedV2 = {};
171
227
  app.setPluginStatus('stopped');
172
228
  }
173
229
  };
174
- const doStartup = (config) => {
230
+ const doStartup = async (config) => {
231
+ // Check Node version
232
+ const nodeVersion = process.versions.node;
233
+ const majorVersion = parseInt(nodeVersion.split('.')[0] ?? '0');
234
+ if (majorVersion < 22) {
235
+ const errorMsg = `Node version ${nodeVersion} is not supported. This plugin requires Node version 22 or higher. Please upgrade Node or install an older plugin version.`;
236
+ app.setPluginError(errorMsg);
237
+ app.debug(errorMsg);
238
+ return Promise.reject(new Error(errorMsg));
239
+ }
175
240
  app.debug(`** loaded config: ${config}`);
176
241
  props = { ...config };
177
242
  urlBase = `${app.config.ssl ? 'https' : 'http'}://localhost:${'getExternalPort' in app.config ? app.config.getExternalPort() : 3000}`;
178
243
  app.debug(`**urlBase** ${urlBase}`);
179
- const chartPaths = _.isEmpty(props.chartPaths)
244
+ activeChartPaths = !props.chartPaths?.length
180
245
  ? [defaultChartsPath]
181
246
  : resolveUniqueChartPaths(props.chartPaths, configBasePath);
182
- const onlineProviders = _.reduce(props.onlineChartProviders, (result, data) => {
247
+ cachePath = props.cachePath || defaultChartsPath;
248
+ // Both paths commonly coincide on a fresh install; ensure they exist once
249
+ // here rather than at plugin construction time, which kept us off the
250
+ // sync-fs path at module load.
251
+ await ensureDirectoryExists(defaultChartsPath);
252
+ if (cachePath !== defaultChartsPath) {
253
+ await ensureDirectoryExists(cachePath);
254
+ }
255
+ activeOnlineProviders = {};
256
+ for (const data of props.onlineChartProviders ?? []) {
183
257
  const provider = convertOnlineProviderConfig(data);
184
- result[provider.identifier] = provider;
185
- return result;
186
- }, {});
187
- app.debug(`Start charts plugin. Chart paths: ${chartPaths.join(', ')}, online charts: ${Object.keys(onlineProviders).length}`);
188
- // Do not register routes if plugin has been started once already
189
- pluginStarted === false && registerRoutes();
258
+ if (activeOnlineProviders[provider.identifier]) {
259
+ app.debug(`Duplicate online provider identifier "${provider.identifier}" ` +
260
+ `(from name "${data.name}"); the later entry wins. ` +
261
+ `Rename one of the providers to avoid the collision.`);
262
+ }
263
+ activeOnlineProviders[provider.identifier] = provider;
264
+ }
265
+ app.debug(`Start charts plugin. Chart paths: ${activeChartPaths.join(', ')}, online charts: ${Object.keys(activeOnlineProviders).length}`);
266
+ // Routes and the v2 provider registration are idempotent — Signal K can
267
+ // call start() again after a config change, but re-registering would
268
+ // either throw or duplicate the handler.
269
+ if (!pluginStarted)
270
+ registerRoutes();
190
271
  pluginStarted = true;
191
- // v2 routes - register as Resource Provider, this needs to be always on startup
192
- if (serverMajorVersion === 2) {
272
+ if (serverMajorVersion === 2 && !providerRegistered) {
193
273
  app.debug('** Registering v2 API paths **');
194
274
  registerAsProvider();
275
+ providerRegistered = true;
195
276
  }
196
277
  app.setPluginStatus('Started');
197
- const loadProviders = bluebird
198
- .mapSeries(chartPaths, (chartPath) => (0, charts_1.findCharts)(chartPath))
199
- .then((list) => _.reduce(list, (result, charts) => _.merge({}, result, charts), {}));
200
- return loadProviders
201
- .then((charts) => {
202
- app.debug(`Chart plugin: Found ${_.keys(charts).length} charts from ${chartPaths.join(', ')}.`);
203
- chartProviders = _.merge({}, charts, onlineProviders);
204
- })
205
- .catch((e) => {
278
+ startWatchers();
279
+ return loadChartProviders();
280
+ };
281
+ const loadChartProviders = async () => {
282
+ // Scan configured chart paths in parallel. findCharts already bounds its
283
+ // own per-file concurrency internally, so kicking off multiple roots at
284
+ // once just overlaps their directory reads.
285
+ let results;
286
+ try {
287
+ results = await Promise.all(activeChartPaths.map((chartPath) => (0, charts_1.findCharts)(chartPath)));
288
+ }
289
+ catch (e) {
290
+ // Keep the last-good chartProviders instead of wiping everything - a
291
+ // transient read error (file locked during copy, EBUSY, etc.) shouldn't
292
+ // blank the service out until the next filesystem event.
206
293
  console.error(`Error loading chart providers`, e.message);
207
- chartProviders = {};
208
294
  app.setPluginError(`Error loading chart providers`);
209
- });
295
+ return;
296
+ }
297
+ // Identifier is the source of uniqueness: a deep-merge here would cost
298
+ // O(N²) property copies for identical keys while adding nothing over a
299
+ // plain shallow assignment.
300
+ const newCharts = {};
301
+ for (const r of results) {
302
+ if (!r)
303
+ continue;
304
+ for (const [id, chart] of Object.entries(r)) {
305
+ if (newCharts[id]) {
306
+ app.debug(`Duplicate chart identifier "${id}" from multiple chart paths; ` +
307
+ `the later one wins.`);
308
+ }
309
+ newCharts[id] = chart;
310
+ }
311
+ }
312
+ app.debug(`Chart plugin: Found ${Object.keys(newCharts).length} charts from ${activeChartPaths.join(', ')}.`);
313
+ reconcileMbtilesHandles(chartProviders);
314
+ // Shallow assign is enough: newCharts and activeOnlineProviders both
315
+ // have unique ids per entry; the values themselves are used by reference.
316
+ chartProviders = { ...newCharts };
317
+ for (const [id, provider] of Object.entries(activeOnlineProviders)) {
318
+ if (chartProviders[id]) {
319
+ app.debug(`Online provider identifier "${id}" collides with a local chart; ` +
320
+ `the online provider wins.`);
321
+ }
322
+ chartProviders[id] = provider;
323
+ }
324
+ buildSanitizedCache();
325
+ app.setPluginStatus(`Started - ${Object.keys(chartProviders).length} chart(s) loaded`);
326
+ };
327
+ // Rebuilds the per-version sanitized views. Called once per reload so the
328
+ // HTTP handlers can hand out pre-built dictionaries instead of deep-cloning
329
+ // every provider on every metadata request.
330
+ const buildSanitizedCache = () => {
331
+ sanitizedV1 = {};
332
+ sanitizedV2 = {};
333
+ for (const [id, provider] of Object.entries(chartProviders)) {
334
+ sanitizedV1[id] = sanitizeProvider(provider, 1);
335
+ sanitizedV2[id] = sanitizeProvider(provider, 2);
336
+ }
337
+ };
338
+ // Close every old MBTiles handle after a reload. findCharts opened fresh
339
+ // handles for every file that still exists, so the NEW set reflects current
340
+ // content — including files that were replaced in place (same filename,
341
+ // new content). Reusing the old handle in that case would serve stale tiles
342
+ // from SQLite's cached pages. Close is delayed so an in-flight tile request
343
+ // that captured a reference has time to complete before the handle goes
344
+ // away; 1s is well above realistic tile-serve latency.
345
+ const MBTILES_CLOSE_DELAY_MS = 1000;
346
+ const reconcileMbtilesHandles = (oldSet) => {
347
+ for (const old of Object.values(oldSet)) {
348
+ if (old?._mbtilesHandle) {
349
+ const handle = old._mbtilesHandle;
350
+ setTimeout(() => closeMbtilesHandle(handle), MBTILES_CLOSE_DELAY_MS);
351
+ }
352
+ }
353
+ };
354
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
355
+ const closeMbtilesHandle = (handle) => {
356
+ if (typeof handle?.close !== 'function')
357
+ return;
358
+ try {
359
+ handle.close((err) => {
360
+ if (err)
361
+ app.debug(`MBTiles close error: ${err.message}`);
362
+ });
363
+ }
364
+ catch (err) {
365
+ app.debug(`MBTiles close threw: ${err.message}`);
366
+ }
367
+ };
368
+ // Chart folders are watched so new/renamed/deleted files become visible
369
+ // without a plugin restart. FSWatcher bursts events during rename and
370
+ // atomic-save sequences; the debounce collapses a burst into one reload.
371
+ const scheduleReload = () => {
372
+ if (reloadTimer)
373
+ clearTimeout(reloadTimer);
374
+ reloadTimer = setTimeout(() => {
375
+ reloadTimer = undefined;
376
+ app.debug('Reloading charts after filesystem change');
377
+ loadChartProviders();
378
+ }, RELOAD_DEBOUNCE_MS);
379
+ };
380
+ const startWatchers = () => {
381
+ stopWatchers();
382
+ for (const p of activeChartPaths) {
383
+ watchers.push(...createWatchers(p));
384
+ }
385
+ };
386
+ // recursive:true is the common path on macOS / Windows / Linux (Node 22+).
387
+ // If the platform or filesystem doesn't support it (some network mounts,
388
+ // older Linux), fall back to a non-recursive watch so at least top-level
389
+ // changes are picked up.
390
+ const createWatchers = (p) => {
391
+ const handlers = [];
392
+ try {
393
+ const watcher = fs_1.default.watch(p, { encoding: 'utf8', recursive: true }, () => scheduleReload());
394
+ watcher.on('error', (err) => app.debug(`Watcher error on ${p}: ${err.message}`));
395
+ handlers.push(watcher);
396
+ app.debug(`Watching chart folder recursively: ${p}`);
397
+ }
398
+ catch (err) {
399
+ app.debug(`Recursive watch unavailable for ${p} (${err.message}); falling back to top-level watch`);
400
+ try {
401
+ const watcher = fs_1.default.watch(p, { encoding: 'utf8' }, () => scheduleReload());
402
+ watcher.on('error', (e) => app.debug(`Watcher error on ${p}: ${e.message}`));
403
+ handlers.push(watcher);
404
+ }
405
+ catch (e) {
406
+ app.debug(`Unable to watch ${p}: ${e.message}`);
407
+ }
408
+ }
409
+ return handlers;
410
+ };
411
+ const stopWatchers = () => {
412
+ if (reloadTimer) {
413
+ clearTimeout(reloadTimer);
414
+ reloadTimer = undefined;
415
+ }
416
+ while (watchers.length) {
417
+ watchers.pop()?.close();
418
+ }
210
419
  };
211
420
  const registerRoutes = () => {
212
421
  app.debug('** Registering API paths **');
213
422
  app.get(`${chartTilesPath}/:identifier/:z([0-9]*)/:x([0-9]*)/:y([0-9]*)`, async (req, res) => {
214
423
  const { identifier, z, x, y } = req.params;
424
+ if (!identifier || !z || !x || !y) {
425
+ return res.sendStatus(404);
426
+ }
215
427
  const ix = parseInt(x);
216
428
  const iy = parseInt(y);
217
429
  const iz = parseInt(z);
@@ -236,21 +448,39 @@ module.exports = (app) => {
236
448
  });
237
449
  app.post(`${chartTilesPath}/cache/:identifier`, async (req, res) => {
238
450
  const { identifier } = req.params;
451
+ if (!identifier) {
452
+ return res.sendStatus(404);
453
+ }
239
454
  const { regionGUID, tile, bbox, maxZoom } = req.body;
240
455
  const provider = chartProviders[identifier];
241
456
  if (!provider) {
242
- return res.sendStatus(500).send('Provider not found');
457
+ return res.status(404).send('Provider not found');
243
458
  }
244
459
  if (!maxZoom) {
245
460
  return res.status(400).send('maxZoom parameter is required');
246
461
  }
462
+ if (!regionGUID && !bbox && !tile) {
463
+ return res
464
+ .status(400)
465
+ .send('Request must include regionGUID, bbox, or tile');
466
+ }
247
467
  const maxZoomParsed = parseInt(maxZoom);
248
- await chartDownloader_1.ChartSeedingManager.createJob(app.resourcesApi, defaultChartsPath, provider, maxZoomParsed, regionGUID, bbox
249
- ? [bbox.minLon, bbox.minLat, bbox.maxLon, bbox.maxLat]
250
- : undefined, tile);
251
- return res.status(200);
468
+ try {
469
+ const job = await chartDownloader_1.ChartSeedingManager.createJob(app.resourcesApi, cachePath, provider, maxZoomParsed, regionGUID, bbox
470
+ ? [bbox.minLon, bbox.minLat, bbox.maxLon, bbox.maxLat]
471
+ : undefined, tile);
472
+ // Job is registered and its tile set is known, but nothing has been
473
+ // downloaded yet — the caller starts it with
474
+ // POST /cache/jobs/:id { action: 'start' }.
475
+ return res.status(202).json(job.info());
476
+ }
477
+ catch (err) {
478
+ return res
479
+ .status(500)
480
+ .send(`Failed to create seeding job: ${err.message}`);
481
+ }
252
482
  });
253
- app.get(`${chartTilesPath}/cache/jobs`, (req, res) => {
483
+ app.get(`${chartTilesPath}/cache/jobs`, (_req, res) => {
254
484
  const jobs = Object.values(chartDownloader_1.ChartSeedingManager.ActiveJobs).map((job) => {
255
485
  return job.info();
256
486
  });
@@ -258,42 +488,57 @@ module.exports = (app) => {
258
488
  });
259
489
  app.post(`${chartTilesPath}/cache/jobs/:id`, (req, res) => {
260
490
  const { id } = req.params;
491
+ if (!id) {
492
+ return res.sendStatus(404);
493
+ }
261
494
  const { action } = req.body;
495
+ if (!id) {
496
+ return res.status(400).send('Missing job id');
497
+ }
262
498
  const parsedId = parseInt(id);
499
+ if (!Number.isFinite(parsedId)) {
500
+ return res.status(400).send(`Invalid job id: ${id}`);
501
+ }
263
502
  const job = chartDownloader_1.ChartSeedingManager.ActiveJobs[parsedId];
264
- if (job && action) {
265
- if (action === 'start') {
266
- job.seedCache();
267
- }
268
- else if (action === 'stop') {
269
- job.cancelJob();
270
- }
271
- else if (action === 'delete') {
272
- job.deleteCache();
273
- }
274
- else if (action === 'remove') {
275
- delete chartDownloader_1.ChartSeedingManager.ActiveJobs[parsedId];
276
- }
277
- else {
278
- return res.status(404).send(`Job ${parsedId} not found`);
279
- }
280
- return res.status(200).send(`Job ${parsedId} ${action}ed`);
503
+ if (!job) {
504
+ return res.status(404).send(`Job ${parsedId} not found`);
505
+ }
506
+ if (!action) {
507
+ return res.status(400).send('action parameter is required');
508
+ }
509
+ if (action === 'start') {
510
+ job.seedCache();
511
+ }
512
+ else if (action === 'stop') {
513
+ job.cancelJob();
514
+ }
515
+ else if (action === 'delete') {
516
+ job.deleteCache();
281
517
  }
518
+ else if (action === 'remove') {
519
+ delete chartDownloader_1.ChartSeedingManager.ActiveJobs[parsedId];
520
+ }
521
+ else {
522
+ return res.status(400).send(`Unknown action: ${action}`);
523
+ }
524
+ return res.status(200).send(`Job ${parsedId} ${action}ed`);
282
525
  });
283
526
  app.debug('** Registering v1 API paths **');
284
527
  app.get(constants_1.apiRoutePrefix[1] + '/charts/:identifier', (req, res) => {
285
528
  const { identifier } = req.params;
286
- const provider = chartProviders[identifier];
287
- if (provider) {
288
- return res.json(sanitizeProvider(provider));
529
+ if (!identifier) {
530
+ return res.sendStatus(404);
531
+ }
532
+ const view = sanitizedV1[identifier];
533
+ if (view) {
534
+ return res.json(view);
289
535
  }
290
536
  else {
291
537
  return res.status(404).send('Not found');
292
538
  }
293
539
  });
294
- app.get(constants_1.apiRoutePrefix[1] + '/charts', (req, res) => {
295
- const sanitized = _.mapValues(chartProviders, (provider) => sanitizeProvider(provider));
296
- res.json(sanitized);
540
+ app.get(constants_1.apiRoutePrefix[1] + '/charts', (_req, res) => {
541
+ res.json(sanitizedV1);
297
542
  });
298
543
  };
299
544
  // Resources API provider registration
@@ -305,13 +550,13 @@ module.exports = (app) => {
305
550
  methods: {
306
551
  listResources: (params) => {
307
552
  app.debug(`** listResources() ${params}`);
308
- return Promise.resolve(_.mapValues(chartProviders, (provider) => sanitizeProvider(provider, 2)));
553
+ return Promise.resolve(sanitizedV2);
309
554
  },
310
555
  getResource: (id) => {
311
556
  app.debug(`** getResource() ${id}`);
312
- const provider = chartProviders[id];
313
- if (provider) {
314
- return Promise.resolve(sanitizeProvider(provider, 2));
557
+ const view = sanitizedV2[id];
558
+ if (view) {
559
+ return Promise.resolve(view);
315
560
  }
316
561
  else {
317
562
  throw new Error('Chart not found!');
@@ -328,11 +573,11 @@ module.exports = (app) => {
328
573
  });
329
574
  }
330
575
  catch (error) {
331
- app.debug('Failed Provider Registration!');
576
+ app.setPluginError(`Failed to register as charts resource provider: ${error.message}`);
332
577
  }
333
578
  };
334
579
  const serveTileFromCacheOrRemote = async (res, provider, z, x, y) => {
335
- const buffer = await chartDownloader_1.ChartDownloader.getTileFromCacheOrRemote(defaultChartsPath, provider, { x, y, z });
580
+ const buffer = await chartDownloader_1.ChartDownloader.getTileFromCacheOrRemote(cachePath, provider, { x, y, z });
336
581
  if (!buffer) {
337
582
  res.sendStatus(502);
338
583
  return;
@@ -347,9 +592,16 @@ const responseHttpOptions = {
347
592
  'Cache-Control': 'public, max-age=7776000' // 90 days
348
593
  }
349
594
  };
595
+ // Allowed tile file formats. Add new formats here when supporting them.
596
+ const ALLOWED_TILE_FORMATS = new Set(['png', 'jpg', 'jpeg', 'pbf']);
597
+ const isAllowedTileFormat = (format) => {
598
+ if (!format)
599
+ return false;
600
+ return ALLOWED_TILE_FORMATS.has(format.toLowerCase());
601
+ };
350
602
  const resolveUniqueChartPaths = (chartPaths, configBasePath) => {
351
- const paths = _.map(chartPaths, (chartPath) => path_1.default.resolve(configBasePath, chartPath));
352
- return _.uniq(paths);
603
+ const paths = chartPaths.map((chartPath) => path_1.default.resolve(configBasePath, chartPath));
604
+ return [...new Set(paths)];
353
605
  };
354
606
  const convertOnlineProviderConfig = (provider) => {
355
607
  const id = _.kebabCase(_.deburr(provider.name));
@@ -396,48 +648,76 @@ const convertOnlineProviderConfig = (provider) => {
396
648
  };
397
649
  return data;
398
650
  };
651
+ // Builds the outward-facing view of a provider for either the v1 or v2 API.
652
+ // Copies non-private top-level fields, then overlays the version-specific
653
+ // block (v1 has tilemapUrl/chartLayers, v2 has url/layers), rewriting the
654
+ // tile-path placeholder. Intentionally a single shallow walk — the previous
655
+ // implementation did three deep clones per call and ran per metadata request.
399
656
  const sanitizeProvider = (provider, version = 1) => {
400
- let v;
401
- if (version === 1) {
402
- v = _.merge({}, provider.v1);
403
- v.tilemapUrl = v.tilemapUrl.replace('~tilePath~', chartTilesPath);
657
+ const out = {};
658
+ for (const [key, value] of Object.entries(provider)) {
659
+ if (key.startsWith('_') || key === 'v1' || key === 'v2')
660
+ continue;
661
+ out[key] = value;
662
+ }
663
+ const v = version === 1 ? provider.v1 : provider.v2;
664
+ if (v) {
665
+ for (const [key, value] of Object.entries(v)) {
666
+ out[key] = value;
667
+ }
668
+ }
669
+ if (version === 1 && typeof out.tilemapUrl === 'string') {
670
+ out.tilemapUrl = out.tilemapUrl.replace('~tilePath~', chartTilesPath);
671
+ }
672
+ else if (version === 2 && typeof out.url === 'string') {
673
+ out.url = out.url.replace('~tilePath~', chartTilesPath);
404
674
  }
405
675
  else if (version === 2) {
406
- v = _.merge({}, provider.v2);
407
- v.url = v.url ? v.url.replace('~tilePath~', chartTilesPath) : '';
676
+ out.url = '';
408
677
  }
409
- provider = _.omit(provider, [
410
- '_filePath',
411
- '_fileFormat',
412
- '_mbtilesHandle',
413
- '_flipY',
414
- 'v1',
415
- 'v2'
416
- ]);
417
- return _.merge(provider, v);
678
+ return out;
418
679
  };
419
- const ensureDirectoryExists = (path) => {
420
- if (!fs_1.default.existsSync(path)) {
421
- fs_1.default.mkdirSync(path);
422
- }
680
+ const ensureDirectoryExists = async (p) => {
681
+ // mkdir with recursive:true is idempotent and skips the existsSync probe,
682
+ // keeping startup off the sync-fs path.
683
+ await fs_1.default.promises.mkdir(p, { recursive: true });
423
684
  };
424
- const serveTileFromFilesystem = (res, provider, z, x, y) => {
685
+ const serveTileFromFilesystem = async (res, provider, z, x, y) => {
425
686
  const { format, _flipY, _filePath } = provider;
687
+ const normalizedFormat = format?.toLowerCase() ?? '';
688
+ if (!_filePath || !ALLOWED_TILE_FORMATS.has(normalizedFormat)) {
689
+ res.sendStatus(404);
690
+ return;
691
+ }
426
692
  const flippedY = Math.pow(2, z) - 1 - y;
427
- const file = _filePath
428
- ? path_1.default.resolve(_filePath, `${z}/${x}/${_flipY ? flippedY : y}.${format}`)
429
- : '';
430
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
431
- res.sendFile(file, responseHttpOptions, (err) => {
432
- if (err && err.code === 'ENOENT') {
693
+ const file = path_1.default.resolve(_filePath, `${z}/${x}/${_flipY ? flippedY : y}.${normalizedFormat}`);
694
+ try {
695
+ const stats = await fs_1.default.promises.stat(file);
696
+ if (!stats.isFile()) {
433
697
  res.sendStatus(404);
698
+ return;
434
699
  }
435
- else if (err) {
436
- throw err;
437
- }
438
- });
700
+ await fs_1.default.promises.access(file, fs_1.default.constants.R_OK);
701
+ }
702
+ catch {
703
+ res.sendStatus(404);
704
+ return;
705
+ }
706
+ res.sendFile(file, responseHttpOptions);
439
707
  };
440
708
  const serveTileFromMbtiles = (res, provider, z, x, y) => {
709
+ if (!isAllowedTileFormat(provider.format)) {
710
+ res.sendStatus(404);
711
+ return;
712
+ }
713
+ // Guard against a provider whose MBTiles handle is missing: openMbtilesFile
714
+ // may have failed post-reconcile, or the handle was closed while a request
715
+ // was in flight. Without this check, `.getTile` throws synchronously and
716
+ // Express never answers the client.
717
+ if (!provider._mbtilesHandle) {
718
+ res.sendStatus(500);
719
+ return;
720
+ }
441
721
  provider._mbtilesHandle.getTile(z, x, y, (err, tile, headers) => {
442
722
  if (err && err.message && err.message === 'Tile does not exist') {
443
723
  res.sendStatus(404);
@@ -453,3 +733,5 @@ const serveTileFromMbtiles = (res, provider, z, x, y) => {
453
733
  }
454
734
  });
455
735
  };
736
+ module.exports = createPlugin;
737
+ //# sourceMappingURL=index.js.map