tileserver-gl 5.6.0 → 5.7.0-pre.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/src/main.js CHANGED
@@ -113,6 +113,10 @@ program
113
113
  '--ignore-missing-files',
114
114
  'Continue startup even if configured mbtiles/pmtiles files are missing',
115
115
  )
116
+ .option(
117
+ '--metrics',
118
+ 'Enable Prometheus metrics endpoint (env: TILESERVER_GL_METRICS)',
119
+ )
116
120
  .version(packageJson.version, '-v, --version');
117
121
  program.parse(process.argv);
118
122
  const opts = program.opts();
@@ -166,6 +170,11 @@ const startServer = (configPath, config) => {
166
170
  publicUrl,
167
171
  allowedHosts,
168
172
  ignoreMissingFiles: opts.ignoreMissingFiles,
173
+ metrics: opts.metrics || process.env.TILESERVER_GL_METRICS === 'true',
174
+ metricsPort: (() => {
175
+ const port = parseInt(process.env.METRICS_PORT, 10);
176
+ return !isNaN(port) && port > 0 ? port : 9090;
177
+ })(),
169
178
  });
170
179
  };
171
180
 
@@ -8,6 +8,7 @@ class MBTilesWrapper {
8
8
  constructor(mbtiles) {
9
9
  this._mbtiles = mbtiles;
10
10
  this._getInfoP = util.promisify(mbtiles.getInfo.bind(mbtiles));
11
+ this._closeP = util.promisify(mbtiles.close.bind(mbtiles));
11
12
  }
12
13
 
13
14
  /**
@@ -25,6 +26,14 @@ class MBTilesWrapper {
25
26
  getInfo() {
26
27
  return this._getInfoP();
27
28
  }
29
+
30
+ /**
31
+ * Closes the underlying MBTiles database handle.
32
+ * @returns {Promise<void>} A promise that resolves when the database is closed.
33
+ */
34
+ close() {
35
+ return this._closeP();
36
+ }
28
37
  }
29
38
 
