@signalk/charts-plugin 3.3.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,9 +44,19 @@ 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: [],
44
62
  cachePath: '',
@@ -48,15 +66,32 @@ module.exports = (app) => {
48
66
  const configBasePath = app.config.configPath;
49
67
  const defaultChartsPath = path_1.default.join(configBasePath, '/charts');
50
68
  const serverMajorVersion = app.config.version
51
- ? parseInt(app.config.version.split('.')[0])
52
- : '1';
53
- ensureDirectoryExists(defaultChartsPath);
69
+ ? parseInt(app.config.version.split('.')[0] ?? '0')
70
+ : 1;
54
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');
55
82
  // ******** REQUIRED PLUGIN DEFINITION *******
56
83
  const CONFIG_SCHEMA = {
57
84
  title: 'Signal K Charts',
58
85
  type: 'object',
59
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
+ }),
60
95
  chartPaths: {
61
96
  type: 'array',
62
97
  title: 'Chart paths',
@@ -175,52 +210,220 @@ module.exports = (app) => {
175
210
  return doStartup(settings); // return required for tests
176
211
  },
177
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 = {};
178
227
  app.setPluginStatus('stopped');
179
228
  }
180
229
  };
181
- 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
+ }
182
240
  app.debug(`** loaded config: ${config}`);
183
241
  props = { ...config };
184
242
  urlBase = `${app.config.ssl ? 'https' : 'http'}://localhost:${'getExternalPort' in app.config ? app.config.getExternalPort() : 3000}`;
185
243
  app.debug(`**urlBase** ${urlBase}`);
186
- const chartPaths = _.isEmpty(props.chartPaths)
244
+ activeChartPaths = !props.chartPaths?.length
187
245
  ? [defaultChartsPath]
188
246
  : resolveUniqueChartPaths(props.chartPaths, configBasePath);
189
247
  cachePath = props.cachePath || defaultChartsPath;
190
- ensureDirectoryExists(cachePath);
191
- const onlineProviders = _.reduce(props.onlineChartProviders, (result, data) => {
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 ?? []) {
192
257
  const provider = convertOnlineProviderConfig(data);
193
- result[provider.identifier] = provider;
194
- return result;
195
- }, {});
196
- app.debug(`Start charts plugin. Chart paths: ${chartPaths.join(', ')}, online charts: ${Object.keys(onlineProviders).length}`);
197
- // Do not register routes if plugin has been started once already
198
- 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();
199
271
  pluginStarted = true;
200
- // v2 routes - register as Resource Provider, this needs to be always on startup
201
- if (serverMajorVersion === 2) {
272
+ if (serverMajorVersion === 2 && !providerRegistered) {
202
273
  app.debug('** Registering v2 API paths **');
203
274
  registerAsProvider();
275
+ providerRegistered = true;
204
276
  }
205
277
  app.setPluginStatus('Started');
206
- const loadProviders = bluebird
207
- .mapSeries(chartPaths, (chartPath) => (0, charts_1.findCharts)(chartPath))
208
- .then((list) => _.reduce(list, (result, charts) => _.merge({}, result, charts), {}));
209
- return loadProviders
210
- .then((charts) => {
211
- app.debug(`Chart plugin: Found ${_.keys(charts).length} charts from ${chartPaths.join(', ')}.`);
212
- chartProviders = _.merge({}, charts, onlineProviders);
213
- })
214
- .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.
215
293
  console.error(`Error loading chart providers`, e.message);
216
- chartProviders = {};
217
294
  app.setPluginError(`Error loading chart providers`);
218
- });
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
+ }
219
419
  };
