tileserver-gl-light 5.5.0-pre.5 → 5.5.0-pre.6

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
@@ -82,7 +82,10 @@ program
82
82
  )
83
83
  .option(
84
84
  '-V, --verbose [level]',
85
- 'More verbose output (can specify level 1-3, default 1)',
85
+ 'More verbose output (level 1-3)\n' +
86
+ '\t-V, --verbose, -V 1, or --verbose 1: Important operations\n' +
87
+ '\t-V 2 or --verbose 2: Detailed operations\n' +
88
+ '\t-V 3 or --verbose 3: All requests and debug info',
86
89
  (value) => {
87
90
  // If no value provided, return 1 (boolean true case)
88
91
  if (value === undefined || value === true) return 1;
@@ -92,6 +95,14 @@ program
92
95
  return isNaN(level) ? 1 : Math.min(Math.max(level, 1), 3);
93
96
  },
94
97
  )
98
+ .option(
99
+ '--fetch-timeout <ms>',
100
+ 'External fetch timeout in milliseconds for renderer HTTP requests (default 15000)',
101
+ (value) => {
102
+ const v = parseInt(value, 10);
103
+ return isNaN(v) ? 15000 : v;
104
+ },
105
+ )
95
106
  .option('-s, --silent', 'Less verbose output')
96
107
  .option('-l|--log_file <file>', 'output log file (defaults to standard out)')
97
108
  .option(
@@ -119,6 +130,7 @@ const startServer = (configPath, config) => {
119
130
  silent: opts.silent,
120
131
  logFile: opts.log_file,
121
132
  logFormat: opts.log_format,
133
+ fetchTimeout: opts.fetchTimeout,
122
134
  publicUrl,
123
135
  });
124
136
  };
@@ -246,7 +258,7 @@ const startWithInputFile = async (inputFile) => {
246
258
  }
247
259
  }
248
260
 