30
39
  /**
package/src/metrics.js ADDED
@@ -0,0 +1,70 @@
1
+ // src/metrics.js
2
+ import {
3
+ Registry,
4
+ Counter,
5
+ Histogram,
6
+ Gauge,
7
+ collectDefaultMetrics,
8
+ } from 'prom-client';
9
+
10
+ export const registry = new Registry();
11
+
12
+ collectDefaultMetrics({ register: registry });
13
+
14
+ export const httpRequestsTotal = new Counter({
15
+ name: 'tileserver_http_requests_total',
16
+ help: 'Total number of HTTP requests',
17
+ labelNames: ['method', 'route', 'status_code'],
18
+ registers: [registry],
19
+ });
20
+
21
+ export const httpRequestDuration = new Histogram({
22
+ name: 'tileserver_http_request_duration_seconds',
23
+ help: 'HTTP request duration in seconds',
24
+ labelNames: ['method', 'route', 'status_code'],
25
+ buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
26
+ registers: [registry],
27
+ });
28
+
29
+ export const tilesServedTotal = new Counter({
30
+ name: 'tileserver_tiles_served_total',
31
+ help: 'Total number of tiles served',
32
+ labelNames: ['type', 'name'],
33
+ registers: [registry],
34
+ });
35
+
36
+ export const tileRenderDuration = new Histogram({
37
+ name: 'tileserver_tile_render_duration_seconds',
38
+ help: 'Tile pipeline duration in seconds (includes pool wait + render + encode)',
39
+ labelNames: ['name', 'zoom'],
40
+ buckets: [0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
41
+ registers: [registry],
42
+ });
43
+
44
+ export const tileErrorsTotal = new Counter({
45
+ name: 'tileserver_tile_errors_total',
46
+ help: 'Total number of tile errors',
47
+ labelNames: ['type', 'name'],
48
+ registers: [registry],
49
+ });
50
+
51
+ export const renderPoolSize = new Gauge({
52
+ name: 'tileserver_render_pool_size',
53
+ help: 'Total objects in render pool',
54
+ labelNames: ['name'],
55
+ registers: [registry],
56
+ });
57
+
58
+ export const renderPoolActive = new Gauge({
59
+ name: 'tileserver_render_pool_active',
60
+ help: 'Active (borrowed) objects in render pool',
61
+ labelNames: ['name'],
62
+ registers: [registry],
63
+ });
64
+
65
+ export const renderPoolWaiting = new Gauge({
66
+ name: 'tileserver_render_pool_waiting',
67
+ help: 'Clients waiting for a render pool object',
68
+ labelNames: ['name'],
69
+ registers: [registry],
70
+ });
@@ -232,18 +232,22 @@ class S3Source {
232
232
  }
233
233
 
234
234
  if (error.name === 'NoSuchKey') {
235
- throw new Error(`PMTiles file not found: ${this.bucket}/${this.key}`);
235
+ throw new Error(`PMTiles file not found: ${this.bucket}/${this.key}`, {
236
+ cause: error,
237
+ });
236
238
  }
237
239
 
238
240
  if (error.name === 'AccessDenied') {
239
241
  throw new Error(
240
242
  `Access denied: ${this.bucket}/${this.key}. Check credentials and bucket permissions.`,
243
+ { cause: error },
241
244
  );
242
245
  }
243
246
 
244
247
  if (error.name === 'NoSuchBucket') {
245
248
  throw new Error(
246
249
  `Bucket not found: ${this.bucket}. Check bucket name and endpoint.`,
250
+ { cause: error },
247
251
  );
248
252
  }
249
253
 
@@ -288,6 +292,23 @@ class PMTilesFileSource {
288
292
  );
289
293
  return { data: ab };
290
294
  }
295
+
296
+ /**
297
+ * Closes the underlying file descriptor for local PMTiles sources.
298
+ * @returns {void}
299
+ */
300
+ close() {
301
+ if (typeof this.fd === 'number') {
302
+ const fd = this.fd;
303
+ try {
304
+ fs.closeSync(fd);
305
+ } catch (err) {
306
+ console.warn(`Failed to close PMTiles file descriptor ${fd}:`, err);
307
+ } finally {
308
+ this.fd = null;
309
+ }
310
+ }
311
+ }
291
312
  }
292
313
 
293
314
  /**
@@ -311,6 +332,22 @@ async function readFileBytes(fd, buffer, offset) {
311
332
  // Cache for PMTiles objects to avoid creating multiple instances for the same URL
312
333
  const pmtilesCache = new Map();
313
334
 
335
+ /**
336
+ * Closes a PMTiles instance if it owns a closeable local file source.
337
+ * @param {PMTiles} pmtiles - The PMTiles instance to close.
338
+ * @returns {void}
339
+ */
340
+ function closePMTiles(pmtiles) {
341
+ if (!pmtiles) {
342
+ return;
343
+ }
344
+
345
+ const source = pmtiles.source;
346
+ if (source && typeof source.close === 'function') {
347
+ source.close();
348
+ }
349
+ }
350
+
314
351
  /**
315
352
  * Opens a PMTiles file from local filesystem, HTTP URL, or S3 URL.
316
353
  * Uses caching to avoid creating multiple PMTiles instances for the same file.
@@ -347,7 +384,7 @@ export function openPMtiles(
347
384
  return pmtilesCache.get(cacheKey);
348
385
  }
349
386
 
350
- let pmtiles = undefined;
387
+ let pmtiles;
351
388
 
352
389
  if (isS3Url(filePath)) {
353
390
  if (verbose >= 2) {
@@ -384,6 +421,17 @@ export function openPMtiles(
384
421
  return pmtiles;
385
422
  }
386
423
 
424
+ /**
425
+ * Clears the PMTiles cache and closes any local file descriptors owned by cached sources.
426
+ * @returns {void}
427
+ */
428
+ export function clearPMtilesCache() {
429
+ for (const pmtiles of pmtilesCache.values()) {
430
+ closePMTiles(pmtiles);
431
+ }
432
+ pmtilesCache.clear();
433
+ }
434
+
387
435
  /**
388
436
  * Retrieves metadata and header information from a PMTiles archive with retry logic for rate limiting.
389
437
  * @param {PMTiles} pmtiles - The PMTiles instance.
@@ -462,7 +510,7 @@ export async function getPMtilesInfo(pmtiles, inputFile, maxRetries = 3) {
462
510
  // If not a 429 or last retry, throw immediately
463
511
  if (!error.message?.includes('429') || attempt === maxRetries - 1) {
464
512
  const errorMessage = `${error.message} for file: ${inputFile}`;
465
- throw new Error(errorMessage);
513
+ throw new Error(errorMessage, { cause: error });
466
514
  }
467
515
  }
468
516
  }
package/src/serve_data.js CHANGED
@@ -5,7 +5,7 @@ import path from 'path';
5
5
 
6
6
  import clone from 'clone';
7
7
  import express from 'express';
8
- import Pbf from 'pbf';
8
+ import { PbfReader } from 'pbf';
9
9
  import { VectorTile } from '@mapbox/vector-tile';
10
10
  import { SphericalMercator } from '@mapbox/sphericalmercator';
11
11
 
@@ -21,6 +21,8 @@ import { gunzipP, gzipP } from './promises.js';
21
21
  import { openMbTilesWrapper } from './mbtiles_wrapper.js';
22
22
 
23
23
  import fs from 'node:fs';
24
+
25
+ let metricsModule = null;
24
26
  import { fileURLToPath } from 'url';
25
27
 
26
28
  const packageJson = JSON.parse(
@@ -45,6 +47,16 @@ export const serve_data = {
45
47
  */
