tileserver-gl-light 5.5.0-pre.1 → 5.5.0-pre.2

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 (32) hide show
  1. package/CHANGELOG.md +4 -2
  2. package/package.json +9 -9
  3. package/public/resources/maplibre-gl-inspect.js +2823 -2770
  4. package/public/resources/maplibre-gl-inspect.js.map +1 -1
  5. package/public/resources/maplibre-gl.css +1 -1
  6. package/public/resources/maplibre-gl.js +4 -4
  7. package/public/resources/maplibre-gl.js.map +1 -1
  8. package/src/main.js +16 -17
  9. package/src/pmtiles_adapter.js +3 -3
  10. package/src/promises.js +1 -1
  11. package/src/render.js +270 -93
  12. package/src/serve_data.js +6 -8
  13. package/src/serve_light.js +0 -1
  14. package/src/serve_rendered.js +372 -205
  15. package/src/server.js +22 -27
  16. package/src/utils.js +17 -18
  17. package/test/fixtures/visual/encoded-path-auto.png +0 -0
  18. package/test/fixtures/visual/linecap-linejoin-bevel-square.png +0 -0
  19. package/test/fixtures/visual/linecap-linejoin-round-round.png +0 -0
  20. package/test/fixtures/visual/path-auto.png +0 -0
  21. package/test/fixtures/visual/static-bbox.png +0 -0
  22. package/test/fixtures/visual/static-bearing-pitch.png +0 -0
  23. package/test/fixtures/visual/static-bearing.png +0 -0
  24. package/test/fixtures/visual/static-border-global.png +0 -0
  25. package/test/fixtures/visual/static-lat-lng.png +0 -0
  26. package/test/fixtures/visual/static-markers.png +0 -0
  27. package/test/fixtures/visual/static-multiple-paths.png +0 -0
  28. package/test/fixtures/visual/static-path-border-isolated.png +0 -0
  29. package/test/fixtures/visual/static-path-border-stroke.png +0 -0
  30. package/test/fixtures/visual/static-path-latlng.png +0 -0
  31. package/test/fixtures/visual/static-pixel-ratio-2x.png +0 -0
  32. package/test/static_images.js +241 -0
@@ -24,7 +24,6 @@ import { SphericalMercator } from '@mapbox/sphericalmercator';
24
24
  import mlgl from '@maplibre/maplibre-gl-native';
25
25
  import polyline from '@mapbox/polyline';
26
26
  import proj4 from 'proj4';
