tileserver-gl 5.7.0-pre.0 → 5.7.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +12 -1
  2. package/docs/config.rst +43 -2
  3. package/docs/usage.rst +2 -1
  4. package/package.json +33 -28
  5. package/public/resources/elevation-control.js +8 -4
  6. package/public/resources/maplibre-gl-inspect.mjs +2286 -0
  7. package/public/resources/maplibre-gl-inspect.mjs.map +1 -0
  8. package/public/resources/maplibre-gl-shared.mjs +6 -0
  9. package/public/resources/maplibre-gl-shared.mjs.map +1 -0
  10. package/public/resources/maplibre-gl-worker.mjs +10 -0
  11. package/public/resources/maplibre-gl-worker.mjs.map +1 -0
  12. package/public/resources/maplibre-gl.css +1 -1
  13. package/public/resources/maplibre-gl.mjs +819 -0
  14. package/public/resources/maplibre-gl.mjs.map +1 -0
  15. package/public/templates/data.tmpl +12 -6
  16. package/public/templates/viewer.tmpl +12 -8
  17. package/src/main.js +53 -7
  18. package/src/render_mode.js +38 -0
  19. package/src/serve_data.js +29 -11
  20. package/src/serve_rendered.js +317 -249
  21. package/src/server.js +10 -10
  22. package/src/utils.js +51 -0
  23. package/test/data_decorator.js +156 -0
  24. package/test/fixtures/data-decorator-async.js +17 -0
  25. package/test/fixtures/data-decorator.js +29 -0
  26. package/test/fixtures/visual/static-512px-path-alignment-2x.png +0 -0
  27. package/test/fixtures/visual/static-marker-low-zoom-large-viewport.png +0 -0
  28. package/test/render_mode.js +48 -0
  29. package/test/sparse.js +411 -0
  30. package/test/static.js +20 -0
  31. package/test/static_images.js +10 -0
  32. package/test/tiles_data_compression.js +145 -0
  33. package/public/resources/maplibre-gl-inspect.js +0 -2934
  34. package/public/resources/maplibre-gl-inspect.js.map +0 -1
  35. package/public/resources/maplibre-gl.js +0 -59
  36. package/public/resources/maplibre-gl.js.map +0 -1
@@ -35,9 +35,12 @@ import {
35
35
  fixTileJSONCenter,
36
36
  fetchTileData,
37
37
  readFile,
38
+ parseOptionalBoolean,
39
+ resolveSparse,
38
40
  } from './utils.js';
39
41
  import { openPMtiles, getPMtilesInfo } from './pmtiles_adapter.js';
40
42
  import { renderOverlay, renderWatermark, renderAttribution } from './render.js';
43
+ import { getMapLibreRenderZoom, getStaticOverlayZoom } from './render_mode.js';
41
44
  import fsp from 'node:fs/promises';
42
45
  import { existsP, gunzipP } from './promises.js';
43
46
  import { openMbTilesWrapper } from './mbtiles_wrapper.js';
@@ -66,6 +69,12 @@ const PATH_PATTERN =
66
69
 
67
70
  const mercator = new SphericalMercator();
68
71
 
