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.
- package/CHANGELOG.md +12 -1
- package/docs/config.rst +43 -2
- package/docs/usage.rst +2 -1
- package/package.json +33 -28
- package/public/resources/elevation-control.js +8 -4
- package/public/resources/maplibre-gl-inspect.mjs +2286 -0
- package/public/resources/maplibre-gl-inspect.mjs.map +1 -0
- package/public/resources/maplibre-gl-shared.mjs +6 -0
- package/public/resources/maplibre-gl-shared.mjs.map +1 -0
- package/public/resources/maplibre-gl-worker.mjs +10 -0
- package/public/resources/maplibre-gl-worker.mjs.map +1 -0
- package/public/resources/maplibre-gl.css +1 -1
- package/public/resources/maplibre-gl.mjs +819 -0
- package/public/resources/maplibre-gl.mjs.map +1 -0
- package/public/templates/data.tmpl +12 -6
- package/public/templates/viewer.tmpl +12 -8
- package/src/main.js +53 -7
- package/src/render_mode.js +38 -0
- package/src/serve_data.js +29 -11
- package/src/serve_rendered.js +317 -249
- package/src/server.js +10 -10
- package/src/utils.js +51 -0
- package/test/data_decorator.js +156 -0
- package/test/fixtures/data-decorator-async.js +17 -0
- package/test/fixtures/data-decorator.js +29 -0
- package/test/fixtures/visual/static-512px-path-alignment-2x.png +0 -0
- package/test/fixtures/visual/static-marker-low-zoom-large-viewport.png +0 -0
- package/test/render_mode.js +48 -0
- package/test/sparse.js +411 -0
- package/test/static.js +20 -0
- package/test/static_images.js +10 -0
- package/test/tiles_data_compression.js +145 -0
- package/public/resources/maplibre-gl-inspect.js +0 -2934
- package/public/resources/maplibre-gl-inspect.js.map +0 -1
- package/public/resources/maplibre-gl.js +0 -59
- package/public/resources/maplibre-gl.js.map +0 -1
package/src/serve_rendered.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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
|
-
|
|
720
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
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
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
if (
|
|
1446
|
-
|
|
1447
|
-
|
|
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
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
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
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
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
|
-
|
|
1474
|
-
|
|
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
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
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
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
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
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
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
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
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 (
|
|
1529
|
-
|
|
1530
|
-
|
|
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
|
-
|
|
1535
|
-
const
|
|
1536
|
-
const
|
|
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
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
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
|
-
|
|
1548
|
-
parsedResponse
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
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
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
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
|
-
|
|
1574
|
-
if (error.name === 'AbortError') {
|
|
1624
|
+
// Log all other errors
|
|
1575
1625
|
console.error(
|
|
1576
|
-
`
|
|
1577
|
-
|
|
1626
|
+
`Error fetching remote URL ${req.url}:`,
|
|
1627
|
+
error.message || error,
|
|
1578
1628
|
);
|
|
1579
|
-
}
|
|
1580
1629
|
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1630
|
+
if (sparse) {
|
|
1631
|
+
// sparse=true -> allow overzoom
|
|
1632
|
+
respond();
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1586
1635
|
|
|
1587
|
-
|
|
1588
|
-
|
|
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
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|