27
- import axios from 'axios';
28
27
  import {
29
28
  allowedScales,
30
29
  allowedTileSizes,
@@ -62,8 +61,7 @@ const staticTypeRegex = new RegExp(
62
61
  );
63
62
 
64
63
  const PATH_PATTERN =
65
- // eslint-disable-next-line security/detect-unsafe-regex -- Simple path pattern validation, no nested quantifiers
66
- /^((fill|stroke|width)\:[^\|]+\|)*(enc:.+|-?\d+(\.\d*)?,-?\d+(\.\d*)?(\|-?\d+(\.\d*)?,-?\d+(\.\d*)?)+)/;
64
+ /^((fill|stroke|width|border|borderwidth):[^|]+\|)*(enc:.+|-?\d+(\.\d*)?,-?\d+(\.\d*)?(\|-?\d+(\.\d*)?,-?\d+(\.\d*)?)+)/;
67
65
 
68
66
  const mercator = new SphericalMercator();
69
67
 
@@ -165,7 +163,7 @@ function parseCoordinatePair(coordinates, query) {
165
163
  }
166
164
 
167
165
  // Check if coordinates have been provided as lat/lng pair instead of the
168
- // ususal lng/lat pair and ensure resulting pair is lng/lat
166
+ // usual lng/lat pair and ensure resulting pair is lng/lat
169
167
  if (query.latlng === '1' || query.latlng === 'true') {
170
168
  return [secondCoordinate, firstCoordinate];
171
169
  }
@@ -183,9 +181,18 @@ function parseCoordinatePair(coordinates, query) {
183
181
  function parseCoordinates(coordinatePair, query, transformer) {
184
182
  const parsedCoordinates = parseCoordinatePair(coordinatePair, query);
185
183
 
184
+ if (!parsedCoordinates) {
185
+ return null;
186
+ }
187
+
186
188
  // Transform coordinates
187
189
  if (transformer) {
188
- return transformer(parsedCoordinates);
190
+ try {
191
+ return transformer(parsedCoordinates);
192
+ } catch (error) {
193
+ console.error('Error transforming coordinates:', error);
194
+ return null;
195
+ }
189
196
  }
190
197
 
191
198
  return parsedCoordinates;
@@ -209,12 +216,37 @@ function extractPathsFromQuery(query, transformer) {
209
216
  const providedPaths = Array.isArray(query.path) ? query.path : [query.path];
210
217
  // Iterate through paths, parse and validate them
211
218
  for (const providedPath of providedPaths) {
219
+ let geometryString = providedPath;
220
+
221
+ // Logic to strip style options (like stroke:red) from the front
222
+ const parts = providedPath.split('|');
223
+ let firstGeometryIndex = 0;
224
+ for (const [index, part] of parts.entries()) {
225
+ // A part is considered a style option if it contains ':' but is NOT an 'enc:' string or a coordinate
226
+ if (part.includes(':') && !part.startsWith('enc:')) {
227
+ // This is a style option, continue
228
+ continue;
229
+ } else {
230
+ // This is the start of the geometry (enc: or coordinate)
231
+ firstGeometryIndex = index;
232
+ break;
233
+ }
234
+ }
235
+
236
+ // If we found a geometry, set the geometryString to the rest of the path
237
+ if (firstGeometryIndex > 0) {
238
+ geometryString = parts.slice(firstGeometryIndex).join('|');
239
+ }
240
+
212
241
  // Logic for pushing coords to path when path includes google polyline
213
- if (providedPath.includes('enc:') && PATH_PATTERN.test(providedPath)) {
242
+ if (
243
+ geometryString.includes('enc:') &&
244
+ PATH_PATTERN.test(geometryString)
245
+ ) {
214
246
  // +4 because 'enc:' is 4 characters, everything after 'enc:' is considered to be part of the polyline
215
- const encIndex = providedPath.indexOf('enc:') + 4;
247
+ const encIndex = geometryString.indexOf('enc:') + 4;
216
248
  const coords = polyline
217
- .decode(providedPath.substring(encIndex))
249
+ .decode(geometryString.substring(encIndex))
218
250
  .map(([lat, lng]) => [lng, lat]);
219
251
  paths.push(coords);
220
252
  } else {
@@ -222,7 +254,7 @@ function extractPathsFromQuery(query, transformer) {
222
254
  const currentPath = [];
223
255
 
224
256
  // Extract coordinate-list from path
225
- const pathParts = (providedPath || '').split('|');
257
+ const pathParts = (geometryString || '').split('|');
226
258
 
227
259
  // Iterate through coordinate-list, parse the coordinates and validate them
228
260
  for (const pair of pathParts) {
@@ -250,6 +282,7 @@ function extractPathsFromQuery(query, transformer) {
250
282
  }
251
283
  return paths;
252
284
  }
285
+
253
286
  /**
254
287
  * Parses marker options provided via query and sets corresponding attributes
255
288
  * on marker object.
@@ -269,21 +302,38 @@ function parseMarkerOptions(optionsList, marker) {
269
302
 
270
303
  switch (optionParts[0]) {
271
304
  // Scale factor to up- or downscale icon
272
- case 'scale':
273
- // Scale factors must not be negative
274
- marker.scale = Math.abs(parseFloat(optionParts[1]));
305
+ case 'scale': {
306
+ // Scale factors must not be negative and should have reasonable bounds
307
+ const scale = parseFloat(optionParts[1]);
308
+ if (!isNaN(scale) && scale > 0 && scale < 10) {
309
+ marker.scale = scale;
310
+ } else {
311
+ console.warn(`Invalid marker scale: ${optionParts[1]}`);
312
+ }
275
313
  break;
314
+ }
276
315
  // Icon offset as positive or negative pixel value in the following
277
316
  // format [offsetX],[offsetY] where [offsetY] is optional
278
- case 'offset':
317
+ case 'offset': {
279
318
  const providedOffset = optionParts[1].split(',');
319
+ const offsetX = parseFloat(providedOffset[0]);
320
+
280
321
  // Set X-axis offset
281
- marker.offsetX = parseFloat(providedOffset[0]);
322
+ if (!isNaN(offsetX) && Math.abs(offsetX) < 1000) {
323
+ marker.offsetX = offsetX;
324
+ }
325
+
282
326
  // Check if an offset has been provided for Y-axis
283
327
  if (providedOffset.length > 1) {
284
- marker.offsetY = parseFloat(providedOffset[1]);
328
+ const offsetY = parseFloat(providedOffset[1]);
329
+ if (!isNaN(offsetY) && Math.abs(offsetY) < 1000) {
330
+ marker.offsetY = offsetY;
331
+ }
285
332
  }
286
333
  break;
334
+ }
335
+ default:
336
+ console.warn(`Unknown marker option: ${optionParts[0]}`);
287
337
  }
288
338
  }
289
339
  }
@@ -304,25 +354,32 @@ function extractMarkersFromQuery(query, options, transformer) {
304
354
  const markers = [];
305
355
 
306
356
  // Check if multiple markers have been provided and mimic a list if it's a
307
- // single maker.
357
+ // single marker.
308
358
  const providedMarkers = Array.isArray(query.marker)
309
359
  ? query.marker
310
360
  : [query.marker];
311
361
 
312
- // Iterate through provided markers which can have one of the following
313
- // formats
314
- // [location]|[pathToFileTelativeToConfiguredIconPath]
362
+ // Iterate through provided markers which can have one of the following formats:
363
+ // [location]|[pathToFileRelativeToConfiguredIconPath]
315
364
  // [location]|[pathToFile...]|[option]|[option]|...
316
365
  for (const providedMarker of providedMarkers) {
366
+ if (typeof providedMarker !== 'string') {
367
+ continue;
368
+ }
369
+
317
370
  const markerParts = providedMarker.split('|');
371
+
318
372
  // Ensure we got at least a location and an icon uri
319
373
  if (markerParts.length < 2) {
374
+ console.warn('Marker requires at least location and icon path');
320
375
  continue;
321
376
  }
322
377
 
323
378
  const locationParts = markerParts[0].split(',');
379
+
324
380
  // Ensure the locationParts contains two items
325
381
  if (locationParts.length !== 2) {
382
+ console.warn('Marker location must have exactly 2 coordinates');
326
383
  continue;
327
384
  }
328
385
 
@@ -338,6 +395,7 @@ function extractMarkersFromQuery(query, options, transformer) {
338
395
 
339
396
  // If the selected icon is not part of available icons skip it
340
397
  if (!options.paths.availableIcons.includes(iconURI)) {
398
+ console.warn(`Icon not in available icons: ${iconURI}`);
341
399
  continue;
342
400
  }
343
401
 
@@ -345,21 +403,24 @@ function extractMarkersFromQuery(query, options, transformer) {
345
403
 
346
404
  // When we encounter a remote icon check if the configuration explicitly allows them.
347
405
  } else if (isRemoteURL && options.allowRemoteMarkerIcons !== true) {
406
+ console.warn('Remote marker icons not allowed');
348
407
  continue;
349
408
  } else if (isDataURL && options.allowInlineMarkerImages !== true) {
409
+ console.warn('Inline marker images not allowed');
350
410
  continue;
351
411
  }
352
412
 
353
413
  // Ensure marker location could be parsed
354
414
  const location = parseCoordinates(locationParts, query, transformer);
355
415
  if (location === null) {
416
+ console.warn('Failed to parse marker location');
356
417
  continue;
357
418
  }
358
419
 
359
- const marker = {};
360
-
361
- marker.location = location;
362
- marker.icon = iconURI;
420
+ const marker = {
421
+ location,
422
+ icon: iconURI,
423
+ };
363
424
 
364
425
  // Check if options have been provided
365
426
  if (markerParts.length > 2) {
@@ -453,6 +514,7 @@ async function respondImage(
453
514
  }
454
515
 
455
516
  if (format === 'png' || format === 'webp') {
517
+ /* empty */
456
518
  } else if (format === 'jpg' || format === 'jpeg') {
457
519
  format = 'jpeg';
458
520
  } else {
@@ -470,6 +532,31 @@ async function respondImage(
470
532
  }
471
533
 
472
534
  pool.acquire(async (err, renderer) => {
535
+ // Check if pool.acquire failed or returned null/invalid renderer
536
+ if (err) {
537
+ console.error('Failed to acquire renderer from pool:', err);
538
+ return res.status(503).send('Renderer pool error');
539
+ }
540
+
541
+ if (!renderer) {
542
+ console.error(
543
+ 'Renderer is null - likely crashed or failed to initialize',
544
+ );
545
+ return res.status(503).send('Renderer unavailable');
546
+ }
547
+
548
+ // Validate renderer has required methods (basic health check)
549
+ if (typeof renderer.render !== 'function') {
550
+ console.error('Renderer is invalid - missing render method');
551
+ // Destroy the bad renderer and remove from pool
552
+ try {
553
+ pool.destroy(renderer);
554
+ } catch (e) {
555
+ console.error('Error destroying invalid renderer:', e);
556
+ }
557
+ return res.status(503).send('Renderer invalid');
558
+ }
559
+
473
560
  // For 512px tiles, use the actual maplibre-native zoom. For 256px tiles, use zoom - 1
474
561
  let mlglZ;
475
562
  if (width === 512) {
@@ -499,111 +586,154 @@ async function respondImage(
499
586
  params.height += tileMargin * 2;
500
587
  }
501
588
 
502
- renderer.render(params, (err, data) => {
503
- pool.release(renderer);
504
- if (err) {
505
- console.error(err);
506
- return res.status(500).header('Content-Type', 'text/plain').send(err);
589
+ // Set a timeout for the render operation to detect hung renderers
590
+ const renderTimeout = setTimeout(() => {
591
+ console.error('Renderer timeout - destroying potentially hung renderer');
592
+ try {
593
+ // Don't release back to pool, destroy it
594
+ pool.destroy(renderer);
595
+ } catch (e) {
596
+ console.error('Error destroying timed-out renderer:', e);
507
597
  }
598
+ if (!res.headersSent) {
599
+ res.status(503).send('Renderer timeout');
600
+ }
601
+ }, 30000); // 30 second timeout
508
602
 
509
- const image = sharp(data, {
510
- raw: {
511
- premultiplied: true,
512
- width: params.width * scale,
513
- height: params.height * scale,
514
- channels: 4,
515
- },
516
- });
603
+ try {
604
+ renderer.render(params, (err, data) => {
605
+ clearTimeout(renderTimeout);
517
606
 
518
- if (z > 0 && tileMargin > 0) {
519
- const y = mercator.px(params.center, z)[1];
520
- const yoffset = Math.max(
521
- Math.min(0, y - 128 - tileMargin),
522
- y + 128 + tileMargin - Math.pow(2, z + 8),
523
- );
524
- image.extract({
525
- left: tileMargin * scale,
526
- top: (tileMargin + yoffset) * scale,
527
- width: width * scale,
528
- height: height * scale,
607
+ if (err) {
608
+ console.error('Render error:', err);
609
+ // Destroy renderer instead of releasing it back to pool since it may be corrupted
610
+ try {
611
+ pool.destroy(renderer);
612
+ } catch (e) {
613
+ console.error('Error destroying failed renderer:', e);
614
+ }
615
+ if (!res.headersSent) {
616
+ return res
617
+ .status(500)
618
+ .header('Content-Type', 'text/plain')
619
+ .send(err);
620
+ }
621
+ return;
622
+ }
623
+
624
+ // Only release if render was successful
625
+ pool.release(renderer);
626
+
627
+ const image = sharp(data, {
628
+ raw: {
629
+ premultiplied: true,
630
+ width: params.width * scale,
631
+ height: params.height * scale,
632
+ channels: 4,
633
+ },
529
634
  });
530
- }
531
- // HACK(Part 2) 256px tiles are a zoom level lower than maplibre-native default tiles. this hack allows tileserver-gl to support zoom 0 256px tiles, which would actually be zoom -1 in maplibre-native. Since zoom -1 isn't supported, a double sized zoom 0 tile is requested and resized here.
532
635
 
533
- if (z === 0 && width === 256) {
534
- image.resize(width * scale, height * scale);
535
- }
536
- // END HACK(Part 2)
636
+ if (z > 0 && tileMargin > 0) {
637
+ const y = mercator.px(params.center, z)[1];
638
+ const yoffset = Math.max(
639
+ Math.min(0, y - 128 - tileMargin),
640
+ y + 128 + tileMargin - Math.pow(2, z + 8),
641
+ );
642
+ image.extract({
643
+ left: tileMargin * scale,
644
+ top: (tileMargin + yoffset) * scale,
645
+ width: width * scale,
646
+ height: height * scale,
647
+ });
648
+ }
649
+ // HACK(Part 2) 256px tiles are a zoom level lower than maplibre-native default tiles. this hack allows tileserver-gl to support zoom 0 256px tiles, which would actually be zoom -1 in maplibre-native. Since zoom -1 isn't supported, a double sized zoom 0 tile is requested and resized here.
537
650
 
538
- const composites = [];
539
- if (overlay) {
540
- composites.push({ input: overlay });
541
- }
542
- if (item.watermark) {
543
- const canvas = renderWatermark(width, height, scale, item.watermark);
651
+ if (z === 0 && width === 256) {
652
+ image.resize(width * scale, height * scale);
653
+ }
654
+ // END HACK(Part 2)
544
655
 
545
- composites.push({ input: canvas.toBuffer() });
546
- }
656
+ const composites = [];
657
+ if (overlay) {
658
+ composites.push({ input: overlay });
659
+ }
660
+ if (item.watermark) {
661
+ const canvas = renderWatermark(width, height, scale, item.watermark);
547
662
 
548
- if (mode === 'static' && item.staticAttributionText) {
549
- const canvas = renderAttribution(
550
- width,
551
- height,
552
- scale,
553
- item.staticAttributionText,
554
- );
663
+ composites.push({ input: canvas.toBuffer() });
664
+ }
555
665
 
556
- composites.push({ input: canvas.toBuffer() });
557
- }
666
+ if (mode === 'static' && item.staticAttributionText) {
667
+ const canvas = renderAttribution(
668
+ width,
669
+ height,
670
+ scale,
671
+ item.staticAttributionText,
672
+ );
558
673
 
559
- if (composites.length > 0) {
560
- image.composite(composites);
561
- }
674
+ composites.push({ input: canvas.toBuffer() });
675
+ }
562
676
 
563
- // Legacy formatQuality is deprecated but still works
564
- const formatQualities = options.formatQuality || {};
565
- if (Object.keys(formatQualities).length !== 0) {
566
- console.log(
567
- 'WARNING: The formatQuality option is deprecated and has been replaced with formatOptions. Please see the documentation. The values from formatQuality will be used if a quality setting is not provided via formatOptions.',
568
- );
569
- }
570
- // eslint-disable-next-line security/detect-object-injection -- format is validated above
571
- const formatQuality = formatQualities[format];
572
-
573
- // eslint-disable-next-line security/detect-object-injection -- format is validated above
574
- const formatOptions = (options.formatOptions || {})[format] || {};
575
-
576
- if (format === 'png') {
577
- image.png({
578
- progressive: formatOptions.progressive,
579
- compressionLevel: formatOptions.compressionLevel,
580
- adaptiveFiltering: formatOptions.adaptiveFiltering,
581
- palette: formatOptions.palette,
582
- quality: formatOptions.quality,
583
- effort: formatOptions.effort,
584
- colors: formatOptions.colors,
585
- dither: formatOptions.dither,
586
- });
587
- } else if (format === 'jpeg') {
588
- image.jpeg({
589
- quality: formatOptions.quality || formatQuality || 80,
590
- progressive: formatOptions.progressive,
591
- });
592
- } else if (format === 'webp') {
593
- image.webp({ quality: formatOptions.quality || formatQuality || 90 });
594
- }
595
- image.toBuffer((err, buffer, info) => {
596
- if (!buffer) {
597
- return res.status(404).send('Not found');
677
+ if (composites.length > 0) {
678
+ image.composite(composites);
598
679
  }
599
680
 
600
- res.set({
601
- 'Last-Modified': item.lastModified,
602
- 'Content-Type': `image/${format}`,
681
+ // Legacy formatQuality is deprecated but still works
682
+ const formatQualities = options.formatQuality || {};
683
+ if (Object.keys(formatQualities).length !== 0) {
684
+ console.log(
685
+ 'WARNING: The formatQuality option is deprecated and has been replaced with formatOptions. Please see the documentation. The values from formatQuality will be used if a quality setting is not provided via formatOptions.',
686
+ );
687
+ }
688
+ // eslint-disable-next-line security/detect-object-injection -- format is validated above
689
+ const formatQuality = formatQualities[format];
690
+
691
+ // eslint-disable-next-line security/detect-object-injection -- format is validated above
692
+ const formatOptions = (options.formatOptions || {})[format] || {};
693
+
694
+ if (format === 'png') {
695
+ image.png({
696
+ progressive: formatOptions.progressive,
697
+ compressionLevel: formatOptions.compressionLevel,
698
+ adaptiveFiltering: formatOptions.adaptiveFiltering,
699
+ palette: formatOptions.palette,
700
+ quality: formatOptions.quality,
701
+ effort: formatOptions.effort,
702
+ colors: formatOptions.colors,
703
+ dither: formatOptions.dither,
704
+ });
705
+ } else if (format === 'jpeg') {
706
+ image.jpeg({
707
+ quality: formatOptions.quality || formatQuality || 80,
708
+ progressive: formatOptions.progressive,
709
+ });
710
+ } else if (format === 'webp') {
711
+ image.webp({ quality: formatOptions.quality || formatQuality || 90 });
712
+ }
713
+ image.toBuffer((err, buffer, info) => {
714
+ if (!buffer) {
715
+ return res.status(404).send('Not found');
716
+ }
717
+
718
+ res.set({
719
+ 'Last-Modified': item.lastModified,
720
+ 'Content-Type': `image/${format}`,
721
+ });
722
+ return res.status(200).send(buffer);
603
723
  });
604
- return res.status(200).send(buffer);
605
724
  });
606
- });
725
+ } catch (error) {
726
+ clearTimeout(renderTimeout);
727
+ console.error('Unexpected error during render:', error);
728
+ try {
729
+ pool.destroy(renderer);
730
+ } catch (e) {
731
+ console.error('Error destroying renderer after error:', e);
732
+ }
733
+ if (!res.headersSent) {
734
+ return res.status(500).send('Render failed');
735
+ }
736
+ }
607
737
  });
608
738
  }
609
739
 
@@ -998,7 +1128,6 @@ export const serve_rendered = {
998
1128
  * @returns {void}
999
1129
  */
1000
1130
  app.get('{/:tileSize}/:id.json', (req, res, next) => {
1001
- // eslint-disable-next-line security/detect-object-injection -- req.params.id is route parameter, validated by Express
1002
1131
  const item = repo[req.params.id];
1003
1132
  if (!item) {
1004
1133
  return res.sendStatus(404);
@@ -1192,61 +1321,90 @@ export const serve_rendered = {
1192
1321
  callback(null, response);
1193
1322
  } else if (protocol === 'http' || protocol === 'https') {
1194
1323
  try {
1195
- const response = await axios.get(req.url, {
1196
- responseType: 'arraybuffer', // Get the response as raw buffer
1197
- // Axios handles gzip by default, so no need for a gzip flag
1324
+ // Add timeout to prevent hanging on unreachable hosts
1325
+ const controller = new AbortController();
1326
+ const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
1327
+
1328
+ const response = await fetch(req.url, {
1329
+ signal: controller.signal,
1198
1330
  });
1199
1331
 
1332
+ clearTimeout(timeoutId);
1333
+
1334
+ // Handle 410 Gone as sparse response
1335
+ if (response.status === 410) {
1336
+ if (verbose) {
1337
+ console.log(
1338
+ 'fetchTile warning on %s, sparse response due to 410 Gone',
1339
+ req.url,
1340
+ );
1341
+ }
1342
+ callback();
1343
+ return;
1344
+ }
1345
+
1346
+ // Check for other non-ok responses
1347
+ if (!response.ok) {
1348
+ throw new Error(
1349
+ `HTTP ${response.status}: ${response.statusText}`,
1350
+ );
1351
+ }
1352
+
1200
1353
  const responseHeaders = response.headers;
1201
- const responseData = response.data;
1354
+ const responseData = await response.arrayBuffer();
1202
1355
 
1203
1356
  const parsedResponse = {};
1204
- if (responseHeaders['last-modified']) {
1357
+ if (responseHeaders.get('last-modified')) {
1205
1358
  parsedResponse.modified = new Date(
1206
- responseHeaders['last-modified'],
1359
+ responseHeaders.get('last-modified'),
1207
1360
  );
1208
1361
  }
1209
- if (responseHeaders.expires) {
1210
- parsedResponse.expires = new Date(responseHeaders.expires);
1362
+ if (responseHeaders.get('expires')) {
1363
+ parsedResponse.expires = new Date(
1364
+ responseHeaders.get('expires'),
1365
+ );
1211
1366
  }
1212
- if (responseHeaders.etag) {
1213
- parsedResponse.etag = responseHeaders.etag;
1367
+ if (responseHeaders.get('etag')) {
1368
+ parsedResponse.etag = responseHeaders.get('etag');
1214
1369
  }
1215
1370
 
1216
- parsedResponse.data = responseData;
1371
+ parsedResponse.data = Buffer.from(responseData);
1217
1372
  callback(null, parsedResponse);
1218
1373
  } catch (error) {
1219
- if (error.response && error.response.status === 410) {
1220
- // This is the 410 "Gone" error, treat as sparse
1221
- if (verbose) {
1222
- console.log(
1223
- 'fetchTile warning on %s, sparse response due to 410 Gone',
1224
- req.url,
1225
- );
1226
- }
1227
- callback();
1228
- } else {
1229
- // For all other errors (e.g., network errors, 404, 500, etc.) return empty content.
1374
+ // Log DNS failures more prominently as they often indicate config issues
1375
+ // Native fetch wraps DNS errors in error.cause
1376
+ if (error.cause?.code === 'ENOTFOUND') {
1230
1377
  console.error(
1231
- `Error fetching remote URL ${req.url}:`,
1232
- error.message || error,
1233
- error.response
1234
- ? `Status: ${error.response.status}`
1235
- : 'No response received',
1378
+ `DNS RESOLUTION FAILED for ${req.url}. ` +
1379
+ `This domain may be unreachable or misconfigured in your style. ` +
1380
+ `Consider removing it or fixing the DNS.`,
1236
1381
  );
1382
+ }
1237
1383
 
1238
- const parts = url.parse(req.url);
1239
- const extension = path.extname(parts.pathname).toLowerCase();
1240
- // eslint-disable-next-line security/detect-object-injection -- extension is from path.extname, limited set
1241
- const format = extensionToFormat[extension] || '';
1242
- createEmptyResponse(format, '', callback);
1384
+ // Handle AbortController timeout
1385
+ if (error.name === 'AbortError') {
1386
+ console.error(
1387
+ `FETCH TIMEOUT for ${req.url}. ` +
1388
+ `The request took longer than 10 seconds to complete.`,
1389
+ );
1243
1390
  }
1391
+
1392
+ // For all other errors (e.g., network errors, 404, 500, etc.) return empty content.
1393
+ console.error(
1394
+ `Error fetching remote URL ${req.url}:`,
1395
+ error.message || error,
1396
+ );
1397
+
1398
+ const parts = url.parse(req.url);
1399
+ const extension = path.extname(parts.pathname).toLowerCase();
1400
+ // eslint-disable-next-line security/detect-object-injection -- extension is from path.extname, limited set
1401
+ const format = extensionToFormat[extension] || '';
1402
+ createEmptyResponse(format, '', callback);
1244
1403
  }
1245
1404
  } else if (protocol === 'file') {
1246
1405
  const name = decodeURI(req.url).substring(protocol.length + 3);
1247
1406
  const file = path.join(options.paths['files'], name);
1248
1407
  if (await existsP(file)) {
1249
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- file path constructed from configured base path and URL-decoded filename
1250
1408
  const inputFileStats = await fsp.stat(file);
1251
1409
  if (!inputFileStats.isFile() || inputFileStats.size === 0) {
1252
1410
  throw Error(
@@ -1451,7 +1609,6 @@ export const serve_rendered = {
1451
1609
 
1452
1610
  // PMTiles supports remote URLs (HTTP and S3), skip file check for those
1453
1611
  if (!isValidRemoteUrl(inputFile)) {
1454
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- inputFile is from dataResolver, which validates against config
1455
1612
  const inputFileStats = await fsp.stat(inputFile);
1456
1613
  if (!inputFileStats.isFile() || inputFileStats.size === 0) {
1457
1614
  throw Error(`Not valid PMTiles file: "${inputFile}"`);
@@ -1504,7 +1661,7 @@ export const serve_rendered = {
1504
1661
  }
1505
1662
  } else {
1506
1663
  // MBTiles does not support remote URLs
1507
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- inputFile is from dataResolver, which validates against config
1664
+
1508
1665
  const inputFileStats = await fsp.stat(inputFile);
1509
1666
  if (!inputFileStats.isFile() || inputFileStats.size === 0) {
1510
1667
  throw Error(`Not valid MBTiles file: "${inputFile}"`);
@@ -1623,68 +1780,78 @@ export const serve_rendered = {
1623
1780
  * @returns {Promise<object>} Promise resolving to elevation data
1624
1781
  */
1625
1782
  getTerrainElevation: async function (data, param) {
1626
- return await new Promise(async (resolve, reject) => {
1627
- const image = new Image();
1628
- image.onload = async () => {
1629
- const canvas = createCanvas(param['tile_size'], param['tile_size']);
1630
- const context = canvas.getContext('2d');
1631
- context.drawImage(image, 0, 0);
1632
-
1633
- // calculate pixel coordinate of tile,
1634
- // see https://developers.google.com/maps/documentation/javascript/examples/map-coordinates
1635
- let siny = Math.sin((param['lat'] * Math.PI) / 180);
1636
- // Truncating to 0.9999 effectively limits latitude to 89.189. This is
1637
- // about a third of a tile past the edge of the world tile.
1638
- siny = Math.min(Math.max(siny, -0.9999), 0.9999);
1639
- const xWorld = param['tile_size'] * (0.5 + param['long'] / 360);
1640
- const yWorld =
1641
- param['tile_size'] *
1642
- (0.5 - Math.log((1 + siny) / (1 - siny)) / (4 * Math.PI));
1643
-
1644
- const scale = 1 << param['z'];
1645
-
1646
- const xTile = Math.floor((xWorld * scale) / param['tile_size']);
1647
- const yTile = Math.floor((yWorld * scale) / param['tile_size']);
1648
-
1649
- const xPixel = Math.floor(xWorld * scale) - xTile * param['tile_size'];
1650
- const yPixel = Math.floor(yWorld * scale) - yTile * param['tile_size'];
1651
- if (
1652
- xPixel < 0 ||
1653
- yPixel < 0 ||
1654
- xPixel >= param['tile_size'] ||
1655
- yPixel >= param['tile_size']
1656
- ) {
1657
- return reject('Out of bounds Pixel');
1658
- }
1659
- const imgdata = context.getImageData(xPixel, yPixel, 1, 1);
1660
- const red = imgdata.data[0];
1661
- const green = imgdata.data[1];
1662
- const blue = imgdata.data[2];
1663
- let elevation;
1664
- if (param['encoding'] === 'mapbox') {
1665
- elevation = -10000 + (red * 256 * 256 + green * 256 + blue) * 0.1;
1666
- } else if (param['encoding'] === 'terrarium') {
1667
- elevation = red * 256 + green + blue / 256 - 32768;
1668
- } else {
1669
- elevation = 'invalid encoding';
1783
+ return new Promise((resolve, reject) => {
1784
+ const image = new Image(); // Create a new Image object
1785
+ image.onload = () => {
1786
+ try {
1787
+ const canvas = createCanvas(param['tile_size'], param['tile_size']);
1788
+ const context = canvas.getContext('2d');
1789
+ context.drawImage(image, 0, 0);
1790
+
1791
+ // calculate pixel coordinate of tile,
1792
+ // see https://developers.google.com/maps/documentation/javascript/examples/map-coordinates
1793
+ let siny = Math.sin((param['lat'] * Math.PI) / 180);
1794
+ // Truncating to 0.9999 effectively limits latitude to 89.189. This is
1795
+ // about a third of a tile past the edge of the world tile.
1796
+ siny = Math.min(Math.max(siny, -0.9999), 0.9999);
1797
+ const xWorld = param['tile_size'] * (0.5 + param['long'] / 360);
1798
+ const yWorld =
1799
+ param['tile_size'] *
1800
+ (0.5 - Math.log((1 + siny) / (1 - siny)) / (4 * Math.PI));
1801
+
1802
+ const scale = 1 << param['z'];
1803
+
1804
+ const xTile = Math.floor((xWorld * scale) / param['tile_size']);
1805
+ const yTile = Math.floor((yWorld * scale) / param['tile_size']);
1806
+
1807
+ const xPixel =
1808
+ Math.floor(xWorld * scale) - xTile * param['tile_size'];
1809
+ const yPixel =
1810
+ Math.floor(yWorld * scale) - yTile * param['tile_size'];
1811
+ if (
1812
+ xPixel < 0 ||
1813
+ yPixel < 0 ||
1814
+ xPixel >= param['tile_size'] ||
1815
+ yPixel >= param['tile_size']
1816
+ ) {
1817
+ return reject('Out of bounds Pixel');
1818
+ }
1819
+ const imgdata = context.getImageData(xPixel, yPixel, 1, 1);
1820
+ const red = imgdata.data[0];
1821
+ const green = imgdata.data[1];
1822
+ const blue = imgdata.data[2];
1823
+ let elevation;
1824
+ if (param['encoding'] === 'mapbox') {
1825
+ elevation = -10000 + (red * 256 * 256 + green * 256 + blue) * 0.1;
1826
+ } else if (param['encoding'] === 'terrarium') {
1827
+ elevation = red * 256 + green + blue / 256 - 32768;
1828
+ } else {
1829
+ elevation = 'invalid encoding';
1830
+ }
1831
+ param['elevation'] = elevation;
1832
+ param['red'] = red;
1833
+ param['green'] = green;
1834
+ param['blue'] = blue;
1835
+ resolve(param);
1836
+ } catch (error) {
1837
+ reject(error); // Catch any errors during canvas operations
1670
1838
  }
1671
- param['elevation'] = elevation;
1672
- param['red'] = red;
1673
- param['green'] = green;
1674
- param['blue'] = blue;
1675
- resolve(param);
1676
1839
  };
1677
1840
  image.onerror = (err) => reject(err);
1678
- if (param['format'] === 'webp') {
1841
+
1842
+ // Load the image data - handle the sharp conversion outside the Promise
1843
+ (async () => {
1679
1844
  try {
1680
- const img = await sharp(data).toFormat('png').toBuffer();
1681
- image.src = img;
1845
+ if (param['format'] === 'webp') {
1846
+ const img = await sharp(data).toFormat('png').toBuffer();
1847
+ image.src = `data:image/png;base64,${img.toString('base64')}`; // Set data URL
1848
+ } else {
1849
+ image.src = data;
1850
+ }
1682
1851
  } catch (err) {
1683
- reject(err);
1852
+ reject(err); // Reject promise on sharp error
1684
1853
  }
1685
- } else {
1686
- image.src = data;
1687
- }
1854
+ })();
1688
1855
  });
1689
1856
  },
1690
1857
  };