UNPKG

tileserver-gl-light

Version:

Map tile server for JSON GL styles - serving vector tiles

1,490 lines (1,344 loc) 66.2 kB
'use strict'; // SECTION START // // The order of the two imports below is important. // For an unknown reason, if the order is reversed, rendering can crash. // This happens on ARM: // > terminate called after throwing an instance of 'std::runtime_error' // > what(): Cannot read GLX extensions. import { Image, createCanvas } from 'canvas'; import '@maplibre/maplibre-gl-native'; // // SECTION END import advancedPool from 'advanced-pool'; import path from 'path'; import url from 'url'; import sharp from 'sharp'; import clone from 'clone'; import Color from 'color'; import express from 'express'; import sanitize from 'sanitize-filename'; import { SphericalMercator } from '@mapbox/sphericalmercator'; import mlgl from '@maplibre/maplibre-gl-native'; import polyline from '@mapbox/polyline'; import proj4 from 'proj4'; import { allowedScales, allowedTileSizes, getFontsPbf, listFonts, getTileUrls, isValidHttpUrl, isValidRemoteUrl, fixTileJSONCenter, fetchTileData, readFile, } from './utils.js'; import { openPMtiles, getPMtilesInfo } from './pmtiles_adapter.js'; import { renderOverlay, renderWatermark, renderAttribution } from './render.js'; import fsp from 'node:fs/promises'; import { existsP, gunzipP } from './promises.js'; import { openMbTilesWrapper } from './mbtiles_wrapper.js'; const FLOAT_PATTERN = '[+-]?(?:\\d+|\\d*\\.\\d+)'; const staticTypeRegex = new RegExp( `^` + `(?:` + // Format 1: {lon},{lat},{zoom}[@{bearing}[,{pitch}]] `(?<lon>${FLOAT_PATTERN}),(?<lat>${FLOAT_PATTERN}),(?<zoom>${FLOAT_PATTERN})` + `(?:@(?<bearing>${FLOAT_PATTERN})(?:,(?<pitch>${FLOAT_PATTERN}))?)?` + `|` + // Format 2: {minx},{miny},{maxx},{maxy} `(?<minx>${FLOAT_PATTERN}),(?<miny>${FLOAT_PATTERN}),(?<maxx>${FLOAT_PATTERN}),(?<maxy>${FLOAT_PATTERN})` + `|` + // Format 3: auto `(?<auto>auto)` + `)` + `$`, ); const PATH_PATTERN = /^((fill|stroke|width|border|borderwidth):[^|]+\|)*(enc:.+|-?\d+(\.\d*)?,-?\d+(\.\d*)?(\|-?\d+(\.\d*)?,-?\d+(\.\d*)?)+)/; const mercator = new SphericalMercator(); mlgl.on('message', (e) => { if (e.severity === 'WARNING' || e.severity === 'ERROR') { console.log('mlgl:', e); } }); /** * Lookup of sharp output formats by file extension. */ const extensionToFormat = { '.jpg': 'jpeg', '.jpeg': 'jpeg', '.png': 'png', '.webp': 'webp', }; /** * Cache of response data by sharp output format and color. Entry for empty * string is for unknown or unsupported formats. */ const cachedEmptyResponses = { '': Buffer.alloc(0), }; /** * Create an appropriate mlgl response for http errors. * @param {string} format The format (a sharp format or 'pbf'). * @param {string} color The background color (or empty string for transparent). * @param {(err: Error|null, data: object|null) => void} callback The mlgl callback. * @returns {void} */ function createEmptyResponse(format, color, callback) { if (!format || format === 'pbf') { callback(null, { data: cachedEmptyResponses[''] }); return; } if (format === 'jpg') { format = 'jpeg'; } if (!color) { color = 'rgba(255,255,255,0)'; } const cacheKey = `${format},${color}`; // eslint-disable-next-line security/detect-object-injection -- cacheKey is constructed from validated format and color const data = cachedEmptyResponses[cacheKey]; if (data) { callback(null, { data: data }); return; } // create an "empty" response image try { color = new Color(color); const array = color.array(); const channels = array.length === 4 && format !== 'jpeg' ? 4 : 3; sharp(Buffer.from(array), { raw: { width: 1, height: 1, channels, }, }) .toFormat(format) .toBuffer((err, buffer, info) => { if (err) { console.error('Error creating image with Sharp:', err); callback(err, null); return; } // eslint-disable-next-line security/detect-object-injection -- cacheKey is constructed from validated format and color cachedEmptyResponses[cacheKey] = buffer; callback(null, { data: buffer }); }); } catch (error) { console.error('Error during image processing setup:', error); callback(error, null); } } /** * Parses coordinate pair provided to pair of floats and ensures the resulting * pair is a longitude/latitude combination depending on lnglat query parameter. * @param {Array<string>} coordinates Coordinate pair. * @param {object} query Request query parameters. * @returns {Array<number>|null} Parsed coordinate pair as [longitude, latitude] or null if invalid */ function parseCoordinatePair(coordinates, query) { const firstCoordinate = parseFloat(coordinates[0]); const secondCoordinate = parseFloat(coordinates[1]); // Ensure provided coordinates could be parsed and abort if not if (isNaN(firstCoordinate) || isNaN(secondCoordinate)) { return null; } // Check if coordinates have been provided as lat/lng pair instead of the // usual lng/lat pair and ensure resulting pair is lng/lat if (query.latlng === '1' || query.latlng === 'true') { return [secondCoordinate, firstCoordinate]; } return [firstCoordinate, secondCoordinate]; } /** * Parses a coordinate pair from query arguments and optionally transforms it. * @param {Array<string>} coordinatePair Coordinate pair. * @param {object} query Request query parameters. * @param {((coords: Array<number>) => Array<number>)|null} transformer Optional transform function. * @returns {Array<number>|null} Transformed coordinate pair or null if invalid. */ function parseCoordinates(coordinatePair, query, transformer) { const parsedCoordinates = parseCoordinatePair(coordinatePair, query); if (!parsedCoordinates) { return null; } // Transform coordinates if (transformer) { try { return transformer(parsedCoordinates); } catch (error) { console.error('Error transforming coordinates:', error); return null; } } return parsedCoordinates; } /** * Parses paths provided via query into a list of path objects. * @param {object} query Request query parameters. * @param {((coords: Array<number>) => Array<number>)|null} transformer Optional transform function. * @returns {Array<Array<Array<number>>>} Array of paths. */ function extractPathsFromQuery(query, transformer) { // Initiate paths array const paths = []; // Return an empty list if no paths have been provided if ('path' in query && !query.path) { return paths; } // Parse paths provided via path query argument if ('path' in query) { const providedPaths = Array.isArray(query.path) ? query.path : [query.path]; // Iterate through paths, parse and validate them for (const providedPath of providedPaths) { let geometryString = providedPath; // Logic to strip style options (like stroke:red) from the front const parts = providedPath.split('|'); let firstGeometryIndex = 0; for (const [index, part] of parts.entries()) { // A part is considered a style option if it contains ':' but is NOT an 'enc:' string or a coordinate if (part.includes(':') && !part.startsWith('enc:')) { // This is a style option, continue continue; } else { // This is the start of the geometry (enc: or coordinate) firstGeometryIndex = index; break; } } // If we found a geometry, set the geometryString to the rest of the path if (firstGeometryIndex > 0) { geometryString = parts.slice(firstGeometryIndex).join('|'); } // Logic for pushing coords to path when path includes google polyline if ( geometryString.includes('enc:') && PATH_PATTERN.test(geometryString) ) { // +4 because 'enc:' is 4 characters, everything after 'enc:' is considered to be part of the polyline const encIndex = geometryString.indexOf('enc:') + 4; const coords = polyline .decode(geometryString.substring(encIndex)) .map(([lat, lng]) => [lng, lat]); paths.push(coords); } else { // Iterate through paths, parse and validate them const currentPath = []; // Extract coordinate-list from path const pathParts = (geometryString || '').split('|'); // Iterate through coordinate-list, parse the coordinates and validate them for (const pair of pathParts) { // Extract coordinates from coordinate pair const pairParts = pair.split(','); // Ensure we have two coordinates if (pairParts.length === 2) { const pair = parseCoordinates(pairParts, query, transformer); // Ensure coordinates could be parsed and skip them if not if (pair === null) { continue; } // Add the coordinate-pair to the current path if they are valid currentPath.push(pair); } } // Extend list of paths with current path if it contains coordinates if (currentPath.length) { paths.push(currentPath); } } } } return paths; } /** * Parses marker options provided via query and sets corresponding attributes * on marker object. * Options adhere to the following format * [optionName]:[optionValue] * @param {Array<string>} optionsList List of option strings. * @param {object} marker Marker object to configure. * @returns {void} */ function parseMarkerOptions(optionsList, marker) { for (const options of optionsList) { const optionParts = options.split(':'); // Ensure we got an option name and value if (optionParts.length < 2) { continue; } switch (optionParts[0]) { // Scale factor to up- or downscale icon case 'scale': { // Scale factors must not be negative and should have reasonable bounds const scale = parseFloat(optionParts[1]); if (!isNaN(scale) && scale > 0 && scale < 10) { marker.scale = scale; } else { console.warn(`Invalid marker scale: ${optionParts[1]}`); } break; } // Icon offset as positive or negative pixel value in the following // format [offsetX],[offsetY] where [offsetY] is optional case 'offset': { const providedOffset = optionParts[1].split(','); const offsetX = parseFloat(providedOffset[0]); // Set X-axis offset if (!isNaN(offsetX) && Math.abs(offsetX) < 1000) { marker.offsetX = offsetX; } // Check if an offset has been provided for Y-axis if (providedOffset.length > 1) { const offsetY = parseFloat(providedOffset[1]); if (!isNaN(offsetY) && Math.abs(offsetY) < 1000) { marker.offsetY = offsetY; } } break; } default: console.warn(`Unknown marker option: ${optionParts[0]}`); } } } /** * Parses markers provided via query into a list of marker objects. * @param {object} query Request query parameters. * @param {object} options Configuration options. * @param {((coords: Array<number>) => Array<number>)|null} transformer Optional transform function. * @returns {Array<object>} An array of marker objects. */ function extractMarkersFromQuery(query, options, transformer) { // Return an empty list if no markers have been provided if (!query.marker) { return []; } const markers = []; // Check if multiple markers have been provided and mimic a list if it's a // single marker. const providedMarkers = Array.isArray(query.marker) ? query.marker : [query.marker]; // Iterate through provided markers which can have one of the following formats: // [location]|[pathToFileRelativeToConfiguredIconPath] // [location]|[pathToFile...]|[option]|[option]|... for (const providedMarker of providedMarkers) { if (typeof providedMarker !== 'string') { continue; } const markerParts = providedMarker.split('|'); // Ensure we got at least a location and an icon uri if (markerParts.length < 2) { console.warn('Marker requires at least location and icon path'); continue; } const locationParts = markerParts[0].split(','); // Ensure the locationParts contains two items if (locationParts.length !== 2) { console.warn('Marker location must have exactly 2 coordinates'); continue; } let iconURI = markerParts[1]; // Check if icon is served via http otherwise marker icons are expected to // be provided as filepaths relative to configured icon path const isRemoteURL = isValidHttpUrl(iconURI); const isDataURL = iconURI.startsWith('data:'); if (!(isRemoteURL || isDataURL)) { // Sanitize URI with sanitize-filename // https://www.npmjs.com/package/sanitize-filename#details iconURI = sanitize(iconURI); // If the selected icon is not part of available icons skip it if (!options.paths.availableIcons.includes(iconURI)) { console.warn(`Icon not in available icons: ${iconURI}`); continue; } iconURI = path.resolve(options.paths.icons, iconURI); // When we encounter a remote icon check if the configuration explicitly allows them. } else if (isRemoteURL && options.allowRemoteMarkerIcons !== true) { console.warn('Remote marker icons not allowed'); continue; } else if (isDataURL && options.allowInlineMarkerImages !== true) { console.warn('Inline marker images not allowed'); continue; } // Ensure marker location could be parsed const location = parseCoordinates(locationParts, query, transformer); if (location === null) { console.warn('Failed to parse marker location'); continue; } const marker = { location, icon: iconURI, }; // Check if options have been provided if (markerParts.length > 2) { parseMarkerOptions(markerParts.slice(2), marker); } // Add marker to list markers.push(marker); } return markers; } /** * Calculates the zoom level for a given bounding box. * @param {Array<number>} bbox Bounding box as [minx, miny, maxx, maxy]. * @param {number} w Width of the image. * @param {number} h Height of the image. * @param {object} query Request query parameters. * @returns {number} Calculated zoom level. */ function calcZForBBox(bbox, w, h, query) { let z = 25; const padding = query.padding !== undefined ? parseFloat(query.padding) : 0.1; const minCorner = mercator.px([bbox[0], bbox[3]], z); const maxCorner = mercator.px([bbox[2], bbox[1]], z); const w_ = w / (1 + 2 * padding); const h_ = h / (1 + 2 * padding); z -= Math.max( Math.log((maxCorner[0] - minCorner[0]) / w_), Math.log((maxCorner[1] - minCorner[1]) / h_), ) / Math.LN2; z = Math.max(Math.log(Math.max(w, h) / 256) / Math.LN2, Math.min(25, z)); return z; } /** * Responds with an image. * @param {object} options Configuration options. * @param {object} item Item object containing map and other information. * @param {number} z Zoom level. * @param {number} lon Longitude of the center. * @param {number} lat Latitude of the center. * @param {number} bearing Map bearing. * @param {number} pitch Map pitch. * @param {number} width Width of the image. * @param {number} height Height of the image. * @param {number} scale Scale factor. * @param {string} format Image format. * @param {object} res Express response object. * @param {Buffer|null} overlay Optional overlay image. * @param {string} mode Rendering mode ('tile' or 'static'). * @returns {Promise<void>} */ async function respondImage( options, item, z, lon, lat, bearing, pitch, width, height, scale, format, res, overlay = null, mode = 'tile', ) { if ( Math.abs(lon) > 180 || Math.abs(lat) > 85.06 || lon !== lon || lat !== lat ) { return res.status(400).send('Invalid center'); } if ( Math.min(width, height) <= 0 || Math.max(width, height) * scale > (options.maxSize || 2048) || width !== width || height !== height ) { return res.status(400).send('Invalid size'); } if (format === 'png' || format === 'webp') { /* empty */ } else if (format === 'jpg' || format === 'jpeg') { format = 'jpeg'; } else { return res.status(400).send('Invalid format'); } const tileMargin = Math.max(options.tileMargin || 0, 0); let pool; if (mode === 'tile' && tileMargin === 0) { // eslint-disable-next-line security/detect-object-injection -- scale is validated by allowedScales pool = item.map.renderers[scale]; } else { // eslint-disable-next-line security/detect-object-injection -- scale is validated by allowedScales pool = item.map.renderersStatic[scale]; } if (!pool) { console.error(`Pool not found for scale ${scale}, mode ${mode}`); return res.status(500).send('Renderer pool not configured'); } pool.acquire(async (err, renderer) => { // Check if pool.acquire failed or returned null/invalid renderer if (err) { console.error('Failed to acquire renderer from pool:', err); if (!res.headersSent) { return res.status(503).send('Renderer pool error'); } return; } if (!renderer) { console.error( 'Renderer is null - likely crashed or failed to initialize', ); if (!res.headersSent) { return res.status(503).send('Renderer unavailable'); } return; } // Validate renderer has required methods (basic health check) if (typeof renderer.render !== 'function') { console.error('Renderer is invalid - missing render method'); try { pool.removeBadObject(renderer); } catch (e) { console.error('Error removing bad renderer:', e); } if (!res.headersSent) { return res.status(503).send('Renderer invalid'); } return; } // For 512px tiles, use the actual maplibre-native zoom. For 256px tiles, use zoom - 1 let mlglZ; if (width === 512) { mlglZ = Math.max(0, z); } else { mlglZ = Math.max(0, z - 1); } const params = { zoom: mlglZ, center: [lon, lat], bearing, pitch, width, height, }; // HACK(Part 1) 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 in Part 2. if (z === 0 && width === 256) { params.width *= 2; params.height *= 2; } // END HACK(Part 1) if (z > 0 && tileMargin > 0) { params.width += tileMargin * 2; params.height += tileMargin * 2; } // Set a timeout for the render operation to detect hung renderers const renderTimeout = setTimeout(() => { console.error('Renderer timeout - destroying hung renderer'); try { pool.removeBadObject(renderer); } catch (e) { console.error('Error removing timed-out renderer:', e); } if (!res.headersSent) { res.status(503).send('Renderer timeout'); } }, 30000); // 30 second timeout try { renderer.render(params, (err, data) => { clearTimeout(renderTimeout); if (res.headersSent) { // Timeout already fired and sent response, don't process return; } if (err) { console.error('Render error:', err); try { pool.removeBadObject(renderer); } catch (e) { console.error('Error removing failed renderer:', e); } if (!res.headersSent) { return res .status(500) .header('Content-Type', 'text/plain') .send(err); } return; } // Only release if render was successful pool.release(renderer); const image = sharp(data, { raw: { premultiplied: true, width: params.width * scale, height: params.height * scale, channels: 4, }, }); if (z > 0 && tileMargin > 0) { const y = mercator.px(params.center, z)[1]; const yoffset = Math.max( Math.min(0, y - 128 - tileMargin), y + 128 + tileMargin - Math.pow(2, z + 8), ); image.extract({ left: tileMargin * scale, top: (tileMargin + yoffset) * scale, width: width * scale, height: height * scale, }); } // 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. if (z === 0 && width === 256) { image.resize(width * scale, height * scale); } const composites = []; if (overlay) { composites.push({ input: overlay }); } if (item.watermark) { const canvas = renderWatermark(width, height, scale, item.watermark); composites.push({ input: canvas.toBuffer() }); } if (mode === 'static' && item.staticAttributionText) { const canvas = renderAttribution( width, height, scale, item.staticAttributionText, ); composites.push({ input: canvas.toBuffer() }); } if (composites.length > 0) { image.composite(composites); } // Legacy formatQuality is deprecated but still works const formatQualities = options.formatQuality || {}; if (Object.keys(formatQualities).length !== 0) { console.log( '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.', ); } // eslint-disable-next-line security/detect-object-injection -- format is validated above const formatQuality = formatQualities[format]; // eslint-disable-next-line security/detect-object-injection -- format is validated above const formatOptions = (options.formatOptions || {})[format] || {}; if (format === 'png') { image.png({ progressive: formatOptions.progressive, compressionLevel: formatOptions.compressionLevel, adaptiveFiltering: formatOptions.adaptiveFiltering, palette: formatOptions.palette, quality: formatOptions.quality, effort: formatOptions.effort, colors: formatOptions.colors, dither: formatOptions.dither, }); } else if (format === 'jpeg') { image.jpeg({ quality: formatOptions.quality || formatQuality || 80, progressive: formatOptions.progressive, }); } else if (format === 'webp') { image.webp({ quality: formatOptions.quality || formatQuality || 90 }); } image.toBuffer((err, buffer, info) => { if (err || !buffer) { console.error('Sharp error:', err); if (!res.headersSent) { return res.status(500).send('Image processing failed'); } return; } if (!res.headersSent) { res.set({ 'Last-Modified': item.lastModified, 'Content-Type': `image/${format}`, }); return res.status(200).send(buffer); } }); }); } catch (error) { clearTimeout(renderTimeout); console.error('Unexpected error during render:', error); try { pool.removeBadObject(renderer); } catch (e) { console.error('Error removing renderer after error:', e); } if (!res.headersSent) { return res.status(500).send('Render failed'); } } }); } /** * Handles requests for tile images. * @param {object} options - Configuration options for the server. * @param {object} repo - The repository object holding style data. * @param {object} req - Express request object. * @param {string} req.params.id - The id of the style. * @param {string} req.params.p1 - The tile size parameter, if available. * @param {string} req.params.p2 - The z parameter. * @param {string} req.params.p3 - The x parameter. * @param {string} req.params.p4 - The y parameter. * @param {string} req.params.scale - The scale parameter. * @param {string} req.params.format - The format of the image. * @param {object} res - Express response object. * @param {object} next - Express next middleware function. * @param {number} maxScaleFactor - The maximum scale factor allowed. * @param {number} defailtTileSize - Default tile size. * @returns {Promise<void>} */ async function handleTileRequest( options, repo, req, res, next, maxScaleFactor, defailtTileSize, ) { const { id, p1: tileSize, p2: zParam, p3: xParam, p4: yParam, scale: scaleParam, format, } = req.params; // eslint-disable-next-line security/detect-object-injection -- id is route parameter, validated by Express const item = repo[id]; if (!item) { return res.sendStatus(404); } const modifiedSince = req.get('if-modified-since'); const cc = req.get('cache-control'); if (modifiedSince && (!cc || cc.indexOf('no-cache') === -1)) { if ( new Date(item.lastModified).getTime() === new Date(modifiedSince).getTime() ) { return res.sendStatus(304); } } const z = parseFloat(zParam) | 0; const x = parseFloat(xParam) | 0; const y = parseFloat(yParam) | 0; const scale = allowedScales(scaleParam, maxScaleFactor); let parsedTileSize = parseInt(defailtTileSize, 10); if (tileSize) { parsedTileSize = parseInt(allowedTileSizes(tileSize), 10); if (parsedTileSize == null) { return res.status(400).send('Invalid Tile Size'); } } if ( scale == null || z < 0 || x < 0 || y < 0 || z > 22 || x >= Math.pow(2, z) || y >= Math.pow(2, z) ) { return res.status(400).send('Out of bounds'); } const tileCenter = mercator.ll( [((x + 0.5) / (1 << z)) * (256 << z), ((y + 0.5) / (1 << z)) * (256 << z)], z, ); // prettier-ignore return await respondImage( options, item, z, tileCenter[0], tileCenter[1], 0, 0, parsedTileSize, parsedTileSize, scale, format, res, ); } /** * Handles requests for static map images. * @param {object} options - Configuration options for the server. * @param {object} repo - The repository object holding style data. * @param {object} req - Express request object. * @param {string} req.params.id - The id of the style. * @param {string} req.params.p2 - The raw or static parameter. * @param {string} req.params.p3 - The staticType parameter. * @param {string} req.params.p4 - The widthAndHeight parameter. * @param {string} req.params.scale - The scale parameter. * @param {string} req.params.format - The format of the image. * @param {object} res - Express response object. * @param {object} next - Express next middleware function. * @param {number} maxScaleFactor - The maximum scale factor allowed. * @returns {Promise<void>} */ async function handleStaticRequest( options, repo, req, res, next, maxScaleFactor, ) { const { id, p2: raw, p3: staticType, p4: widthAndHeight, scale: scaleParam, format, } = req.params; // eslint-disable-next-line security/detect-object-injection -- id is route parameter, validated by Express const item = repo[id]; let parsedWidth = null; let parsedHeight = null; if (widthAndHeight) { const sizeMatch = widthAndHeight.match(/^(\d+)x(\d+)$/); if (sizeMatch) { const width = parseInt(sizeMatch[1], 10); const height = parseInt(sizeMatch[2], 10); if ( isNaN(width) || isNaN(height) || width !== parseFloat(sizeMatch[1]) || height !== parseFloat(sizeMatch[2]) ) { return res .status(400) .send('Invalid width or height provided in size parameter'); } parsedWidth = width; parsedHeight = height; } else { return res .status(400) .send('Invalid width or height provided in size parameter'); } } else { return res .status(400) .send('Invalid width or height provided in size parameter'); } const scale = allowedScales(scaleParam, maxScaleFactor); let isRaw = raw === 'raw'; const staticTypeMatch = staticType.match(staticTypeRegex); if (!item || !format || !scale || !staticTypeMatch?.groups) { return res.sendStatus(404); } if (staticTypeMatch.groups.lon) { // Center Based Static Image const z = parseFloat(staticTypeMatch.groups.zoom) || 0; let x = parseFloat(staticTypeMatch.groups.lon) || 0; let y = parseFloat(staticTypeMatch.groups.lat) || 0; const bearing = parseFloat(staticTypeMatch.groups.bearing) || 0; const pitch = parseInt(staticTypeMatch.groups.pitch) || 0; if (z < 0) { return res.status(404).send('Invalid zoom'); } const transformer = isRaw ? mercator.inverse.bind(mercator) : item.dataProjWGStoInternalWGS; if (transformer) { const ll = transformer([x, y]); x = ll[0]; y = ll[1]; } const paths = extractPathsFromQuery(req.query, transformer); const markers = extractMarkersFromQuery(req.query, options, transformer); // prettier-ignore const overlay = await renderOverlay( z, x, y, bearing, pitch, parsedWidth, parsedHeight, scale, paths, markers, req.query, ); // prettier-ignore return await respondImage( options, item, z, x, y, bearing, pitch, parsedWidth, parsedHeight, scale, format, res, overlay, 'static', ); } else if (staticTypeMatch.groups.minx) { // Area Based Static Image const minx = parseFloat(staticTypeMatch.groups.minx) || 0; const miny = parseFloat(staticTypeMatch.groups.miny) || 0; const maxx = parseFloat(staticTypeMatch.groups.maxx) || 0; const maxy = parseFloat(staticTypeMatch.groups.maxy) || 0; const bbox = [minx, miny, maxx, maxy]; let center = [(bbox[0] + bbox[2]) / 2, (bbox[1] + bbox[3]) / 2]; const transformer = isRaw ? mercator.inverse.bind(mercator) : item.dataProjWGStoInternalWGS; if (transformer) { const minCorner = transformer(bbox.slice(0, 2)); const maxCorner = transformer(bbox.slice(2)); bbox[0] = minCorner[0]; bbox[1] = minCorner[1]; bbox[2] = maxCorner[0]; bbox[3] = maxCorner[1]; center = transformer(center); } const z = calcZForBBox(bbox, parsedWidth, parsedHeight, req.query); const x = center[0]; const y = center[1]; const bearing = 0; const pitch = 0; const paths = extractPathsFromQuery(req.query, transformer); const markers = extractMarkersFromQuery(req.query, options, transformer); // prettier-ignore const overlay = await renderOverlay( z, x, y, bearing, pitch, parsedWidth, parsedHeight, scale, paths, markers, req.query, ); // prettier-ignore return await respondImage( options, item, z, x, y, bearing, pitch, parsedWidth, parsedHeight, scale, format, res, overlay, 'static', ); } else if (staticTypeMatch.groups.auto) { // Area Static Image const bearing = 0; const pitch = 0; const transformer = isRaw ? mercator.inverse.bind(mercator) : item.dataProjWGStoInternalWGS; const paths = extractPathsFromQuery(req.query, transformer); const markers = extractMarkersFromQuery(req.query, options, transformer); // Extract coordinates from markers const markerCoordinates = []; for (const marker of markers) { markerCoordinates.push(marker.location); } // Create array with coordinates from markers and path const coords = [].concat(paths.flat()).concat(markerCoordinates); // Check if we have at least one coordinate to calculate a bounding box if (coords.length < 1) { return res.status(400).send('No coordinates provided'); } const bbox = [Infinity, Infinity, -Infinity, -Infinity]; for (const pair of coords) { bbox[0] = Math.min(bbox[0], pair[0]); bbox[1] = Math.min(bbox[1], pair[1]); bbox[2] = Math.max(bbox[2], pair[0]); bbox[3] = Math.max(bbox[3], pair[1]); } const bbox_ = mercator.convert(bbox, '900913'); const center = mercator.inverse([ (bbox_[0] + bbox_[2]) / 2, (bbox_[1] + bbox_[3]) / 2, ]); // Calculate zoom level const maxZoom = parseFloat(req.query.maxzoom); let z = calcZForBBox(bbox, parsedWidth, parsedHeight, req.query); if (maxZoom > 0) { z = Math.min(z, maxZoom); } const x = center[0]; const y = center[1]; // prettier-ignore const overlay = await renderOverlay( z, x, y, bearing, pitch, parsedWidth, parsedHeight, scale, paths, markers, req.query, ); // prettier-ignore return await respondImage( options, item, z, x, y, bearing, pitch, parsedWidth, parsedHeight, scale, format, res, overlay, 'static', ); } else { return res.sendStatus(404); } } const existingFonts = {}; let maxScaleFactor = 2; export const serve_rendered = { /** * Initializes the serve_rendered module. * @param {object} options Configuration options. * @param {object} repo Repository object. * @param {object} programOpts - An object containing the program options. * @returns {Promise<express.Application>} A promise that resolves to the Express app. */ init: async function (options, repo, programOpts) { const { verbose, tileSize: defailtTileSize = 256 } = programOpts; maxScaleFactor = Math.min(Math.floor(options.maxScaleFactor || 3), 9); const app = express().disable('x-powered-by'); /** * Handles requests for tile images. * @param {object} req - Express request object. * @param {object} res - Express response object. * @param {object} next - Express next middleware function. * @param {string} req.params.id - The id of the style. * @param {string} [req.params.p1] - The tile size or static parameter, if available. * @param {string} req.params.p2 - The z, static, or raw parameter. * @param {string} req.params.p3 - The x or staticType parameter. * @param {string} req.params.p4 - The y or width parameter. * @param {string} req.params.scale - The scale parameter. * @param {string} req.params.format - The format of the image. * @returns {Promise<void>} */ app.get( `/:id{/:p1}/:p2/:p3/:p4{@:scale}{.:format}`, async (req, res, next) => { try { const { p1, p2, id, p3, p4, scale, format } = req.params; const requestType = (!p1 && p2 === 'static') || (p1 === 'static' && p2 === 'raw') ? 'static' : 'tile'; if (verbose >= 3) { console.log( `Handling rendered %s request for: /styles/%s%s/%s/%s/%s%s.%s`, requestType, String(id).replace(/\n|\r/g, ''), p1 ? '/' + String(p1).replace(/\n|\r/g, '') : '', String(p2).replace(/\n|\r/g, ''), String(p3).replace(/\n|\r/g, ''), String(p4).replace(/\n|\r/g, ''), scale ? '@' + String(scale).replace(/\n|\r/g, '') : '', String(format).replace(/\n|\r/g, ''), ); } if (requestType === 'static') { // Route to static if p2 is static if (options.serveStaticMaps !== false) { return handleStaticRequest( options, repo, req, res, next, maxScaleFactor, ); } return res.sendStatus(404); } return handleTileRequest( options, repo, req, res, next, maxScaleFactor, defailtTileSize, ); } catch (e) { console.log(e); return next(e); } }, ); /** * Handles requests for rendered tilejson endpoint. * @param {object} req - Express request object. * @param {object} res - Express response object. * @param {object} next - Express next middleware function. * @param {string} req.params.id - The id of the tilejson * @param {string} [req.params.tileSize] - The size of the tile, if specified. * @returns {void} */ app.get('{/:tileSize}/:id.json', (req, res, next) => { const item = repo[req.params.id]; if (!item) { return res.sendStatus(404); } const tileSize = parseInt(req.params.tileSize, 10) || undefined; if (verbose >= 3) { console.log( `Handling rendered tilejson request for: /styles/%s%s.json`, req.params.tileSize ? String(req.params.tileSize).replace(/\n|\r/g, '') + '/' : '', String(req.params.id).replace(/\n|\r/g, ''), ); } const info = clone(item.tileJSON); info.tileSize = tileSize != undefined ? tileSize : 256; info.tiles = getTileUrls( req, info.tiles, `styles/${req.params.id}`, tileSize, info.format, item.publicUrl, ); return res.send(info); }); const fonts = await listFonts(options.paths.fonts); Object.assign(existingFonts, fonts); return app; }, /** * Adds a new item to the repository. * @param {object} options Configuration options. * @param {object} repo Repository object. * @param {object} params Parameters object. * @param {string} id ID of the item. * @param {object} programOpts - An object containing the program options * @param {object} style pre-fetched/read StyleJSON object. * @param {(dataId: string) => object} dataResolver Function to resolve data. * @returns {Promise<void>} */ add: async function ( options, repo, params, id, programOpts, style, dataResolver, ) { const map = { renderers: [], renderersStatic: [], sources: {}, sourceTypes: {}, sparseFlags: {}, }; const { publicUrl, verbose, fetchTimeout } = programOpts; const styleJSON = clone(style); // Global sparse flag for HTTP/remote sources (from config options) const globalSparse = options.sparse ?? true; /** * Creates a pool of renderers. * @param {number} ratio Pixel ratio * @param {string} mode Rendering mode ('tile' or 'static'). * @param {number} min Minimum pool size. * @param {number} max Maximum pool size. * @returns {object} The created pool */ const createPool = (ratio, mode, min, max) => { /** * Creates a renderer * @param {number} ratio Pixel ratio * @param {(err: Error|null, renderer: object) => void} createCallback Function that returns the renderer when created * @returns {void} */ const createRenderer = (ratio, createCallback) => { const renderer = new mlgl.Map({ mode, ratio, request: async (req, callback) => { const protocol = req.url.split(':')[0]; if (verbose >= 3) { console.log('Handling request:', req); } if (protocol === 'sprites') { // eslint-disable-next-line security/detect-object-injection -- protocol is 'sprites', validated above const dir = options.paths[protocol]; const file = decodeURIComponent(req.url).substring( protocol.length + 3, ); readFile(path.join(dir, file)) .then((data) => { callback(null, { data: data }); }) .catch((err) => { callback(err, null); }); } else if (protocol === 'fonts') { const parts = req.url.split('/'); const fontstack = decodeURIComponent(parts[2]); const range = parts[3].split('.')[0]; try { const concatenated = await getFontsPbf( null, // eslint-disable-next-line security/detect-object-injection -- protocol is 'fonts', validated above options.paths[protocol], fontstack, range, existingFonts, ); callback(null, { data: concatenated }); } catch (err) { callback(err, { data: null }); } } else if (protocol === 'mbtiles' || protocol === 'pmtiles') { const parts = req.url.split('/'); const sourceId = parts[2]; // eslint-disable-next-line security/detect-object-injection -- sourceId from internal style source names const source = map.sources[sourceId]; // eslint-disable-next-line security/detect-object-injection -- sourceId from internal style source names const sourceType = map.sourceTypes[sourceId]; // eslint-disable-next-line security/detect-object-injection -- sourceId from internal style source names const sourceInfo = styleJSON.sources[sourceId]; const z = parts[3] | 0; const x = parts[4] | 0; const y = parts[5].split('.')[0] | 0; const format = parts[5].split('.')[1]; const fetchTile = await fetchTileData( source, sourceType, z, x, y, ); if (fetchTile == null) { if (verbose >= 2) { console.log('fetchTile null on %s', req.url); } // eslint-disable-next-line security/detect-object-injection -- sourceId from internal style source names const sparse = map.sparseFlags[sourceId] ?? true; // sparse=true (default) -> return empty callback so MapLibre can overzoom if (sparse) { callback(); return; } // sparse=false -> 204 (empty tile, no overzoom) - create blank response createEmptyResponse( sourceInfo.format, sourceInfo.color, callback, ); return; } const response = {}; response.data = fetchTile.data; let headers = fetchTile.headers; if (headers['Last-Modified']) { response.modified = new Date(headers['Last-Modified']); } if (format === 'pbf') { let isGzipped = response.data .slice(0, 2) .indexOf(Buffer.from([0x1f, 0x8b])) === 0; if (isGzipped) { response.data = await gunzipP(response.data); } if (options.dataDecoratorFunc) { response.data = options.dataDecoratorFunc( sourceId, 'data', response.data, z, x, y, ); } } callback(null, response); } else if (protocol === 'http' || protocol === 'https') { const controller = new AbortController(); const timeoutMs = (fetchTimeout && Number(fetchTimeout)) || 15000; let timeoutId; try { timeoutId = setTimeout(() => controller.abort(), timeoutMs); const response = await fetch(req.url, { signal: controller.signal, }); clearTimeout(timeoutId); // HTTP 204 No Content means "empty tile" - generate a blank tile if (response.status === 204) { const parts = url.parse(req.url); const extension = path.extname(parts.pathname).toLowerCase(); // eslint-disable-next-line security/detect-object-injection -- extension is from path.extname, limited set const format = extensionToFormat[extension] || ''; createEmptyResponse(format, '', callback); return; } if (!response.ok) { if (verbose >= 2) { console.log( 'fetchTile HTTP %d on %s, %s', response.status, req.url, globalSparse ? 'allowing overzoom' : 'creating empty tile', ); } if (globalSparse) { // sparse=true -> allow overzoom callback(); return; } // sparse=false (default) -> create empty tile const parts = url.parse(req.url); const extension = path.extname(parts.pathname).toLowerCase(); // eslint-disable-next-line security/detect-object-injection -- extension is from path.extname, limited set const format = extensionToFormat[extension] || ''; createEmptyResponse(format, '', callback); return; } const responseHeaders = response.headers; const responseData = await response.arrayBuffer(); const parsedResponse = {}; if (responseHeaders.get('last-modified')) { parsedResponse.modified = new Date( responseHeaders.get('last-modified'), ); } if (responseHeaders.get('expires')) { parsedResponse.expires = new Date( responseHeaders.get('expires'), ); } if (responseHeaders.get('etag')) { parsedResponse.etag = responseHeaders.get('etag'); } parsedResponse.data = Buffer.from(responseData); callback(null, parsedResponse); } catch (error) { // Log DNS failures if (error.cause?.code === 'ENOTFOUND') { console.error( `DNS RESOLUTION FAILED for ${req.url}. ` + `This domain may be unreachable or misconfigured in your style. ` + `Consider removing it or fixing the DNS.`, ); } // Log timeout if (error.name === 'AbortError') { console.error( `FETCH TIMEOUT for ${req.url}. ` + `The request took longer than ${timeoutMs} ms to complete.`, ); } // Log all other errors console.error( `Error fetching remote URL ${req.url}:`, error.message || error, ); if (globalSparse) { // sparse=true -> allow overzoom callback(); return; } // sparse=false (default) -> create empty tile const parts = url.parse(req.url); const extension = path.extname(parts.pathname).toLowerCase(); // eslint-disable-next-line security/detect-object-injection -- extension is from path.extname, limited set const format = extensionToFormat[extension] || ''; createEmptyResponse(format, '', callback); } } else if (protocol === 'file') { const name = decodeURI(req.url).substring(protocol.length + 3); const file = path.join(options.paths['files'], name); if (await existsP(file)) { const inputFileStats = await fsp.stat(file); if (!inputFileStats.isFile() || inputFileStats.size === 0) { throw Error( `File is not valid: "${req.url}" - resolved to "${file}"`, ); } readFile(file) .then((data) => { callback(null, { data: data }); }) .catch((err) => { callback(err, null); }); } else { throw Error( `File does not exist: "${req.url}" - resolved to "${file}"`, ); } } }, }); renderer.load(styleJSON); createCallback(null, renderer); }; return new advancedPool.Pool({ min, max, create: createRenderer.bind(null, ratio), destroy: (renderer) => { renderer.release(); }, }); }; const styleFile = params.style; const styleJSONPath = path.resolve(options.paths.styles, styleFil