46
48
  init: function (options, repo, programOpts) {
47
49
  const { verbose, allowedHosts } = programOpts;
50
+ // Cache metrics module if enabled. Safe because tests verify before production.
51
+ if (programOpts.metrics) {
52
+ import('./metrics.js')
53
+ .then((m) => {
54
+ metricsModule = m;
55
+ })
56
+ .catch((err) => {
57
+ console.error('Failed to import metrics module:', err);
58
+ });
59
+ }
48
60
  const app = express().disable('x-powered-by');
49
61
  app.use(express.json());
50
62
 
@@ -142,7 +154,7 @@ export const serve_data = {
142
154
  headers['Content-Type'] = 'application/x-protobuf';
143
155
  } else if (format === 'geojson') {
144
156
  headers['Content-Type'] = 'application/json';
145
- const tile = new VectorTile(new Pbf(data));
157
+ const tile = new VectorTile(new PbfReader(data));
146
158
  const geojson = {
147
159
  type: 'FeatureCollection',
148
160
  features: [],
@@ -167,6 +179,12 @@ export const serve_data = {
167
179
 
168
180
  data = await gzipP(data);
169
181
 
182
+ if (metricsModule) {
183
+ metricsModule.tilesServedTotal.inc({
184
+ type: 'vector',
185
+ name: req.params.id,
186
+ });
187
+ }
170
188
  return res.status(200).send(data);
171
189
  });
172
190
 
@@ -548,14 +566,14 @@ export const serve_data = {
548
566
  if (!inputFileStats.isFile() || inputFileStats.size === 0) {
549
567
  throw Error(`Not valid input file: "${inputFile}"`);
550
568
  }
551
- } catch (err) {
569
+ } catch (_err) {
552
570
  if (ignoreMissingFiles) {
553
571
  console.log(
554
572
  `WARN: Data source '${id}' file not found: "${inputFile}" - skipping`,
555
573
  );
556
574
  return;
557
575
  }
558
- throw Error(`Not valid input file: "${inputFile}"`);
576
+ throw Error(`Not valid input file: "${inputFile}"`, { cause: _err });
559
577
  }
560
578
  }
561
579
 
@@ -624,4 +642,29 @@ export const serve_data = {
624
642
  sparse,
625
643
  };
626
644
  },