249
- if (opts.verbose) {
261
+ if (opts.verbose >= 1) {
250
262
  console.log(JSON.stringify(config, undefined, 2));
251
263
  } else {
252
264
  console.log('Run with --verbose to see the config file here.');
@@ -304,7 +316,7 @@ const startWithInputFile = async (inputFile) => {
304
316
  };
305
317
  }
306
318
 
307
- if (opts.verbose) {
319
+ if (opts.verbose >= 1) {
308
320
  console.log(JSON.stringify(config, undefined, 2));
309
321
  } else {
310
322
  console.log('Run with --verbose to see the config file here.');
@@ -18,7 +18,7 @@ class S3Source {
18
18
  * @param {boolean} [configRequestPayer] - Optional flag from config for requester pays buckets.
19
19
  * @param {string} [configRegion] - Optional AWS region from config.
20
20
  * @param {string} [s3UrlFormat] - Optional S3 URL format from config: 'aws' or 'custom'.
21
- * @param {boolean} [verbose] - Whether to show verbose logging.
21
+ * @param {number} [verbose] - Verbosity level (1-3). 1=important, 2=detailed, 3=debug/all requests.
22
22
  */
23
23
  constructor(
24
24
  s3Url,
@@ -147,7 +147,7 @@ class S3Source {
147
147
  * @param {string|null} endpoint - The custom endpoint URL, or null for default AWS S3.
148
148
  * @param {string} region - The AWS region.
149
149
  * @param {string} [profile] - Optional AWS credential profile name.
150
- * @param {boolean} [verbose] - Whether to show verbose logging.
150
+ * @param {number} [verbose] - Verbosity level (1-3). 1=important, 2=detailed, 3=debug/all requests.
151
151
  * @returns {S3Client} - Configured S3Client instance.
152
152
  */
153
153
  createS3Client(endpoint, region, profile, verbose) {
@@ -308,14 +308,18 @@ async function readFileBytes(fd, buffer, offset) {
308
308
  });
309
309
  }
310
310
 
311
+ // Cache for PMTiles objects to avoid creating multiple instances for the same URL
312
+ const pmtilesCache = new Map();
313
+
311
314
  /**
312
315
  * Opens a PMTiles file from local filesystem, HTTP URL, or S3 URL.
316
+ * Uses caching to avoid creating multiple PMTiles instances for the same file.
313
317
  * @param {string} filePath - The path to the PMTiles file.
314
318
  * @param {string} [s3Profile] - Optional AWS credential profile name.
315
319
  * @param {boolean} [requestPayer] - Optional flag for requester pays buckets.
316
320
  * @param {string} [s3Region] - Optional AWS region.
317
321
  * @param {string} [s3UrlFormat] - Optional S3 URL format: 'aws' or 'custom'.
318
- * @param {boolean} [verbose] - Whether to show verbose logging.
322
+ * @param {number} [verbose] - Verbosity level (1-3). 1=important, 2=detailed, 3=debug/all requests.
319
323
  * @returns {PMTiles} - A PMTiles instance.
320
324
  */
321
325
  export function openPMtiles(
@@ -326,6 +330,23 @@ export function openPMtiles(
326
330
  s3UrlFormat,
327
331
  verbose = 0,
328
332
  ) {
333
+ // Create a cache key that includes all parameters that affect the source
334
+ const cacheKey = JSON.stringify({
335
+ filePath,
336
+ s3Profile,
337
+ requestPayer,
338
+ s3Region,
339
+ s3UrlFormat,
340
+ });
341
+
342
+ // Check if we already have a PMTiles object for this configuration
343
+ if (pmtilesCache.has(cacheKey)) {
344
+ if (verbose >= 2) {
345
+ console.log(`Using cached PMTiles instance for: ${filePath}`);
346
+ }
347
+ return pmtilesCache.get(cacheKey);
348
+ }
349
+
329
350
  let pmtiles = undefined;
330
351
 
331
352
  if (isS3Url(filePath)) {
@@ -357,6 +378,9 @@ export function openPMtiles(
357
378
  pmtiles = new PMTiles(source);
358
379
  }
359
380
 
381
+ // Cache the PMTiles object
382
+ pmtilesCache.set(cacheKey, pmtiles);
383
+
360
384
  return pmtiles;
361
385
  }
362
386
 
package/src/serve_data.js CHANGED
@@ -58,7 +58,7 @@ export const serve_data = {
58
58
  * @returns {Promise<void>}
59
59
  */
60
60
  app.get('/:id/:z/:x/:y.:format', async (req, res) => {
61
- if (verbose) {
61
+ if (verbose >= 1) {
62
62
  console.log(
63
63
  `Handling tile request for: /data/%s/%s/%s/%s.%s`,
64
64
  String(req.params.id).replace(/\n|\r/g, ''),
@@ -109,10 +109,10 @@ export const serve_data = {
109
109
  x,
110
110
  y,
111
111
  );
112
- if (fetchTile == null && item.tileJSON.sparse) {
113
- return res.status(410).send();
114
- } else if (fetchTile == null) {
115
- return res.status(204).send();
112
+ if (fetchTile == null) {
113
+ // sparse=true (default) -> 404 (allows overzoom)
114
+ // sparse=false -> 204 (empty tile, no overzoom)
115
+ return res.status(item.sparse ? 404 : 204).send();
116
116
  }
117
117
 
118
118
  let data = fetchTile.data;
@@ -180,7 +180,7 @@ export const serve_data = {
180
180
  */
181
181
  app.get('/:id/elevation/:z/:x/:y', async (req, res, next) => {
182
182
  try {
183
- if (verbose) {
183
+ if (verbose >= 1) {
184
184
  console.log(
185
185
  `Handling elevation request for: /data/%s/elevation/%s/%s/%s`,
186
186
  String(req.params.id).replace(/\n|\r/g, ''),
@@ -261,7 +261,11 @@ export const serve_data = {
261
261
  xy[0],
262
262
  xy[1],
263
263
  );
264
- if (fetchTile == null) return res.status(204).send();
264
+ if (fetchTile == null) {
265
+ // sparse=true (default) -> 404 (allows overzoom)
266
+ // sparse=false -> 204 (empty tile, no overzoom)
267
+ return res.status(item.sparse ? 404 : 204).send();
268
+ }
265
269
 
266
270
  let data = fetchTile.data;
267
271
  var param = {
@@ -294,7 +298,7 @@ export const serve_data = {
294
298
  * @returns {Promise<void>}
295
299
  */
296
300
  app.get('/:id.json', (req, res) => {
297
- if (verbose) {
301
+ if (verbose >= 1) {
298
302
  console.log(
299
303
  `Handling tilejson request for: /data/%s.json`,
300
304
  String(req.params.id).replace(/\n|\r/g, ''),
@@ -331,7 +335,7 @@ export const serve_data = {
331
335
  * @param {string} id ID of the data source.
332
336
  * @param {object} programOpts - An object containing the program options
333
337
  * @param {string} programOpts.publicUrl Public URL for the data.
334
- * @param {boolean} programOpts.verbose Whether verbose logging should be used.
338
+ * @param {number} programOpts.verbose Verbosity level (1-3). 1=important, 2=detailed, 3=debug/all requests.
335
339
  * @returns {Promise<void>}
336
340
  */
337
341
  add: async function (options, repo, params, id, programOpts) {
@@ -359,7 +363,7 @@ export const serve_data = {
359
363
  }
360
364
  }
361
365
 
362
- if (verbose && verbose >= 1) {
366
+ if (verbose >= 1) {
363
367
  console.log(`[INFO] Loading data source '${id}' from: ${inputFile}`);
364
368
  }
365
369
 
@@ -381,7 +385,6 @@ export const serve_data = {
381
385
  tileJSON['format'] = 'pbf';
382
386
  tileJSON['encoding'] = params['encoding'];
383
387
  tileJSON['tileSize'] = params['tileSize'];
384
- tileJSON['sparse'] = params['sparse'];
385
388
 
386
389
  if (inputType === 'pmtiles') {
387
390
  source = openPMtiles(
@@ -415,12 +418,20 @@ export const serve_data = {
415
418
  tileJSON = options.dataDecoratorFunc(id, 'tilejson', tileJSON);
416
419
  }
417
420
 
421
+ // Determine sparse: per-source overrides global, then format-based default
422
+ // sparse=true -> 404 (allows overzoom)
423
+ // sparse=false -> 204 (empty tile, no overzoom)
424
+ // Default: vector tiles (pbf) -> false, raster tiles -> true
425
+ const isVector = tileJSON.format === 'pbf';
426
+ const sparse = params.sparse ?? options.sparse ?? !isVector;
427
+
418
428
  // eslint-disable-next-line security/detect-object-injection -- id is from config file data source names
419
429
  repo[id] = {
420
430
  tileJSON,
421
431
  publicUrl,
422
432
  source,
423
433
  sourceType,
434
+ sparse,
424
435
  };
425
436
  },
426
437
  };
package/src/serve_font.js CHANGED
@@ -36,7 +36,7 @@ export async function serve_font(options, allowedFonts, programOpts) {
36
36
  '',
37
37
  );
38
38
 
39
- if (verbose) {
39
+ if (verbose >= 1) {
40
40
  console.log(
41
41
  `Handling font request for: /fonts/%s/%s.pbf`,
42
42
  sFontStack,
@@ -86,7 +86,7 @@ export async function serve_font(options, allowedFonts, programOpts) {
86
86
  * @returns {void}
87
87
  */
88
88
  app.get('/fonts.json', (req, res) => {
89
- if (verbose) {
89
+ if (verbose >= 1) {
90
90
  console.log('Handling list font request for /fonts.json');
91
91
  }
92
92
  res.header('Content-type', 'application/json');
@@ -1094,7 +1094,7 @@ export const serve_rendered = {
1094
1094
  (!p1 && p2 === 'static') || (p1 === 'static' && p2 === 'raw')
1095
1095
  ? 'static'
1096
1096
  : 'tile';
1097
- if (verbose) {
1097
+ if (verbose >= 3) {
1098
1098
  console.log(
1099
1099
  `Handling rendered %s request for: /styles/%s%s/%s/%s/%s%s.%s`,
1100
1100
  requestType,
@@ -1154,7 +1154,7 @@ export const serve_rendered = {
1154
1154
  return res.sendStatus(404);
1155
1155
  }
1156
1156
  const tileSize = parseInt(req.params.tileSize, 10) || undefined;
1157
- if (verbose) {
1157
+ if (verbose >= 3) {
1158
1158
  console.log(
1159
1159
  `Handling rendered tilejson request for: /styles/%s%s.json`,
1160
1160
  req.params.tileSize
@@ -1205,11 +1205,16 @@ export const serve_rendered = {
1205
1205
  renderersStatic: [],
1206
1206
  sources: {},
1207
1207
  sourceTypes: {},
1208
+ sparseFlags: {},
1208
1209
  };
1209
1210
 
1210
- const { publicUrl, verbose } = programOpts;
1211
+ const { publicUrl, verbose, fetchTimeout } = programOpts;
1211
1212
 
1212
1213
  const styleJSON = clone(style);
1214
+
1215
+ // Global sparse flag for HTTP/remote sources (from config options)
1216
+ const globalSparse = options.sparse ?? true;
1217
+
1213
1218
  /**
1214
1219
  * Creates a pool of renderers.
1215
1220
  * @param {number} ratio Pixel ratio
@@ -1231,7 +1236,7 @@ export const serve_rendered = {
1231
1236
  ratio,
1232
1237
  request: async (req, callback) => {
1233
1238
  const protocol = req.url.split(':')[0];
1234
- if (verbose && verbose >= 3) {
1239
+ if (verbose >= 3) {
1235
1240
  console.log('Handling request:', req);
1236
1241
  }
1237
1242
  if (protocol === 'sprites') {
@@ -1287,22 +1292,18 @@ export const serve_rendered = {
1287
1292
  x,
1288
1293
  y,
1289
1294
  );
1290
- if (fetchTile == null && sourceInfo.sparse == true) {
1291
- if (verbose) {
1292
- console.log(
1293
- 'fetchTile warning on %s, sparse response',
1294
- req.url,
1295
- );
1295
+ if (fetchTile == null) {
1296
+ if (verbose >= 2) {
1297
+ console.log('fetchTile null on %s', req.url);
1296
1298
  }
1297
- callback();
1298
- return;
1299
- } else if (fetchTile == null) {
1300
- if (verbose) {
1301
- console.log(
1302
- 'fetchTile error on %s, serving empty response',
1303
- req.url,
1304
- );
1299
+ // eslint-disable-next-line security/detect-object-injection -- sourceId from internal style source names
1300
+ const sparse = map.sparseFlags[sourceId] ?? true;
1301
+ // sparse=true (default) -> return empty callback so MapLibre can overzoom
1302
+ if (sparse) {
1303
+ callback();
1304
+ return;
1305
1305
  }
1306
+ // sparse=false -> 204 (empty tile, no overzoom) - create blank response
1306
1307
  createEmptyResponse(
1307
1308
  sourceInfo.format,
1308
1309
  sourceInfo.color,
@@ -1341,40 +1342,58 @@ export const serve_rendered = {
1341
1342
 
1342
1343
  callback(null, response);
1343
1344
  } else if (protocol === 'http' || protocol === 'https') {
1344
- try {
1345
- // Add timeout to prevent hanging on unreachable hosts
1346
- const controller = new AbortController();
1347
- const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
1345
+ const controller = new AbortController();
1346
+ const timeoutMs = (fetchTimeout && Number(fetchTimeout)) || 15000;
1347
+ let timeoutId;
1348
1348
 
1349
+ try {
1350
+ timeoutId = setTimeout(() => controller.abort(), timeoutMs);
1349
1351
  const response = await fetch(req.url, {
1350
1352
  signal: controller.signal,
1351
1353
  });
1352
-
1353
1354
  clearTimeout(timeoutId);
1354
1355
 
1355
- // Handle 410 Gone as sparse response
1356
- if (response.status === 410) {
1357
- if (verbose) {
1356
+ // HTTP 204 No Content means "empty tile" - generate a blank tile
1357
+ if (response.status === 204) {
1358
+ const parts = url.parse(req.url);
1359
+ const extension = path.extname(parts.pathname).toLowerCase();
1360
+ // eslint-disable-next-line security/detect-object-injection -- extension is from path.extname, limited set
1361
+ const format = extensionToFormat[extension] || '';
1362
+ createEmptyResponse(format, '', callback);
1363
+ return;
1364
+ }
1365
+
1366
+ if (!response.ok) {
1367
+ if (verbose >= 2) {
1358
1368
  console.log(
1359
- 'fetchTile warning on %s, sparse response due to 410 Gone',
1369
+ 'fetchTile HTTP %d on %s, %s',
1370
+ response.status,
1360
1371
  req.url,
1372
+ globalSparse
1373
+ ? 'allowing overzoom'
1374
+ : 'creating empty tile',
1361
1375
  );
1362
1376
  }
1363
- callback();
1364
- return;
1365
- }
1366
1377
 
1367
- // Check for other non-ok responses
1368
- if (!response.ok) {
1369
- throw new Error(
1370
- `HTTP ${response.status}: ${response.statusText}`,
1371
- );
1378
+ if (globalSparse) {
1379
+ // sparse=true -> allow overzoom
1380
+ callback();
1381
+ return;
1382
+ }
1383
+
1384
+ // sparse=false (default) -> create empty tile
1385
+ const parts = url.parse(req.url);
1386
+ const extension = path.extname(parts.pathname).toLowerCase();
1387
+ // eslint-disable-next-line security/detect-object-injection -- extension is from path.extname, limited set
1388
+ const format = extensionToFormat[extension] || '';
1389
+ createEmptyResponse(format, '', callback);
1390
+ return;
1372
1391
  }
1373
1392
 
1374
1393
  const responseHeaders = response.headers;
1375
1394
  const responseData = await response.arrayBuffer();
1376
-
1377
1395
  const parsedResponse = {};
1396
+
1378
1397
  if (responseHeaders.get('last-modified')) {
1379
1398
  parsedResponse.modified = new Date(
1380
1399
  responseHeaders.get('last-modified'),
@@ -1392,8 +1411,7 @@ export const serve_rendered = {
1392
1411
  parsedResponse.data = Buffer.from(responseData);
1393
1412
  callback(null, parsedResponse);
1394
1413
  } catch (error) {
1395
- // Log DNS failures more prominently as they often indicate config issues
1396
- // Native fetch wraps DNS errors in error.cause
1414
+ // Log DNS failures
1397
1415
  if (error.cause?.code === 'ENOTFOUND') {
1398
1416
  console.error(
1399
1417
  `DNS RESOLUTION FAILED for ${req.url}. ` +
@@ -1402,20 +1420,27 @@ export const serve_rendered = {
1402
1420
  );
1403
1421
  }
1404
1422
 
1405
- // Handle AbortController timeout
1423
+ // Log timeout
1406
1424
  if (error.name === 'AbortError') {
1407
1425
  console.error(
1408
1426
  `FETCH TIMEOUT for ${req.url}. ` +
1409
- `The request took longer than 10 seconds to complete.`,
1427
+ `The request took longer than ${timeoutMs} ms to complete.`,
1410
1428
  );
1411
1429
  }
1412
1430
 
1413
- // For all other errors (e.g., network errors, 404, 500, etc.) return empty content.
1431
+ // Log all other errors
1414
1432
  console.error(
1415
1433
  `Error fetching remote URL ${req.url}:`,
1416
1434
  error.message || error,
1417
1435
  );
1418
1436
 
1437
+ if (globalSparse) {
1438
+ // sparse=true -> allow overzoom
1439
+ callback();
1440
+ return;
1441
+ }
1442
+
1443
+ // sparse=false (default) -> create empty tile
1419
1444
  const parts = url.parse(req.url);
1420
1445
  const extension = path.extname(parts.pathname).toLowerCase();
1421
1446
  // eslint-disable-next-line security/detect-object-injection -- extension is from path.extname, limited set
@@ -1497,7 +1522,7 @@ export const serve_rendered = {
1497
1522
 
1498
1523
  // Remove (flatten) 3D buildings
1499
1524
  if (layer.paint['fill-extrusion-height']) {
1500
- if (verbose) {
1525
+ if (verbose >= 1) {
1501
1526
  console.warn(
1502
1527
  `Warning: Layer '${layerIdForWarning}' in style '${id}' has property 'fill-extrusion-height'. ` +
1503
1528
  `3D extrusion may appear distorted or misleading when rendered as a static image due to camera angle limitations. ` +
@@ -1508,7 +1533,7 @@ export const serve_rendered = {
1508
1533
  layer.paint['fill-extrusion-height'] = 0;
1509
1534
  }
1510
1535
  if (layer.paint['fill-extrusion-base']) {
1511
- if (verbose) {
1536
+ if (verbose >= 1) {
1512
1537
  console.warn(
1513
1538
  `Warning: Layer '${layerIdForWarning}' in style '${id}' has property 'fill-extrusion-base'. ` +
1514
1539
  `3D extrusion may appear distorted or misleading when rendered as a static image due to camera angle limitations. ` +
@@ -1528,7 +1553,7 @@ export const serve_rendered = {
1528
1553
 
1529
1554
  for (const prop of hillshadePropertiesToRemove) {
1530
1555
  if (prop in layer.paint) {
1531
- if (verbose) {
1556
+ if (verbose >= 1) {
1532
1557
  console.warn(
1533
1558
  `Warning: Layer '${layerIdForWarning}' in style '${id}' has property '${prop}'. ` +
1534
1559
  `This property is not supported by MapLibre Native. ` +
@@ -1543,7 +1568,7 @@ export const serve_rendered = {
1543
1568
 
1544
1569
  // --- Remove 'hillshade-shadow-color' if it is an array. It can only be a string in MapLibre Native ---
1545
1570
  if (Array.isArray(layer.paint['hillshade-shadow-color'])) {
1546
- if (verbose) {
1571
+ if (verbose >= 1) {
1547
1572
  console.warn(
1548
1573
  `Warning: Layer '${layerIdForWarning}' in style '${id}' has property 'hillshade-shadow-color'. ` +
1549
1574
  `An array value is not supported by MapLibre Native for this property (expected string/color). ` +
@@ -1589,7 +1614,6 @@ export const serve_rendered = {
1589
1614
 
1590
1615
  for (const name of Object.keys(styleJSON.sources)) {
1591
1616
  let sourceType;
1592
- let sparse;
1593
1617
  // eslint-disable-next-line security/detect-object-injection -- name is from style sources object keys
1594
1618
  let source = styleJSON.sources[name];
1595
1619
  let url = source.url;
@@ -1620,7 +1644,6 @@ export const serve_rendered = {
1620
1644
  if (dataInfo.inputFile) {
1621
1645
  inputFile = dataInfo.inputFile;
1622
1646
  sourceType = dataInfo.fileType;
1623
- sparse = dataInfo.sparse;
1624
1647
  s3Profile = dataInfo.s3Profile;
1625
1648
  requestPayer = dataInfo.requestPayer;
1626
1649
  s3Region = dataInfo.s3Region;
@@ -1664,7 +1687,6 @@ export const serve_rendered = {
1664
1687
  const type = source.type;
1665
1688
  Object.assign(source, metadata);
1666
1689
  source.type = type;
1667
- source.sparse = sparse;
1668
1690
  source.tiles = [
1669
1691
  // meta url which will be detected when requested
1670
1692
  `pmtiles://${name}/{z}/{x}/{y}.${metadata.format || 'pbf'}`,
@@ -1683,6 +1705,13 @@ export const serve_rendered = {
1683
1705
  tileJSON.attribution += source.attribution;
1684
1706
  }
1685
1707
  }
1708
+
1709
+ // Set sparse flag: user config overrides format-based default
1710
+ // Vector tiles (pbf) default to false (204), raster tiles default to true (404)
1711
+ const isVector = metadata.format === 'pbf';
1712
+ // eslint-disable-next-line security/detect-object-injection -- name is from style sources object keys
1713
+ map.sparseFlags[name] =
1714
+ dataInfo.sparse ?? options.sparse ?? !isVector;
1686
1715
  } else {
1687
1716
  // MBTiles does not support remote URLs
1688
1717
 
@@ -1708,7 +1737,6 @@ export const serve_rendered = {
1708
1737
  const type = source.type;
1709
1738
  Object.assign(source, info);
1710
1739
  source.type = type;
1711
- source.sparse = sparse;
1712
1740
  source.tiles = [
1713
1741
  // meta url which will be detected when requested
1714
1742
  `mbtiles://${name}/{z}/{x}/{y}.${info.format || 'pbf'}`,
@@ -1731,6 +1759,13 @@ export const serve_rendered = {
1731
1759
  tileJSON.attribution += source.attribution;
1732
1760
  }
1733
1761
  }
1762
+
1763
+ // Set sparse flag: user config overrides format-based default
1764
+ // Vector tiles (pbf) default to false (204), raster tiles default to true (404)
1765
+ const isVector = info.format === 'pbf';
1766
+ // eslint-disable-next-line security/detect-object-injection -- name is from style sources object keys
1767
+ map.sparseFlags[name] =
1768
+ dataInfo.sparse ?? options.sparse ?? !isVector;
1734
1769
  }
1735
1770
  }
1736
1771
  }
@@ -35,7 +35,7 @@ export const serve_style = {
35
35
  */
36
36
  app.get('/:id/style.json', (req, res, next) => {
37
37
  const { id } = req.params;
38
- if (verbose) {
38
+ if (verbose >= 1) {
39
39
  console.log(
40
40
  'Handling style request for: /styles/%s/style.json',
41
41
  String(id).replace(/\n|\r/g, ''),
@@ -95,7 +95,7 @@ export const serve_style = {
95
95
  const sanitizedFormat = format
96
96
  ? '.' + String(format).replace(/\n|\r/g, '')
97
97
  : '';
98
- if (verbose) {
98
+ if (verbose >= 1) {
99
99
  console.log(
100
100
  `Handling sprite request for: /styles/%s/sprite/%s%s%s`,
101
101
  sanitizedId,
@@ -108,7 +108,7 @@ export const serve_style = {
108
108
  const item = repo[id];
109
109
  const validatedFormat = allowedSpriteFormats(format);
110
110
  if (!item || !validatedFormat) {
111
- if (verbose)
111
+ if (verbose >= 1)
112
112
  console.error(
113
113
  `Sprite item or format not found for: /styles/%s/sprite/%s%s%s`,
114
114
  sanitizedId,
@@ -123,7 +123,7 @@ export const serve_style = {
123
123
  );
124
124
  const spriteScale = allowedSpriteScales(scale);
125
125
  if (!sprite || spriteScale === null) {
126
- if (verbose)
126
+ if (verbose >= 1)
127
127
  console.error(
128
128
  `Bad Sprite ID or Scale for: /styles/%s/sprite/%s%s%s`,
129
129
  sanitizedId,
@@ -147,7 +147,7 @@ export const serve_style = {
147
147
 
148
148
  const sanitizedSpritePath = sprite.path.replace(/^(\.\.\/)+/, '');
149
149
  const filename = `${sanitizedSpritePath}${spriteScale}.${validatedFormat}`;
150
- if (verbose) console.log(`Loading sprite from: %s`, filename);
150
+ if (verbose >= 1) console.log(`Loading sprite from: %s`, filename);
151
151
  try {
152
152
  const data = await readFile(filename);
153
153
 
@@ -156,7 +156,7 @@ export const serve_style = {
156
156
  } else if (validatedFormat === 'png') {
157
157
  res.header('Content-type', 'image/png');
158
158
  }
159
- if (verbose)
159
+ if (verbose >= 1)
160
160
  console.log(
161
161
  `Responding with sprite data for /styles/%s/sprite/%s%s%s`,
162
162
  sanitizedId,
@@ -167,7 +167,7 @@ export const serve_style = {
167
167
  res.set({ 'Last-Modified': item.lastModified });
168
168
  return res.send(data);
169
169
  } catch (err) {
170
- if (verbose) {
170
+ if (verbose >= 1) {
171
171
  console.error(
172
172
  'Sprite load error: %s, Error: %s',
173
173
  filename,
@@ -217,7 +217,28 @@ export const serve_style = {
217
217
  const styleFile = path.resolve(options.paths.styles, params.style);
218
218
  const styleJSON = clone(style);
219
219
 
220
- const validationErrors = validateStyleMin(styleJSON);
220
+ // Sanitize style for validation: remove non-spec properties (e.g., 'sparse')
221
+ // so that validateStyleMin doesn't reject valid styles containing our custom flags.
222
+ const styleForValidation = clone(styleJSON);
223
+ if (styleForValidation.sources) {
224
+ for (const name of Object.keys(styleForValidation.sources)) {
225
+ if (
226
+ // eslint-disable-next-line security/detect-object-injection -- name is from Object.keys of styleForValidation.sources
227
+ styleForValidation.sources[name] &&
228
+ // eslint-disable-next-line security/detect-object-injection -- name is from Object.keys of styleForValidation.sources
229
+ 'sparse' in styleForValidation.sources[name]
230
+ ) {
231
+ try {
232
+ // eslint-disable-next-line security/detect-object-injection -- name is from Object.keys of styleForValidation.sources
233
+ delete styleForValidation.sources[name].sparse;
234
+ } catch (_err) {
235
+ // ignore any deletion errors and continue validation
236
+ }
237
+ }
238
+ }
239
+ }
240
+
241
+ const validationErrors = validateStyleMin(styleForValidation);
221
242
  if (validationErrors.length > 0) {
222
243
  console.log(`The file "${params.style}" is not a valid style file:`);
223
244
  for (const err of validationErrors) {