220
420
  const registerRoutes = () => {
221
421
  app.debug('** Registering API paths **');
222
422
  app.get(`${chartTilesPath}/:identifier/:z([0-9]*)/:x([0-9]*)/:y([0-9]*)`, async (req, res) => {
223
423
  const { identifier, z, x, y } = req.params;
424
+ if (!identifier || !z || !x || !y) {
425
+ return res.sendStatus(404);
426
+ }
224
427
  const ix = parseInt(x);
225
428
  const iy = parseInt(y);
226
429
  const iz = parseInt(z);
@@ -245,21 +448,39 @@ module.exports = (app) => {
245
448
  });
246
449
  app.post(`${chartTilesPath}/cache/:identifier`, async (req, res) => {
247
450
  const { identifier } = req.params;
451
+ if (!identifier) {
452
+ return res.sendStatus(404);
453
+ }
248
454
  const { regionGUID, tile, bbox, maxZoom } = req.body;
249
455
  const provider = chartProviders[identifier];
250
456
  if (!provider) {
251
- return res.sendStatus(500).send('Provider not found');
457
+ return res.status(404).send('Provider not found');
252
458
  }
253
459
  if (!maxZoom) {
254
460
  return res.status(400).send('maxZoom parameter is required');
255
461
  }
462
+ if (!regionGUID && !bbox && !tile) {
463
+ return res
464
+ .status(400)
465
+ .send('Request must include regionGUID, bbox, or tile');
466
+ }
256
467
  const maxZoomParsed = parseInt(maxZoom);
257
- await chartDownloader_1.ChartSeedingManager.createJob(app.resourcesApi, cachePath, provider, maxZoomParsed, regionGUID, bbox
258
- ? [bbox.minLon, bbox.minLat, bbox.maxLon, bbox.maxLat]
259
- : undefined, tile);
260
- 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
+ }
261
482
  });
262
- app.get(`${chartTilesPath}/cache/jobs`, (req, res) => {
483
+ app.get(`${chartTilesPath}/cache/jobs`, (_req, res) => {
263
484
  const jobs = Object.values(chartDownloader_1.ChartSeedingManager.ActiveJobs).map((job) => {
264
485
  return job.info();
265
486
  });
@@ -267,42 +488,57 @@ module.exports = (app) => {
267
488
  });
268
489
  app.post(`${chartTilesPath}/cache/jobs/:id`, (req, res) => {
269
490
  const { id } = req.params;
491
+ if (!id) {
492
+ return res.sendStatus(404);
493
+ }
270
494
  const { action } = req.body;
495
+ if (!id) {
496
+ return res.status(400).send('Missing job id');
497
+ }
271
498
  const parsedId = parseInt(id);
499
+ if (!Number.isFinite(parsedId)) {
500
+ return res.status(400).send(`Invalid job id: ${id}`);
501
+ }
272
502
  const job = chartDownloader_1.ChartSeedingManager.ActiveJobs[parsedId];
273
- if (job && action) {
274
- if (action === 'start') {
275
- job.seedCache();
276
- }
277
- else if (action === 'stop') {
278
- job.cancelJob();
279
- }
280
- else if (action === 'delete') {
281
- job.deleteCache();
282
- }
283
- else if (action === 'remove') {
284
- delete chartDownloader_1.ChartSeedingManager.ActiveJobs[parsedId];
285
- }
286
- else {
287
- return res.status(404).send(`Job ${parsedId} not found`);
288
- }
289
- 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();
290
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`);
291
525
  });
292
526
  app.debug('** Registering v1 API paths **');
293
527
  app.get(constants_1.apiRoutePrefix[1] + '/charts/:identifier', (req, res) => {
294
528
  const { identifier } = req.params;
295
- const provider = chartProviders[identifier];
296
- if (provider) {
297
- 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);
298
535
  }
299
536
  else {
300
537
  return res.status(404).send('Not found');
301
538
  }
302
539
  });
303
- app.get(constants_1.apiRoutePrefix[1] + '/charts', (req, res) => {
304
- const sanitized = _.mapValues(chartProviders, (provider) => sanitizeProvider(provider));
305
- res.json(sanitized);
540
+ app.get(constants_1.apiRoutePrefix[1] + '/charts', (_req, res) => {
541
+ res.json(sanitizedV1);
306
542
  });
307
543
  };
308
544
  // Resources API provider registration
@@ -314,13 +550,13 @@ module.exports = (app) => {
314
550
  methods: {
315
551
  listResources: (params) => {
316
552
  app.debug(`** listResources() ${params}`);
317
- return Promise.resolve(_.mapValues(chartProviders, (provider) => sanitizeProvider(provider, 2)));
553
+ return Promise.resolve(sanitizedV2);
318
554
  },
319
555
  getResource: (id) => {
320
556
  app.debug(`** getResource() ${id}`);
321
- const provider = chartProviders[id];
322
- if (provider) {
323
- return Promise.resolve(sanitizeProvider(provider, 2));
557
+ const view = sanitizedV2[id];
558
+ if (view) {
559
+ return Promise.resolve(view);
324
560
  }
325
561
  else {
326
562
  throw new Error('Chart not found!');
@@ -337,7 +573,7 @@ module.exports = (app) => {
337
573
  });
338
574
  }
339
575
  catch (error) {
340
- app.debug('Failed Provider Registration!');
576
+ app.setPluginError(`Failed to register as charts resource provider: ${error.message}`);
341
577
  }
342
578
  };
343
579
  const serveTileFromCacheOrRemote = async (res, provider, z, x, y) => {
@@ -356,9 +592,16 @@ const responseHttpOptions = {
356
592
  'Cache-Control': 'public, max-age=7776000' // 90 days
357
593
  }
358
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
+ };
359
602
  const resolveUniqueChartPaths = (chartPaths, configBasePath) => {
360
- const paths = _.map(chartPaths, (chartPath) => path_1.default.resolve(configBasePath, chartPath));
361
- return _.uniq(paths);
603
+ const paths = chartPaths.map((chartPath) => path_1.default.resolve(configBasePath, chartPath));
604
+ return [...new Set(paths)];
362
605
  };
363
606
  const convertOnlineProviderConfig = (provider) => {
364
607
  const id = _.kebabCase(_.deburr(provider.name));
@@ -405,48 +648,76 @@ const convertOnlineProviderConfig = (provider) => {
405
648
  };
406
649
  return data;
407
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.
408
656
  const sanitizeProvider = (provider, version = 1) => {
409
- let v;
410
- if (version === 1) {
411
- v = _.merge({}, provider.v1);
412
- 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);
413
674
  }
414
675
  else if (version === 2) {
415
- v = _.merge({}, provider.v2);
416
- v.url = v.url ? v.url.replace('~tilePath~', chartTilesPath) : '';
676
+ out.url = '';
417
677
  }
418
- provider = _.omit(provider, [
419
- '_filePath',
420
- '_fileFormat',
421
- '_mbtilesHandle',
422
- '_flipY',
423
- 'v1',
424
- 'v2'
425
- ]);
426
- return _.merge(provider, v);
678
+ return out;
427
679
  };
428
- const ensureDirectoryExists = (path) => {
429
- if (!fs_1.default.existsSync(path)) {
430
- fs_1.default.mkdirSync(path);
431
- }
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 });
432
684
  };
433
- const serveTileFromFilesystem = (res, provider, z, x, y) => {
685
+ const serveTileFromFilesystem = async (res, provider, z, x, y) => {
434
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
+ }
435
692
  const flippedY = Math.pow(2, z) - 1 - y;
436
- const file = _filePath
437
- ? path_1.default.resolve(_filePath, `${z}/${x}/${_flipY ? flippedY : y}.${format}`)
438
- : '';
439
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
440
- res.sendFile(file, responseHttpOptions, (err) => {
441
- 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()) {
442
697
  res.sendStatus(404);
698
+ return;
443
699
  }
444
- else if (err) {
445
- throw err;
446
- }
447
- });
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);
448
707
  };
449
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
+ }
450
721
  provider._mbtilesHandle.getTile(z, x, y, (err, tile, headers) => {
451
722
  if (err && err.message && err.message === 'Tile does not exist') {
452
723
  res.sendStatus(404);
@@ -462,3 +733,5 @@ const serveTileFromMbtiles = (res, provider, z, x, y) => {
462
733
  }
463
734
  });
464
735
  };
736
+ module.exports = createPlugin;
737
+ //# sourceMappingURL=index.js.map