72
+ // Consecutive failed renders per renderer. A single render error is usually a
73
+ // style or tile problem, but a renderer that keeps failing is itself broken and
74
+ // has to be replaced.
75
+ const rendererFailures = new WeakMap();
76
+ const MAX_CONSECUTIVE_RENDER_FAILURES = 3;
77
+
69
78
  mlgl.on('message', (e) => {
70
79
  if (e.severity === 'WARNING' || e.severity === 'ERROR') {
71
80
  console.log('mlgl:', e);
@@ -614,10 +623,39 @@ async function respondImage(
614
623
  return;
615
624
  }
616
625
 
626
+ // Hand the renderer back to the pool exactly once. A renderer that is
627
+ // neither released nor removed stays in the pool's busy list forever, so the
628
+ // pool shrinks and a replacement mlgl.Map is built to take its place.
629
+ let returned = false;
630
+ const releaseRenderer = () => {
631
+ if (returned) {
632
+ return;
633
+ }
634
+ returned = true;
635
+ try {
636
+ pool.release(renderer);
637
+ } catch (e) {
638
+ console.error('Error releasing renderer:', e);
639
+ }
640
+ };
641
+ const discardRenderer = (reason) => {
642
+ if (returned) {
643
+ return;
644
+ }
645
+ returned = true;
646
+ console.error(`Discarding renderer: ${reason}`);
647
+ try {
648
+ pool.removeBadObject(renderer);
649
+ } catch (e) {
650
+ console.error('Error removing bad renderer:', e);
651
+ }
652
+ };
653
+
617
654
  if (!renderer) {
618
655
  console.error(
619
656
  'Renderer is null - likely crashed or failed to initialize',
620
657
  );
658
+ discardRenderer('renderer is null');
621
659
  if (!res.headersSent) {
622
660
  if (metricsModule) {
623
661
  metricsModule.tileErrorsTotal.inc({ type: 'rendered', name: id });
@@ -629,12 +667,7 @@ async function respondImage(
629
667
 
630
668
  // Validate renderer has required methods (basic health check)
631
669
  if (typeof renderer.render !== 'function') {
632
- console.error('Renderer is invalid - missing render method');
633
- try {
634
- pool.removeBadObject(renderer);
635
- } catch (e) {
636
- console.error('Error removing bad renderer:', e);
637
- }
670
+ discardRenderer('missing render method');
638
671
  if (!res.headersSent) {
639
672
  if (metricsModule) {
640
673
  metricsModule.tileErrorsTotal.inc({ type: 'rendered', name: id });
@@ -644,13 +677,7 @@ async function respondImage(
644
677
  return;
645
678
  }
646
679
 
647
- // For 512px tiles, use the actual maplibre-native zoom. For 256px tiles, use zoom - 1
648
- let mlglZ;
649
- if (width === 512) {
650
- mlglZ = Math.max(0, z);
651
- } else {
652
- mlglZ = Math.max(0, z - 1);
653
- }
680
+ const mlglZ = getMapLibreRenderZoom(z, width, mode);
654
681
 
655
682
  const params = {
656
683
  zoom: mlglZ,
@@ -675,13 +702,7 @@ async function respondImage(
675
702
 
676
703
  // Set a timeout for the render operation to detect hung renderers
677
704
  const renderTimeout = setTimeout(() => {
678
- console.error('Renderer timeout - destroying hung renderer');
679
-
680
- try {
681
- pool.removeBadObject(renderer);
682
- } catch (e) {
683
- console.error('Error removing timed-out renderer:', e);
684
- }
705
+ discardRenderer('render timed out - renderer is hung');
685
706
 
686
707
  if (!res.headersSent) {
687
708
  res.status(503).send('Renderer timeout');
@@ -693,16 +714,23 @@ async function respondImage(
693
714
  clearTimeout(renderTimeout);
694
715
 
695
716
  if (res.headersSent) {
696
- // Timeout already fired and sent response, don't process
717
+ // The timeout already fired and answered the request. It discarded
718
+ // the renderer, so this is a no-op unless some other path responded.
719
+ releaseRenderer();
697
720
  return;
698
721
  }
699
722
 
700
723
  if (err) {
724
+ // Keep a renderer that hit a one-off error: discarding on every
725
+ // failure churns mlgl.Map instances for what is usually a style or
726
+ // tile problem. Replace it only once it fails repeatedly.
701
727
  console.error('Render error:', err);
702
- try {
703
- pool.removeBadObject(renderer);
704
- } catch (e) {
705
- console.error('Error removing failed renderer:', e);
728
+ const failures = (rendererFailures.get(renderer) || 0) + 1;
729
+ rendererFailures.set(renderer, failures);
730
+ if (failures >= MAX_CONSECUTIVE_RENDER_FAILURES) {
731
+ discardRenderer(`${failures} consecutive render failures`);
732
+ } else {
733
+ releaseRenderer();
706
734
  }
707
735
  if (!res.headersSent) {
708
736
  if (metricsModule) {
@@ -716,8 +744,8 @@ async function respondImage(
716
744
  return;
717
745
  }
718
746
 
719
- // Only release if render was successful
720
- pool.release(renderer);
747
+ rendererFailures.delete(renderer);
748
+ releaseRenderer();
721
749
 
722
750
  const image = sharp(data, {
723
751
  raw: {
@@ -845,11 +873,7 @@ async function respondImage(
845
873
  } catch (error) {
846
874
  clearTimeout(renderTimeout);
847
875
  console.error('Unexpected error during render:', error);
848
- try {
849
- pool.removeBadObject(renderer);
850
- } catch (e) {
851
- console.error('Error removing renderer after error:', e);
852
- }
876
+ discardRenderer('render call threw');
853
877
  if (!res.headersSent) {
854
878
  if (metricsModule) {
855
879
  metricsModule.tileErrorsTotal.inc({ type: 'rendered', name: id });
@@ -1045,9 +1069,10 @@ async function handleStaticRequest(
1045
1069
 
1046
1070
  const paths = extractPathsFromQuery(req.query, transformer);
1047
1071
  const markers = extractMarkersFromQuery(req.query, options, transformer);
1072
+ const overlayZoom = getStaticOverlayZoom(z, parsedWidth, parsedHeight);
1048
1073
  // prettier-ignore
1049
1074
  const overlay = await renderOverlay(
1050
- z, x, y, bearing, pitch, parsedWidth, parsedHeight, scale, paths, markers, req.query,
1075
+ overlayZoom, x, y, bearing, pitch, parsedWidth, parsedHeight, scale, paths, markers, req.query,
1051
1076
  );
1052
1077
 
1053
1078
  // prettier-ignore
@@ -1362,9 +1387,6 @@ export const serve_rendered = {
1362
1387
 
1363
1388
  const styleJSON = clone(style);
1364
1389
 
1365
- // Global sparse flag for HTTP/remote sources (from config options)
1366
- const globalSparse = options.sparse ?? true;
1367
-
1368
1390
  /**
1369
1391
  * Creates a pool of renderers.
1370
1392
  * @param {number} ratio Pixel ratio
@@ -1385,241 +1407,267 @@ export const serve_rendered = {
1385
1407
  mode,
1386
1408
  ratio,
1387
1409
  request: async (req, callback) => {
1388
- const protocol = req.url.split(':')[0];
1389
- if (verbose >= 3) {
1390
- console.log('Handling request:', req);
1391
- }
1392
- if (protocol === 'sprites') {
1393
- // eslint-disable-next-line security/detect-object-injection -- protocol is 'sprites', validated above
1394
- const dir = options.paths[protocol];
1395
- const file = decodeURIComponent(req.url).substring(
1396
- protocol.length + 3,
1397
- );
1398
- readFile(path.join(dir, file))
1399
- .then((data) => {
1400
- callback(null, { data: data });
1401
- })
1402
- .catch((err) => {
1403
- callback(err, null);
1404
- });
1405
- } else if (protocol === 'fonts') {
1406
- const parts = req.url.split('/');
1407
- const fontstack = decodeURIComponent(parts[2]);
1408
- const range = parts[3].split('.')[0];
1409
-
1410
- try {
1411
- const concatenated = await getFontsPbf(
1412
- null,
1413
- // eslint-disable-next-line security/detect-object-injection -- protocol is 'fonts', validated above
1414
- options.paths[protocol],
1415
- fontstack,
1416
- range,
1417
- existingFonts,
1418
- );
1419
- callback(null, { data: concatenated });
1420
- } catch (err) {
1421
- callback(err, { data: null });
1410
+ // MapLibre Native waits indefinitely for a resource whose callback
1411
+ // never fires, wedging the renderer at 0% CPU until the render timeout
1412
+ // recycles it. Guarantee exactly one call on every path, including throws.
1413
+ let settled = false;
1414
+ const respond = (err, response) => {
1415
+ if (settled) {
1416
+ return;
1422
1417
  }
1423
- } else if (protocol === 'mbtiles' || protocol === 'pmtiles') {
1424
- const parts = req.url.split('/');
1425
- const sourceId = parts[2];
1426
- // eslint-disable-next-line security/detect-object-injection -- sourceId from internal style source names
1427
- const source = map.sources[sourceId];
1428
- // eslint-disable-next-line security/detect-object-injection -- sourceId from internal style source names
1429
- const sourceType = map.sourceTypes[sourceId];
1430
- // eslint-disable-next-line security/detect-object-injection -- sourceId from internal style source names
1431
- const sourceInfo = styleJSON.sources[sourceId];
1432
-
1433
- const z = parts[3] | 0;
1434
- const x = parts[4] | 0;
1435
- const y = parts[5].split('.')[0] | 0;
1436
- const format = parts[5].split('.')[1];
1437
-
1438
- const fetchTile = await fetchTileData(
1439
- source,
1440
- sourceType,
1441
- z,
1442
- x,
1443
- y,
1444
- );
1445
- if (fetchTile == null) {
1446
- if (verbose >= 2) {
1447
- console.log('fetchTile null on %s', req.url);
1418
+ settled = true;
1419
+ callback(err, response);
1420
+ };
1421
+
1422
+ try {
1423
+ const protocol = req.url.split(':')[0];
1424
+ if (verbose >= 3) {
1425
+ console.log('Handling request:', req);
1426
+ }
1427
+ if (protocol === 'sprites') {
1428
+ // eslint-disable-next-line security/detect-object-injection -- protocol is 'sprites', validated above
1429
+ const dir = options.paths[protocol];
1430
+ const file = decodeURIComponent(req.url).substring(
1431
+ protocol.length + 3,
1432
+ );
1433
+ readFile(path.join(dir, file))
1434
+ .then((data) => {
1435
+ respond(null, { data: data });
1436
+ })
1437
+ .catch((err) => {
1438
+ respond(err, null);
1439
+ });
1440
+ } else if (protocol === 'fonts') {
1441
+ const parts = req.url.split('/');
1442
+ const fontstack = decodeURIComponent(parts[2]);
1443
+ const range = parts[3].split('.')[0];
1444
+
1445
+ try {
1446
+ const concatenated = await getFontsPbf(
1447
+ null,
1448
+ // eslint-disable-next-line security/detect-object-injection -- protocol is 'fonts', validated above
1449
+ options.paths[protocol],
1450
+ fontstack,
1451
+ range,
1452
+ existingFonts,
1453
+ );
1454
+ respond(null, { data: concatenated });
1455
+ } catch (err) {
1456
+ respond(err, { data: null });
1448
1457
  }
1458
+ } else if (protocol === 'mbtiles' || protocol === 'pmtiles') {
1459
+ const parts = req.url.split('/');
1460
+ const sourceId = parts[2];
1461
+ // eslint-disable-next-line security/detect-object-injection -- sourceId from internal style source names
1462
+ const source = map.sources[sourceId];
1463
+ // eslint-disable-next-line security/detect-object-injection -- sourceId from internal style source names
1464
+ const sourceType = map.sourceTypes[sourceId];
1449
1465
  // eslint-disable-next-line security/detect-object-injection -- sourceId from internal style source names
1450
- const sparse = map.sparseFlags[sourceId] ?? true;
1451
- // sparse=true (default) -> return empty callback so MapLibre can overzoom
1452
- if (sparse) {
1453
- callback();
1466
+ const sourceInfo = styleJSON.sources[sourceId];
1467
+
1468
+ const z = parts[3] | 0;
1469
+ const x = parts[4] | 0;
1470
+ const y = parts[5].split('.')[0] | 0;
1471
+ const format = parts[5].split('.')[1];
1472
+
1473
+ const fetchTile = await fetchTileData(
1474
+ source,
1475
+ sourceType,
1476
+ z,
1477
+ x,
1478
+ y,
1479
+ );
1480
+ if (fetchTile == null) {
1481
+ if (verbose >= 2) {
1482
+ console.log('fetchTile null on %s', req.url);
1483
+ }
1484
+ // eslint-disable-next-line security/detect-object-injection -- sourceId from internal style source names
1485
+ const sourceSparse = map.sparseFlags[sourceId];
1486
+ // Metadata was already folded into sparseFlags at load time.
1487
+ const sparse = resolveSparse({
1488
+ perSource: sourceSparse,
1489
+ globalOption: options.sparse,
1490
+ isVector: format === 'pbf',
1491
+ });
1492
+ // sparse=true -> return empty callback so MapLibre can overzoom
1493
+ if (sparse) {
1494
+ respond();
1495
+ return;
1496
+ }
1497
+ // sparse=false -> 204 (empty tile, no overzoom) - create blank response
1498
+ createEmptyResponse(
1499
+ sourceInfo.format,
1500
+ sourceInfo.color,
1501
+ respond,
1502
+ );
1454
1503
  return;
1455
1504
  }
1456
- // sparse=false -> 204 (empty tile, no overzoom) - create blank response
1457
- createEmptyResponse(
1458
- sourceInfo.format,
1459
- sourceInfo.color,
1460
- callback,
1461
- );
1462
- return;
1463
- }
1464
1505
 
1465
- const response = {};
1466
- response.data = fetchTile.data;
1467
- let headers = fetchTile.headers;
1468
-
1469
- if (headers['Last-Modified']) {
1470
- response.modified = new Date(headers['Last-Modified']);
1471
- }
1506
+ const response = {};
1507
+ response.data = fetchTile.data;
1508
+ let headers = fetchTile.headers;
1472
1509
 
1473
- if (format === 'pbf') {
1474
- let isGzipped =
1475
- response.data
1476
- .slice(0, 2)
1477
- .indexOf(Buffer.from([0x1f, 0x8b])) === 0;
1478
- if (isGzipped) {
1479
- response.data = await gunzipP(response.data);
1510
+ if (headers['Last-Modified']) {
1511
+ response.modified = new Date(headers['Last-Modified']);
1480
1512
  }
1481
- if (options.dataDecoratorFunc) {
1482
- response.data = options.dataDecoratorFunc(
1483
- sourceId,
1484
- 'data',
1485
- response.data,
1486
- z,
1487
- x,
1488
- y,
1489
- );
1513
+
1514
+ if (format === 'pbf') {
1515
+ let isGzipped =
1516
+ response.data
1517
+ .slice(0, 2)
1518
+ .indexOf(Buffer.from([0x1f, 0x8b])) === 0;
1519
+ if (isGzipped) {
1520
+ response.data = await gunzipP(response.data);
1521
+ }
1522
+ if (options.dataDecoratorFunc) {
1523
+ response.data = await options.dataDecoratorFunc(
1524
+ sourceId,
1525
+ 'data',
1526
+ response.data,
1527
+ z,
1528
+ x,
1529
+ y,
1530
+ );
1531
+ }
1490
1532
  }
1491
- }
1492
1533
 
1493
- callback(null, response);
1494
- } else if (protocol === 'http' || protocol === 'https') {
1495
- const controller = new AbortController();
1496
- const timeoutMs = (fetchTimeout && Number(fetchTimeout)) || 15000;
1497
- let timeoutId;
1534
+ respond(null, response);
1535
+ } else if (protocol === 'http' || protocol === 'https') {
1536
+ const controller = new AbortController();
1537
+ const timeoutMs =
1538
+ (fetchTimeout && Number(fetchTimeout)) || 15000;
1539
+ let timeoutId;
1498
1540
 
1499
- try {
1500
- timeoutId = setTimeout(() => controller.abort(), timeoutMs);
1501
- const response = await fetch(req.url, {
1502
- signal: controller.signal,
1541
+ const extension = path
1542
+ .extname(url.parse(req.url).pathname)
1543
+ .toLowerCase();
1544
+ // eslint-disable-next-line security/detect-object-injection -- extension is from path.extname, limited set
1545
+ const format = extensionToFormat[extension] || '';
1546
+ // A raw remote URL has neither per-source config nor metadata.
1547
+ const sparse = resolveSparse({
1548
+ globalOption: options.sparse,
1549
+ isVector: extension === '.pbf',
1503
1550
  });
1504
- clearTimeout(timeoutId);
1505
-
1506
- // HTTP 204 No Content means "empty tile" - generate a blank tile
1507
- if (response.status === 204) {
1508
- const parts = url.parse(req.url);
1509
- const extension = path.extname(parts.pathname).toLowerCase();
1510
- // eslint-disable-next-line security/detect-object-injection -- extension is from path.extname, limited set
1511
- const format = extensionToFormat[extension] || '';
1512
- createEmptyResponse(format, '', callback);
1513
- return;
1514
- }
1515
1551
 
1516
- if (!response.ok) {
1517
- if (verbose >= 2) {
1518
- console.log(
1519
- 'fetchTile HTTP %d on %s, %s',
1520
- response.status,
1521
- req.url,
1522
- globalSparse
1523
- ? 'allowing overzoom'
1524
- : 'creating empty tile',
1525
- );
1552
+ try {
1553
+ timeoutId = setTimeout(() => controller.abort(), timeoutMs);
1554
+ const response = await fetch(req.url, {
1555
+ signal: controller.signal,
1556
+ });
1557
+ clearTimeout(timeoutId);
1558
+
1559
+ // HTTP 204 No Content means "empty tile" - generate a blank tile
1560
+ if (response.status === 204) {
1561
+ createEmptyResponse(format, '', respond);
1562
+ return;
1526
1563
  }
1527
1564
 
1528
- if (globalSparse) {
1529
- // sparse=true -> allow overzoom
1530
- callback();
1565
+ if (!response.ok) {
1566
+ if (verbose >= 2) {
1567
+ console.log(
1568
+ 'fetchTile HTTP %d on %s, %s',
1569
+ response.status,
1570
+ req.url,
1571
+ sparse ? 'allowing overzoom' : 'creating empty tile',
1572
+ );
1573
+ }
1574
+
1575
+ if (sparse) {
1576
+ // sparse=true -> allow overzoom
1577
+ respond();
1578
+ return;
1579
+ }
1580
+
1581
+ // sparse=false -> create empty tile
1582
+ createEmptyResponse(format, '', respond);
1531
1583
  return;
1532
1584
  }
1533
1585
 
1534
- // sparse=false (default) -> create empty tile
1535
- const parts = url.parse(req.url);
1536
- const extension = path.extname(parts.pathname).toLowerCase();
1537
- // eslint-disable-next-line security/detect-object-injection -- extension is from path.extname, limited set
1538
- const format = extensionToFormat[extension] || '';
1539
- createEmptyResponse(format, '', callback);
1540
- return;
1541
- }
1586
+ const responseHeaders = response.headers;
1587
+ const responseData = await response.arrayBuffer();
1588
+ const parsedResponse = {};
1542
1589
 
1543
- const responseHeaders = response.headers;
1544
- const responseData = await response.arrayBuffer();
1545
- const parsedResponse = {};
1590
+ if (responseHeaders.get('last-modified')) {
1591
+ parsedResponse.modified = new Date(
1592
+ responseHeaders.get('last-modified'),
1593
+ );
1594
+ }
1595
+ if (responseHeaders.get('expires')) {
1596
+ parsedResponse.expires = new Date(
1597
+ responseHeaders.get('expires'),
1598
+ );
1599
+ }
1600
+ if (responseHeaders.get('etag')) {
1601
+ parsedResponse.etag = responseHeaders.get('etag');
1602
+ }
1546
1603
 
1547
- if (responseHeaders.get('last-modified')) {
1548
- parsedResponse.modified = new Date(
1549
- responseHeaders.get('last-modified'),
1550
- );
1551
- }
1552
- if (responseHeaders.get('expires')) {
1553
- parsedResponse.expires = new Date(
1554
- responseHeaders.get('expires'),
1555
- );
1556
- }
1557
- if (responseHeaders.get('etag')) {
1558
- parsedResponse.etag = responseHeaders.get('etag');
1559
- }
1604
+ parsedResponse.data = Buffer.from(responseData);
1605
+ respond(null, parsedResponse);
1606
+ } catch (error) {
1607
+ // Log DNS failures
1608
+ if (error.cause?.code === 'ENOTFOUND') {
1609
+ console.error(
1610
+ `DNS RESOLUTION FAILED for ${req.url}. ` +
1611
+ `This domain may be unreachable or misconfigured in your style. ` +
1612
+ `Consider removing it or fixing the DNS.`,
1613
+ );
1614
+ }
1560
1615
 
1561
- parsedResponse.data = Buffer.from(responseData);
1562
- callback(null, parsedResponse);
1563
- } catch (error) {
1564
- // Log DNS failures
1565
- if (error.cause?.code === 'ENOTFOUND') {
1566
- console.error(
1567
- `DNS RESOLUTION FAILED for ${req.url}. ` +
1568
- `This domain may be unreachable or misconfigured in your style. ` +
1569
- `Consider removing it or fixing the DNS.`,
1570
- );
1571
- }
1616
+ // Log timeout
1617
+ if (error.name === 'AbortError') {
1618
+ console.error(
1619
+ `FETCH TIMEOUT for ${req.url}. ` +
1620
+ `The request took longer than ${timeoutMs} ms to complete.`,
1621
+ );
1622
+ }
1572
1623
 
1573
- // Log timeout
1574
- if (error.name === 'AbortError') {
1624
+ // Log all other errors
1575
1625
  console.error(
1576
- `FETCH TIMEOUT for ${req.url}. ` +
1577
- `The request took longer than ${timeoutMs} ms to complete.`,
1626
+ `Error fetching remote URL ${req.url}:`,
1627
+ error.message || error,
1578
1628
  );
1579
- }
1580
1629
 
1581
- // Log all other errors
1582
- console.error(
1583
- `Error fetching remote URL ${req.url}:`,
1584
- error.message || error,
1585
- );
1630
+ if (sparse) {
1631
+ // sparse=true -> allow overzoom
1632
+ respond();
1633
+ return;
1634
+ }
1586
1635
 
1587
- if (globalSparse) {
1588
- // sparse=true -> allow overzoom
1589
- callback();
1590
- return;
1636
+ // sparse=false -> create empty tile
1637
+ createEmptyResponse(format, '', respond);
1591
1638
  }
1639
+ } else if (protocol === 'file') {
1640
+ const name = decodeURI(req.url).substring(protocol.length + 3);
1641
+ const file = path.join(options.paths['files'], name);
1642
+ if (await existsP(file)) {
1643
+ const inputFileStats = await fsp.stat(file);
1644
+ if (!inputFileStats.isFile() || inputFileStats.size === 0) {
1645
+ throw Error(
1646
+ `File is not valid: "${req.url}" - resolved to "${file}"`,
1647
+ );
1648
+ }
1592
1649
 
1593
- // sparse=false (default) -> create empty tile
1594
- const parts = url.parse(req.url);
1595
- const extension = path.extname(parts.pathname).toLowerCase();
1596
- // eslint-disable-next-line security/detect-object-injection -- extension is from path.extname, limited set
1597
- const format = extensionToFormat[extension] || '';
1598
- createEmptyResponse(format, '', callback);
1599
- }
1600
- } else if (protocol === 'file') {
1601
- const name = decodeURI(req.url).substring(protocol.length + 3);
1602
- const file = path.join(options.paths['files'], name);
1603
- if (await existsP(file)) {
1604
- const inputFileStats = await fsp.stat(file);
1605
- if (!inputFileStats.isFile() || inputFileStats.size === 0) {
1650
+ readFile(file)
1651
+ .then((data) => {
1652
+ respond(null, { data: data });
1653
+ })
1654
+ .catch((err) => {
1655
+ respond(err, null);
1656
+ });
1657
+ } else {
1606
1658
  throw Error(
1607
- `File is not valid: "${req.url}" - resolved to "${file}"`,
1659
+ `File does not exist: "${req.url}" - resolved to "${file}"`,
1608
1660
  );
1609
1661
  }
1610
-
1611
- readFile(file)
1612
- .then((data) => {
1613
- callback(null, { data: data });
1614
- })
1615
- .catch((err) => {
1616
- callback(err, null);
1617
- });
1618
1662
  } else {
1619
- throw Error(
1620
- `File does not exist: "${req.url}" - resolved to "${file}"`,
1621
- );
1663
+ throw Error(`Unsupported protocol in request: "${req.url}"`);
1622
1664
  }
1665
+ } catch (err) {
1666
+ console.error(
1667
+ `Error handling renderer request for ${req.url}:`,
1668
+ err,
1669
+ );
1670
+ respond(err, null);
1623
1671
  }
1624
1672
  },
1625
1673
  });
@@ -1807,6 +1855,17 @@ export const serve_rendered = {
1807
1855
  `pmtiles://${name}/{z}/{x}/{y}.${metadata.format || 'pbf'}`,
1808
1856
  ];
1809
1857
  delete source.scheme;
1858
+ // Object.assign copied the archive metadata onto the style source, so
1859
+ // `sparse` is sitting there unparsed - "false" as a string is truthy.
1860
+ // sparseFlags below is the value the request handler reads, so drop
1861
+ // the duplicate here, before the data decorator sees it.
1862
+ delete source.sparse;
1863
+
1864
+ if (options.dataDecoratorFunc) {
1865
+ source = await options.dataDecoratorFunc(name, 'tilejson', source);
1866
+ // eslint-disable-next-line security/detect-object-injection -- name is from style sources object keys
1867
+ styleJSON.sources[name] = source;
1868
+ }
1810
1869
 
1811
1870
  if (
1812
1871
  !attributionOverride &&
@@ -1821,12 +1880,13 @@ export const serve_rendered = {
1821
1880
  }
1822
1881
  }
1823
1882
 
1824
- // Set sparse flag: user config overrides format-based default
1825
- // Vector tiles (pbf) default to false (204), raster tiles default to true (404)
1826
- const isVector = metadata.format === 'pbf';
1827
1883
  // eslint-disable-next-line security/detect-object-injection -- name is from style sources object keys
1828
- map.sparseFlags[name] =
1829
- dataInfo.sparse ?? options.sparse ?? !isVector;
1884
+ map.sparseFlags[name] = resolveSparse({
1885
+ perSource: dataInfo.sparse,
1886
+ globalOption: options.sparse,
1887
+ metadata: parseOptionalBoolean(metadata.sparse),
1888
+ isVector: metadata.format === 'pbf',
1889
+ });
1830
1890
  } else {
1831
1891
  // MBTiles does not support remote URLs
1832
1892
 
@@ -1857,9 +1917,16 @@ export const serve_rendered = {
1857
1917
  `mbtiles://${name}/{z}/{x}/{y}.${info.format || 'pbf'}`,
1858
1918
  ];
1859
1919
  delete source.scheme;
1920
+ // Object.assign copied the archive metadata onto the style source, so
1921
+ // `sparse` is sitting there unparsed - "false" as a string is truthy.
1922
+ // sparseFlags below is the value the request handler reads, so drop
1923
+ // the duplicate here, before the data decorator sees it.
1924
+ delete source.sparse;
1860
1925
 
1861
1926
  if (options.dataDecoratorFunc) {
1862
- source = options.dataDecoratorFunc(name, 'tilejson', source);
1927
+ source = await options.dataDecoratorFunc(name, 'tilejson', source);
1928
+ // eslint-disable-next-line security/detect-object-injection -- name is from style sources object keys
1929
+ styleJSON.sources[name] = source;
1863
1930
  }
1864
1931
 
1865
1932
  if (
@@ -1875,12 +1942,13 @@ export const serve_rendered = {
1875
1942
  }
1876
1943
  }
1877
1944
 
1878
- // Set sparse flag: user config overrides format-based default
1879
- // Vector tiles (pbf) default to false (204), raster tiles default to true (404)
1880
- const isVector = info.format === 'pbf';
1881
1945
  // eslint-disable-next-line security/detect-object-injection -- name is from style sources object keys
1882
- map.sparseFlags[name] =
1883
- dataInfo.sparse ?? options.sparse ?? !isVector;
1946
+ map.sparseFlags[name] = resolveSparse({
1947
+ perSource: dataInfo.sparse,
1948
+ globalOption: options.sparse,
1949
+ metadata: parseOptionalBoolean(info.sparse),
1950
+ isVector: info.format === 'pbf',
1951
+ });
1884
1952
  }
1885
1953
  }
1886
1954
  }