645
+ /**
646
+ * Removes all items from the repository and closes owned local data sources.
647
+ * @param {object} repo Repository object.
648
+ * @returns {Promise<void>}
649
+ */
650
+ clear: async function (repo) {
651
+ await Promise.all(
652
+ Object.keys(repo).map(async (id) => {
653
+ // eslint-disable-next-line security/detect-object-injection -- id is from Object.keys() iteration
654
+ const item = repo[id];
655
+ try {
656
+ if (item && item.sourceType === 'mbtiles' && item.source) {
657
+ await new Promise((resolve, reject) => {
658
+ item.source.close((err) => (err ? reject(err) : resolve()));
659
+ });
660
+ }
661
+ } catch (err) {
662
+ console.warn(`Failed to close data source "${id}":`, err);
663
+ } finally {
664
+ // eslint-disable-next-line security/detect-object-injection -- id is from Object.keys() iteration
665
+ delete repo[id];
666
+ }
667
+ }),
668
+ );
669
+ },
627
670
  };
package/src/serve_font.js CHANGED
@@ -4,6 +4,8 @@ import express from 'express';
4
4
 
5
5
  import { getFontsPbf, listFonts } from './utils.js';
6
6
 
7
+ let metricsModule = null;
8
+
7
9
  /**
8
10
  * Initializes and returns an Express app that serves font files.
9
11
  * @param {object} options - Configuration options for the server.
@@ -13,6 +15,11 @@ import { getFontsPbf, listFonts } from './utils.js';
13
15
  */
14
16
  export async function serve_font(options, allowedFonts, programOpts) {
15
17
  const { verbose } = programOpts;
18
+ // Cache metrics module if enabled. Safe because tests verify before production.
19
+ if (programOpts.metrics) {
20
+ const m = await import('./metrics.js');
21
+ metricsModule = m;
22
+ }
16
23
  const app = express().disable('x-powered-by');
17
24
 
18
25
  const lastModified = new Date().toUTCString();
@@ -64,6 +71,9 @@ export async function serve_font(options, allowedFonts, programOpts) {
64
71
  );
65
72
  res.header('Content-type', 'application/x-protobuf');
66
73
  res.header('Last-Modified', lastModified);
74
+ if (metricsModule) {
75
+ metricsModule.tilesServedTotal.inc({ type: 'font', name: sFontStack });
76
+ }
67
77
  return res.send(concatenated);
68
78
  } catch (err) {
69
79
  console.error(
@@ -539,6 +539,7 @@ function calcZForBBox(bbox, w, h, query) {
539
539
  * @param {object} res Express response object.
540
540
  * @param {Buffer|null} overlay Optional overlay image.
541
541
  * @param {string} mode Rendering mode ('tile' or 'static').
542
+ * @param {string|null} id Style or dataset ID for metrics labeling.
542
543
  * @returns {Promise<void>}
543
544
  */
544
545
  async function respondImage(
@@ -556,6 +557,7 @@ async function respondImage(
556
557
  res,
557
558
  overlay = null,
558
559
  mode = 'tile',
560
+ id = null,
559
561
  ) {
560
562
  if (
561
563
  Math.abs(lon) > 180 ||
@@ -599,10 +601,14 @@ async function respondImage(
599
601
  }
600
602
 
601
603
  pool.acquire(async (err, renderer) => {
604
+ const renderStart = process.hrtime.bigint();
602
605
  // Check if pool.acquire failed or returned null/invalid renderer
603
606
  if (err) {
604
607
  console.error('Failed to acquire renderer from pool:', err);
605
608
  if (!res.headersSent) {
609
+ if (metricsModule) {
610
+ metricsModule.tileErrorsTotal.inc({ type: 'rendered', name: id });
611
+ }
606
612
  return res.status(503).send('Renderer pool error');
607
613
  }
608
614
  return;
@@ -613,6 +619,9 @@ async function respondImage(
613
619
  'Renderer is null - likely crashed or failed to initialize',
614
620
  );
615
621
  if (!res.headersSent) {
622
+ if (metricsModule) {
623
+ metricsModule.tileErrorsTotal.inc({ type: 'rendered', name: id });
624
+ }
616
625
  return res.status(503).send('Renderer unavailable');
617
626
  }
618
627
  return;
@@ -627,6 +636,9 @@ async function respondImage(
627
636
  console.error('Error removing bad renderer:', e);
628
637
  }
629
638
  if (!res.headersSent) {
639
+ if (metricsModule) {
640
+ metricsModule.tileErrorsTotal.inc({ type: 'rendered', name: id });
641
+ }
630
642
  return res.status(503).send('Renderer invalid');
631
643
  }
632
644
  return;
@@ -693,6 +705,9 @@ async function respondImage(
693
705
  console.error('Error removing failed renderer:', e);
694
706
  }
695
707
  if (!res.headersSent) {
708
+ if (metricsModule) {
709
+ metricsModule.tileErrorsTotal.inc({ type: 'rendered', name: id });
710
+ }
696
711
  return res
697
712
  .status(500)
698
713
  .header('Content-Type', 'text/plain')
@@ -791,12 +806,34 @@ async function respondImage(
791
806
  if (err || !buffer) {
792
807
  console.error('Sharp error:', err);
793
808
  if (!res.headersSent) {
809
+ if (metricsModule) {
810
+ metricsModule.tileErrorsTotal.inc({
811
+ type: 'rendered',
812
+ name: id,
813
+ });
814
+ }
794
815
  return res.status(500).send('Image processing failed');
795
816
  }
796
817
  return;
797
818
  }
798
819
 
799
820
  if (!res.headersSent) {
821
+ if (metricsModule) {
822
+ const renderDurationSec =
823
+ Number(process.hrtime.bigint() - renderStart) / 1e9;
824
+ metricsModule.tilesServedTotal.inc({
825
+ type: 'rendered',
826
+ name: id,
827
+ });
828
+ const zoomLabel =
829
+ process.env.TILESERVER_GL_METRICS_ZOOM === 'true'
830
+ ? String(z)
831
+ : 'all';
832
+ metricsModule.tileRenderDuration.observe(
833
+ { name: id, zoom: zoomLabel },
834
+ renderDurationSec,
835
+ );
836
+ }
800
837
  res.set({
801
838
  'Last-Modified': item.lastModified,
802
839
  'Content-Type': `image/${format}`,
@@ -814,6 +851,9 @@ async function respondImage(
814
851
  console.error('Error removing renderer after error:', e);
815
852
  }
816
853
  if (!res.headersSent) {
854
+ if (metricsModule) {
855
+ metricsModule.tileErrorsTotal.inc({ type: 'rendered', name: id });
856
+ }
817
857
  return res.status(500).send('Render failed');
818
858
  }
819
859
  }
@@ -905,7 +945,7 @@ async function handleTileRequest(
905
945
 
906
946
  // prettier-ignore
907
947
  return await respondImage(
908
- options, item, z, tileCenter[0], tileCenter[1], 0, 0, parsedTileSize, parsedTileSize, scale, format, res,
948
+ options, item, z, tileCenter[0], tileCenter[1], 0, 0, parsedTileSize, parsedTileSize, scale, format, res, null, 'tile', id,
909
949
  );
910
950
  }
911
951
 
@@ -944,8 +984,8 @@ async function handleStaticRequest(
944
984
  // eslint-disable-next-line security/detect-object-injection -- id is route parameter, validated by Express
945
985
  const item = repo[id];
946
986
 
947
- let parsedWidth = null;
948
- let parsedHeight = null;
987
+ let parsedWidth;
988
+ let parsedHeight;
949
989
  if (widthAndHeight) {
950
990
  const sizeMatch = widthAndHeight.match(/^(\d+)x(\d+)$/);
951
991
  if (sizeMatch) {
@@ -1012,7 +1052,7 @@ async function handleStaticRequest(
1012
1052
 
1013
1053
  // prettier-ignore
1014
1054
  return await respondImage(
1015
- options, item, z, x, y, bearing, pitch, parsedWidth, parsedHeight, scale, format, res, overlay, 'static',
1055
+ options, item, z, x, y, bearing, pitch, parsedWidth, parsedHeight, scale, format, res, overlay, 'static', id,
1016
1056
  );
1017
1057
  } else if (staticTypeMatch.groups.minx) {
1018
1058
  // Area Based Static Image
@@ -1052,7 +1092,7 @@ async function handleStaticRequest(
1052
1092
 
1053
1093
  // prettier-ignore
1054
1094
  return await respondImage(
1055
- options, item, z, x, y, bearing, pitch, parsedWidth, parsedHeight, scale, format, res, overlay, 'static',
1095
+ options, item, z, x, y, bearing, pitch, parsedWidth, parsedHeight, scale, format, res, overlay, 'static', id,
1056
1096
  );
1057
1097
  } else if (staticTypeMatch.groups.auto) {
1058
1098
  // Area Static Image
@@ -1111,7 +1151,7 @@ async function handleStaticRequest(
1111
1151
 
1112
1152
  // prettier-ignore
1113
1153
  return await respondImage(
1114
- options, item, z, x, y, bearing, pitch, parsedWidth, parsedHeight, scale, format, res, overlay, 'static',
1154
+ options, item, z, x, y, bearing, pitch, parsedWidth, parsedHeight, scale, format, res, overlay, 'static', id,
1115
1155
  );
1116
1156
  } else {
1117
1157
  return res.sendStatus(404);
@@ -1119,6 +1159,7 @@ async function handleStaticRequest(
1119
1159
  }
1120
1160
  const existingFonts = {};
1121
1161
  let maxScaleFactor = 2;
1162
+ let metricsModule = null;
1122
1163
 
1123
1164
  export const serve_rendered = {
1124
1165
  /**
@@ -1312,6 +1353,13 @@ export const serve_rendered = {
1312
1353
 
1313
1354
  const { publicUrl, verbose, fetchTimeout } = programOpts;
1314
1355
 
1356
+ // Cache metrics module if enabled (avoids per-request dynamic imports).
1357
+ // Guard prevents re-importing per style added. Safe because tests verify before production.
1358
+ if (programOpts.metrics && !metricsModule) {
1359
+ const m = await import('./metrics.js');
1360
+ metricsModule = m;
1361
+ }
1362
+
1315
1363
  const styleJSON = clone(style);
1316
1364
 
1317
1365
  // Global sparse flag for HTTP/remote sources (from config options)
@@ -1857,6 +1905,31 @@ export const serve_rendered = {
1857
1905
  maxPoolSize,
1858
1906
  );
1859
1907
  }
1908
+
1909
+ if (metricsModule) {
1910
+ map._metricsInterval = setInterval(() => {
1911
+ [map.renderers, map.renderersStatic].forEach((poolArr) => {
1912
+ poolArr.forEach((pool) => {
1913
+ if (!pool) return;
1914
+ try {
1915
+ const total = pool.size ?? 0;
1916
+ const available = pool.available ?? 0;
1917
+ metricsModule.renderPoolSize.set({ name: id }, total);
1918
+ metricsModule.renderPoolActive.set(
1919
+ { name: id },
1920
+ total - available,
1921
+ );
1922
+ metricsModule.renderPoolWaiting.set(
1923
+ { name: id },
1924
+ pool.pending ?? 0,
1925
+ );
1926
+ } catch (_) {
1927
+ /* pool may be mid-teardown */
1928
+ }
1929
+ });
1930
+ });
1931
+ }, 5000);
1932
+ }
1860
1933
  },
1861
1934
  /**
1862
1935
  * Removes an item from the repository.
@@ -1868,6 +1941,26 @@ export const serve_rendered = {
1868
1941
  // eslint-disable-next-line security/detect-object-injection -- id is function parameter for removal
1869
1942
  const item = repo[id];
1870
1943
  if (item) {
1944
+ if (item.map._metricsInterval) {
1945
+ clearInterval(item.map._metricsInterval);
1946
+ }
1947
+ Object.keys(item.map.sources || {}).forEach((sourceId) => {
1948
+ // eslint-disable-next-line security/detect-object-injection -- sourceId is from Object.keys() iteration
1949
+ const source = item.map.sources[sourceId];
1950
+ // eslint-disable-next-line security/detect-object-injection -- sourceId is from Object.keys() iteration
1951
+ const sourceType = item.map.sourceTypes[sourceId];
1952
+ if (
1953
+ sourceType === 'mbtiles' &&
1954
+ source &&
1955
+ typeof source.close === 'function'
1956
+ ) {
1957
+ source.close((err) => {
1958
+ if (err) {
1959
+ console.warn('Failed to close MBTiles source:', err);
1960
+ }
1961
+ });
1962
+ }
1963
+ });
1871
1964
  item.map.renderers.forEach((pool) => {
1872
1965
  pool.close();
1873
1966
  });
@@ -1879,25 +1972,59 @@ export const serve_rendered = {
1879
1972
  delete repo[id];
1880
1973
  },
1881
1974
  /**
1882
- * Removes all items from the repository.
1975
+ * Removes all items from the repository and closes owned local data sources.
1883
1976
  * @param {object} repo Repository object.
1884
- * @returns {void}
1977
+ * @returns {Promise<void>}
1885
1978
  */
1886
- clear: function (repo) {
1887
- Object.keys(repo).forEach((id) => {
1888
- // eslint-disable-next-line security/detect-object-injection -- id is from Object.keys() iteration
1889
- const item = repo[id];
1890
- if (item) {
1891
- item.map.renderers.forEach((pool) => {
1892
- pool.close();
1893
- });
1894
- item.map.renderersStatic.forEach((pool) => {
1895
- pool.close();
1896
- });
1897
- }
1898
- // eslint-disable-next-line security/detect-object-injection -- id is from Object.keys() iteration
1899
- delete repo[id];
1900
- });
1979
+ clear: async function (repo) {
1980
+ await Promise.all(
1981
+ Object.keys(repo).map(async (id) => {
1982
+ // eslint-disable-next-line security/detect-object-injection -- id is from Object.keys() iteration
1983
+ const item = repo[id];
1984
+ try {
1985
+ if (!item) {
1986
+ return;
1987
+ }
1988
+
1989
+ await Promise.all(
1990
+ Object.keys(item.map.sources || {}).map(async (sourceId) => {
1991
+ // eslint-disable-next-line security/detect-object-injection -- sourceId is from Object.keys() iteration
1992
+ const source = item.map.sources[sourceId];
1993
+ // eslint-disable-next-line security/detect-object-injection -- sourceId is from Object.keys() iteration
1994
+ const sourceType = item.map.sourceTypes[sourceId];
1995
+ if (
1996
+ sourceType === 'mbtiles' &&
1997
+ source &&
1998
+ typeof source.close === 'function'
1999
+ ) {
2000
+ await new Promise((resolve) => {
2001
+ source.close((err) => {
2002
+ if (err) {
2003
+ console.warn(
2004
+ `Failed to close MBTiles source "${sourceId}" while clearing rendered repo entry "${id}":`,
2005
+ err,
2006
+ );
2007
+ }
2008
+ resolve();
2009
+ });
2010
+ });
2011
+ }
2012
+ }),
2013
+ );
2014
+ item.map.renderers.forEach((pool) => {
2015
+ pool.close();
2016
+ });
2017
+ item.map.renderersStatic.forEach((pool) => {
2018
+ pool.close();
2019
+ });
2020
+ } catch (err) {
2021
+ console.warn(`Failed to clear rendered repo entry "${id}":`, err);
2022
+ } finally {
2023
+ // eslint-disable-next-line security/detect-object-injection -- id is from Object.keys() iteration
2024
+ delete repo[id];
2025
+ }
2026
+ }),
2027
+ );
1901
2028
  },
1902
2029
 
1903
2030
  /**