tileserver-gl-light
Version:
Map tile server for JSON GL styles - serving vector tiles
1,015 lines (924 loc) • 34.3 kB
JavaScript
;
import fs from 'node:fs';
import path from 'path';
import fnv1a from '@sindresorhus/fnv1a';
import chokidar from 'chokidar';
import clone from 'clone';
import cors from 'cors';
import enableShutdown from 'http-shutdown';
import express from 'express';
import handlebars from 'handlebars';
import { SphericalMercator } from '@mapbox/sphericalmercator';
const mercator = new SphericalMercator();
import morgan from 'morgan';
import { serve_data } from './serve_data.js';
import { serve_style } from './serve_style.js';
import { serve_font } from './serve_font.js';
import {
allowedTileSizes,
getTileUrls,
getPublicUrl,
isValidHttpUrl,
isValidRemoteUrl,
} from './utils.js';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const packageJson = JSON.parse(
fs.readFileSync(__dirname + '/../package.json', 'utf8'),
);
const isLight = packageJson.name.slice(-6) === '-light';
const { serve_rendered } = await import(
`${!isLight ? `./serve_rendered.js` : `./serve_light.js`}`
);
/**
* Starts the server.
* @param {object} opts - Configuration options for the server.
* @returns {Promise<object>} - A promise that resolves to the server object.
*/
async function start(opts) {
console.log('Starting server');
const app = express().disable('x-powered-by');
const serving = {
styles: {},
rendered: {},
data: {},
fonts: {},
};
app.enable('trust proxy');
if (process.env.NODE_ENV !== 'test') {
const defaultLogFormat =
process.env.NODE_ENV === 'production' ? 'tiny' : 'dev';
const logFormat = opts.logFormat || defaultLogFormat;
app.use(
morgan(logFormat, {
stream: opts.logFile
? fs.createWriteStream(opts.logFile, { flags: 'a' })
: process.stdout,
skip: (req, res) =>
opts.silent && (res.statusCode === 200 || res.statusCode === 304),
}),
);
}
let config = opts.config || null;
let configPath = null;
if (opts.configPath) {
configPath = path.resolve(opts.configPath);
try {
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
} catch {
console.log('ERROR: Config file not found or invalid!');
console.log(' See README.md for instructions and sample data.');
process.exit(1);
}
}
if (!config) {
console.log('ERROR: No config file not specified!');
process.exit(1);
}
const options = config.options || {};
const paths = options.paths || {};
options.paths = paths;
paths.root = path.resolve(
configPath ? path.dirname(configPath) : process.cwd(),
paths.root || '',
);
paths.styles = path.resolve(paths.root, paths.styles || '');
paths.fonts = path.resolve(paths.root, paths.fonts || '');
paths.sprites = path.resolve(paths.root, paths.sprites || '');
paths.mbtiles = path.resolve(paths.root, paths.mbtiles || '');
paths.pmtiles = path.resolve(paths.root, paths.pmtiles || '');
paths.icons = paths.icons
? path.resolve(paths.root, paths.icons)
: path.resolve(__dirname, '../public/resources/images');
paths.files = paths.files
? path.resolve(paths.root, paths.files)
: path.resolve(__dirname, '../public/files');
const startupPromises = [];
for (const type of Object.keys(paths)) {
// eslint-disable-next-line security/detect-object-injection -- paths[type] constructed from validated config paths
if (!fs.existsSync(paths[type])) {
console.error(
// eslint-disable-next-line security/detect-object-injection -- type is from Object.keys of paths config
`The specified path for "${type}" does not exist (${paths[type]}).`,
);
process.exit(1);
}
}
/**
* Recursively get all files within a directory.
* Inspired by https://stackoverflow.com/a/45130990/10133863
* @param {string} directory Absolute path to a directory to get files from.
* @returns {Promise<string[]>} - A promise that resolves to an array of file paths relative to the icon directory.
*/
async function getFiles(directory) {
// Fetch all entries of the directory and attach type information
const dirEntries = await fs.promises.readdir(directory, {
withFileTypes: true,
});
// Iterate through entries and return the relative file-path to the icon directory if it is not a directory
// otherwise initiate a recursive call
const files = await Promise.all(
dirEntries.map((dirEntry) => {
const entryPath = path.resolve(directory, dirEntry.name);
return dirEntry.isDirectory()
? getFiles(entryPath)
: entryPath.replace(paths.icons + path.sep, '');
}),
);
// Flatten the list of files to a single array
return files.flat();
}
// Load all available icons into a settings object
startupPromises.push(
getFiles(paths.icons).then((files) => {
paths.availableIcons = files;
}),
);
if (options.dataDecorator) {
try {
const dataDecoratorPath = path.resolve(paths.root, options.dataDecorator);
const module = await import(dataDecoratorPath);
options.dataDecoratorFunc = module.default;
} catch (e) {
console.error(`Error loading data decorator: ${e}`);
// Intentionally don't set options.dataDecoratorFunc - let it remain undefined
}
}
const data = clone(config.data || {});
if (opts.cors) {
app.use(cors());
}
app.use('/data/', serve_data.init(options, serving.data, opts));
app.use('/files/', express.static(paths.files));
app.use('/styles/', serve_style.init(options, serving.styles, opts));
if (!isLight) {
startupPromises.push(
serve_rendered.init(options, serving.rendered, opts).then((sub) => {
app.use('/styles/', sub);
}),
);
}
/**
* Adds a style to the server.
* @param {string} id - The ID of the style.
* @param {object} item - The style configuration object.
* @param {boolean} allowMoreData - Whether to allow adding more data sources.
* @param {boolean} reportFonts - Whether to report fonts.
* @returns {Promise<boolean>} - Returns true if successful, false otherwise.
*/
async function addStyle(id, item, allowMoreData, reportFonts) {
let success = true;
let styleJSON;
try {
// Style files should only be HTTP/HTTPS, not S3
if (isValidHttpUrl(item.style)) {
const res = await fetch(item.style);
if (!res.ok) {
throw new Error(`fetch error ${res.status}`);
}
styleJSON = await res.json();
} else {
const styleFile = path.resolve(options.paths.styles, item.style);
const styleFileData = await fs.promises.readFile(styleFile);
styleJSON = JSON.parse(styleFileData);
}
} catch (err) {
console.log(`Error getting style file "${item.style}"`);
console.error(err && err.stack ? err.stack : err);
return false;
}
if (item.serve_data !== false) {
success = serve_style.add(
options,
serving.styles,
item,
id,
opts,
styleJSON,
(styleSourceId, protocol) => {
let dataItemId;
for (const id of Object.keys(data)) {
if (id === styleSourceId) {
// Style id was found in data ids, return that id
dataItemId = id;
break;
} else {
// eslint-disable-next-line security/detect-object-injection -- id is from Object.keys of data config
const sourceData = data[id];
if (
(sourceData.pmtiles && sourceData.pmtiles === styleSourceId) ||
(sourceData.mbtiles && sourceData.mbtiles === styleSourceId)
) {
dataItemId = id;
break;
}
}
}
if (dataItemId) {
// input files exists in the data config, return found id
return dataItemId;
} else {
if (!allowMoreData) {
console.log(
`ERROR: style "${item.style}" using unknown file "${styleSourceId}"! Skipping...`,
);
return undefined;
} else {
let id =
styleSourceId.substr(0, styleSourceId.lastIndexOf('.')) ||
styleSourceId;
// PMTiles can be remote URLs (HTTP or S3), generate unique ID for remote sources
if (isValidRemoteUrl(styleSourceId)) {
id =
fnv1a(styleSourceId) + '_' + id.replace(/^.*\/(.*)$/, '$1');
}
// eslint-disable-next-line security/detect-object-injection -- id is being checked for existence before modification
while (data[id]) id += '_'; //if the data source id already exists, add a "_" untill it doesn't
//Add the new data source to the data array.
// eslint-disable-next-line security/detect-object-injection -- id is constructed above to be unique
data[id] = {
[protocol]: styleSourceId,
};
return id;
}
}
},
(font) => {
if (reportFonts) {
// eslint-disable-next-line security/detect-object-injection -- font is font name from style
serving.fonts[font] = true;
}
},
);
}
if (success && item.serve_rendered !== false) {
if (!isLight) {
startupPromises.push(
serve_rendered.add(
options,
serving.rendered,
item,
id,
opts,
styleJSON,
function dataResolver(styleSourceId) {
let resolvedFileType;
let resolvedInputFile;
let resolvedS3Profile;
let resolvedRequestPayer;
let resolvedS3Region;
let resolvedS3UrlFormat;
let resolvedSparse;
// Debug logging to see what we're trying to match
if (opts.verbose >= 3) {
console.log(
`[dataResolver] Looking for styleSourceId: ${styleSourceId}`,
);
console.log(
`[dataResolver] Available data keys: ${Object.keys(data).join(', ')}`,
);
}
for (const id of Object.keys(data)) {
// eslint-disable-next-line security/detect-object-injection -- id is from Object.keys of data config
const sourceData = data[id];
let currentFileType;
let currentInputFileValue;
// Check for recognized file type keys
if (Object.hasOwn(sourceData, 'pmtiles')) {
currentFileType = 'pmtiles';
currentInputFileValue = sourceData.pmtiles;
} else if (Object.hasOwn(sourceData, 'mbtiles')) {
currentFileType = 'mbtiles';
currentInputFileValue = sourceData.mbtiles;
}
if (currentFileType && currentInputFileValue) {
// Debug logging
if (opts.verbose >= 3) {
console.log(
`[dataResolver] Checking id="${id}", file="${currentInputFileValue}"`,
);
}
// Check if this source matches the styleSourceId
// Match by ID, by file path, or by base filename
const matchById = styleSourceId === id;
const matchByFile = styleSourceId === currentInputFileValue;
const matchByBasename =
styleSourceId.includes(currentInputFileValue) ||
currentInputFileValue.includes(styleSourceId);
if (matchById || matchByFile || matchByBasename) {
if (opts.verbose >= 2) {
console.log(
`[dataResolver] Match found for styleSourceId: ${styleSourceId}. (byId=${matchById}, byFile=${matchByFile}, byBasename=${matchByBasename})`,
);
}
resolvedFileType = currentFileType;
resolvedInputFile = currentInputFileValue;
// Get s3Profile if present
if (Object.hasOwn(sourceData, 's3Profile')) {
resolvedS3Profile = sourceData.s3Profile;
}
// Get s3UrlFormat if present
if (Object.hasOwn(sourceData, 's3UrlFormat')) {
resolvedS3UrlFormat = sourceData.s3UrlFormat;
}
// Get requestPayer if present
if (Object.hasOwn(sourceData, 'requestPayer')) {
resolvedRequestPayer = !!sourceData.requestPayer;
}
// Get s3Region if present
if (Object.hasOwn(sourceData, 's3Region')) {
resolvedS3Region = sourceData.s3Region;
}
// Get sparse: per-source overrides global, default to true
resolvedSparse =
sourceData.sparse ?? options.sparse ?? true;
break; // Found our match, exit the outer loop
}
}
}
// If no match was found
if (!resolvedInputFile || !resolvedFileType) {
console.warn(
`Data source not found for styleSourceId: ${styleSourceId}`,
);
console.warn(
`Available data sources: ${Object.keys(data)
.map((id) => {
// eslint-disable-next-line security/detect-object-injection
const src = data[id];
return `${id} -> ${src.pmtiles || src.mbtiles || 'unknown'}`;
})
.join(', ')}`,
);
return {
inputFile: undefined,
fileType: undefined,
s3Profile: undefined,
requestPayer: false,
s3Region: undefined,
s3UrlFormat: undefined,
sparse: true,
};
}
// PMTiles supports remote URLs (HTTP and S3), skip path resolution for those
if (!isValidRemoteUrl(resolvedInputFile)) {
// Ensure options.paths and options.paths[resolvedFileType] exist before trying to use them
if (
options &&
options.paths &&
// eslint-disable-next-line security/detect-object-injection -- resolvedFileType is either 'pmtiles' or 'mbtiles'
options.paths[resolvedFileType]
) {
resolvedInputFile = path.resolve(
// eslint-disable-next-line security/detect-object-injection -- resolvedFileType is either 'pmtiles' or 'mbtiles'
options.paths[resolvedFileType],
resolvedInputFile,
);
} else {
console.warn(
`Path configuration missing for fileType: ${resolvedFileType}. Using relative path for: ${resolvedInputFile}`,
);
}
}
return {
inputFile: resolvedInputFile,
fileType: resolvedFileType,
s3Profile: resolvedS3Profile,
requestPayer: resolvedRequestPayer,
s3Region: resolvedS3Region,
s3UrlFormat: resolvedS3UrlFormat,
sparse: resolvedSparse,
};
},
),
);
} else {
item.serve_rendered = false;
}
}
return success;
}
// Collect style loading promises separately
const stylePromises = [];
for (const id of Object.keys(config.styles || {})) {
// eslint-disable-next-line security/detect-object-injection -- id is from Object.keys of config.styles
const item = config.styles[id];
if (!item.style || item.style.length === 0) {
console.log(`Missing "style" property for ${id}`);
continue;
}
stylePromises.push(addStyle(id, item, true, true));
}
// Wait for styles to finish loading, then load data sources
// This ensures data sources added by styles are included
startupPromises.push(
Promise.all(stylePromises).then(() => {
const dataLoadPromises = [];
for (const id of Object.keys(data)) {
// eslint-disable-next-line security/detect-object-injection -- id is from Object.keys of data config
const item = data[id];
if (!item.pmtiles && !item.mbtiles) {
console.log(
`Missing "pmtiles" or "mbtiles" property for ${id} data source`,
);
continue;
}
dataLoadPromises.push(
serve_data.add(options, serving.data, item, id, opts),
);
}
return Promise.all(dataLoadPromises);
}),
);
startupPromises.push(
serve_font(options, serving.fonts, opts).then((sub) => {
app.use('/', sub);
}),
);
if (options.serveAllStyles) {
fs.readdir(options.paths.styles, { withFileTypes: true }, (err, files) => {
if (err) {
return;
}
for (const file of files) {
if (file.isFile() && path.extname(file.name).toLowerCase() == '.json') {
const id = path.basename(file.name, '.json');
const item = {
style: file.name,
};
addStyle(id, item, false, false);
}
}
});
const watcher = chokidar.watch(
path.join(options.paths.styles, '*.json'),
{},
);
watcher.on('all', (eventType, filename) => {
if (filename) {
const id = path.basename(filename, '.json');
console.log(`Style "${id}" changed, updating...`);
serve_style.remove(serving.styles, id);
if (!isLight) {
serve_rendered.remove(serving.rendered, id);
}
if (eventType == 'add' || eventType == 'change') {
const item = {
style: filename,
};
addStyle(id, item, false, false);
}
}
});
}
/**
* Handles requests for a list of available styles.
* @param {object} req - Express request object.
* @param {object} res - Express response object.
* @param {object} next - Express next middleware function.
* @param {string} [req.query.key] - Optional API key.
* @returns {void}
*/
app.get('/styles.json', (req, res, next) => {
const result = [];
const query = req.query.key
? `?key=${encodeURIComponent(req.query.key)}`
: '';
for (const id of Object.keys(serving.styles)) {
// eslint-disable-next-line security/detect-object-injection -- id is from Object.keys of serving.styles
const styleJSON = serving.styles[id].styleJSON;
result.push({
version: styleJSON.version,
name: styleJSON.name,
id,
url: `${getPublicUrl(
opts.publicUrl,
req,
)}styles/${id}/style.json${query}`,
});
}
res.send(result);
});
/**
* Adds TileJSON metadata to an array.
* @param {Array} arr - The array to add TileJSONs to
* @param {object} req - The express request object.
* @param {string} type - The type of resource
* @param {number} tileSize - The tile size.
* @returns {Array} - An array of TileJSON objects.
*/
function addTileJSONs(arr, req, type, tileSize) {
// eslint-disable-next-line security/detect-object-injection -- type is 'rendered' or 'data', validated by caller
for (const id of Object.keys(serving[type])) {
// eslint-disable-next-line security/detect-object-injection -- type is 'rendered' or 'data', id is from Object.keys
const info = clone(serving[type][id].tileJSON);
let path = '';
if (type === 'rendered') {
path = `styles/${id}`;
} else {
path = `${type}/${id}`;
}
info.tiles = getTileUrls(
req,
info.tiles,
path,
tileSize,
info.format,
opts.publicUrl,
{
pbf: options.pbfAlias,
},
);
arr.push(info);
}
return arr;
}
/**
* Handles requests for a 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.tileSize - Optional tile size parameter.
* @returns {void}
*/
app.get('{/:tileSize}/rendered.json', (req, res, next) => {
const tileSize = allowedTileSizes(req.params['tileSize']);
res.send(addTileJSONs([], req, 'rendered', parseInt(tileSize, 10)));
});
/**
* Handles requests for a data tilejson endpoint.
* @param {object} req - Express request object.
* @param {object} res - Express response object.
* @returns {void}
*/
app.get('/data.json', (req, res) => {
res.send(addTileJSONs([], req, 'data', undefined));
});
/**
* Handles requests for a combined rendered and data 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.tileSize - Optional tile size parameter.
* @returns {void}
*/
app.get('{/:tileSize}/index.json', (req, res, next) => {
const tileSize = allowedTileSizes(req.params['tileSize']);
res.send(
addTileJSONs(
addTileJSONs([], req, 'rendered', parseInt(tileSize, 10)),
req,
'data',
undefined,
),
);
});
// ------------------------------------
// serve web presentations
app.use('/', express.static(path.join(__dirname, '../public/resources')));
const templates = path.join(__dirname, '../public/templates');
/**
* Serves a Handlebars template.
* @param {string} urlPath - The URL path to serve the template at
* @param {string} template - The name of the template file
* @param {(req: object) => object|null} dataGetter - A function to get data to be passed to the template.
* @returns {void}
*/
function serveTemplate(urlPath, template, dataGetter) {
let templateFile = `${templates}/${template}.tmpl`;
if (template === 'index') {
if (options.frontPage === false) {
return;
} else if (
options.frontPage &&
options.frontPage.constructor === String
) {
templateFile = path.resolve(paths.root, options.frontPage);
}
}
try {
const content = fs.readFileSync(templateFile, 'utf-8');
const compiled = handlebars.compile(content.toString());
app.get(urlPath, (req, res, next) => {
if (opts.verbose >= 1) {
console.log(`Serving template at path: ${urlPath}`);
}
let data = {};
if (dataGetter) {
data = dataGetter(req);
if (data) {
data['server_version'] =
`${packageJson.name} v${packageJson.version}`;
data['public_url'] = opts.publicUrl || '/';
data['is_light'] = isLight;
data['key_query_part'] = req.query.key
? `key=${encodeURIComponent(req.query.key)}&`
: '';
data['key_query'] = req.query.key
? `?key=${encodeURIComponent(req.query.key)}`
: '';
if (template === 'wmts') res.set('Content-Type', 'text/xml');
return res.status(200).send(compiled(data));
} else {
if (opts.verbose >= 1) {
console.log(`Forwarding request for: ${urlPath} to next route`);
}
next('route');
}
}
});
} catch (err) {
console.error(`Error reading template file: ${templateFile}`, err);
throw new Error(`Template not found: ${err.message}`); //throw an error so that the server doesnt start
}
}
/**
* Handles requests for the index page, providing a list of available styles and data.
* @param {object} req - Express request object.
* @returns {object|null} Template data object or null
*/
serveTemplate('/', 'index', (req) => {
let styles = {};
for (const id of Object.keys(serving.styles || {})) {
let style = {
// eslint-disable-next-line security/detect-object-injection -- id is from Object.keys of serving.styles
...serving.styles[id],
// eslint-disable-next-line security/detect-object-injection -- id is from Object.keys of serving.styles
serving_data: serving.styles[id],
// eslint-disable-next-line security/detect-object-injection -- id is from Object.keys of serving.styles
serving_rendered: serving.rendered[id],
};
if (style.serving_rendered) {
const { center } = style.serving_rendered.tileJSON;
if (center) {
style.viewer_hash = `#${center[2]}/${center[1].toFixed(
5,
)}/${center[0].toFixed(5)}`;
const centerPx = mercator.px([center[0], center[1]], center[2]);
// Set thumbnail default size to be 256px x 256px
style.thumbnail = `${Math.floor(center[2])}/${Math.floor(
centerPx[0] / 256,
)}/${Math.floor(centerPx[1] / 256)}.png`;
}
const tileSize = 512;
style.xyz_link = getTileUrls(
req,
style.serving_rendered.tileJSON.tiles,
`styles/${id}`,
tileSize,
style.serving_rendered.tileJSON.format,
opts.publicUrl,
)[0];
}
// eslint-disable-next-line security/detect-object-injection -- id is from Object.keys of serving.styles
styles[id] = style;
}
let datas = {};
for (const id of Object.keys(serving.data || {})) {
// eslint-disable-next-line security/detect-object-injection -- id is from Object.keys of serving.data
let data = Object.assign({}, serving.data[id]);
// eslint-disable-next-line security/detect-object-injection -- id is from Object.keys of serving.data
const { tileJSON } = serving.data[id];
const { center } = tileJSON;
if (center) {
data.viewer_hash = `#${center[2]}/${center[1].toFixed(
5,
)}/${center[0].toFixed(5)}`;
}
const tileSize = undefined;
data.xyz_link = getTileUrls(
req,
tileJSON.tiles,
`data/${id}`,
tileSize,
tileJSON.format,
opts.publicUrl,
{
pbf: options.pbfAlias,
},
)[0];
data.is_vector = tileJSON.format === 'pbf';
if (!data.is_vector) {
if (
tileJSON.encoding === 'terrarium' ||
tileJSON.encoding === 'mapbox'
) {
if (!isLight) {
data.elevation_link = getTileUrls(
req,
tileJSON.tiles,
`data/${id}/elevation`,
)[0];
}
data.is_terrain = true;
}
if (center) {
const centerPx = mercator.px([center[0], center[1]], center[2]);
data.thumbnail = `${Math.floor(center[2])}/${Math.floor(
centerPx[0] / 256,
)}/${Math.floor(centerPx[1] / 256)}.${tileJSON.format}`;
}
}
if (data.filesize) {
let suffix = 'kB';
let size = parseInt(tileJSON.filesize, 10) / 1024;
if (size > 1024) {
suffix = 'MB';
size /= 1024;
}
if (size > 1024) {
suffix = 'GB';
size /= 1024;
}
data.formatted_filesize = `${size.toFixed(2)} ${suffix}`;
}
// eslint-disable-next-line security/detect-object-injection -- id is from Object.keys of serving.data
datas[id] = data;
}
return {
styles: Object.keys(styles).length ? styles : null,
data: Object.keys(datas).length ? datas : null,
};
});
/**
* Handles requests for a map viewer template for a specific style.
* @param {object} req - Express request object.
* @returns {object|null} Template data object or null
*/
serveTemplate('/styles/:id/', 'viewer', (req) => {
const { id } = req.params;
// eslint-disable-next-line security/detect-object-injection -- id is route parameter from URL
const style = clone(((serving.styles || {})[id] || {}).styleJSON);
if (!style) {
return null;
}
return {
...style,
id,
// eslint-disable-next-line security/detect-object-injection -- id is route parameter from URL
name: (serving.styles[id] || serving.rendered[id]).name,
// eslint-disable-next-line security/detect-object-injection -- id is route parameter from URL
serving_data: serving.styles[id],
// eslint-disable-next-line security/detect-object-injection -- id is route parameter from URL
serving_rendered: serving.rendered[id],
};
});
/**
* Handles requests for a Web Map Tile Service (WMTS) XML template.
* @param {object} req - Express request object.
* @returns {object|null} Template data object or null
*/
serveTemplate('/styles/:id/wmts.xml', 'wmts', (req) => {
const { id } = req.params;
// eslint-disable-next-line security/detect-object-injection -- id is route parameter from URL
const wmts = clone((serving.styles || {})[id]);
if (!wmts) {
return null;
}
if (Object.hasOwn(wmts, 'serve_rendered') && !wmts.serve_rendered) {
return null;
}
let baseUrl;
if (opts.publicUrl) {
baseUrl = opts.publicUrl;
} else {
baseUrl = `${
req.get('X-Forwarded-Protocol')
? req.get('X-Forwarded-Protocol')
: req.protocol
}://${req.get('host')}/`;
}
return {
...wmts,
id,
// eslint-disable-next-line security/detect-object-injection -- id is route parameter from URL
name: (serving.styles[id] || serving.rendered[id]).name,
baseUrl,
};
});
/**
* Handles requests for a data view template for a specific data source.
* @param {object} req - Express request object.
* @returns {object|null} Template data object or null
*/
serveTemplate('/data{/:view}/:id/', 'data', (req) => {
const { id, view } = req.params;
// eslint-disable-next-line security/detect-object-injection -- id is route parameter from URL
const data = serving.data[id];
if (!data) {
return null;
}
const is_terrain =
(data.tileJSON.encoding === 'terrarium' ||
data.tileJSON.encoding === 'mapbox') &&
view === 'preview';
return {
...data,
id,
use_maplibre: data.tileJSON.format === 'pbf' || is_terrain,
is_terrain: is_terrain,
is_terrainrgb: data.tileJSON.encoding === 'mapbox',
terrain_encoding: data.tileJSON.encoding,
is_light: isLight,
};
});
let startupComplete = false;
const startupPromise = Promise.all(startupPromises).then(() => {
console.log('Startup complete');
startupComplete = true;
});
/**
* Handles requests to see the health of the server.
* @param {object} req - Express request object.
* @param {object} res - Express response object.
* @returns {void}
*/
app.get('/health', (req, res) => {
if (startupComplete) {
return res.status(200).send('OK');
} else {
return res.status(503).send('Starting');
}
});
const server = app.listen(
process.env.PORT || opts.port,
process.env.BIND || opts.bind,
function () {
const addressInfo = this.address();
if (!addressInfo) {
console.error('Failed to bind to port');
return;
}
let address = addressInfo.address;
if (address.indexOf('::') === 0) {
address = `[${address}]`; // literal IPv6 address
}
console.log(`Listening at http://${address}:${addressInfo.port}/`);
},
);
// Handle server errors
server.on('error', (err) => {
const port = process.env.PORT || opts.port;
if (err.code === 'EADDRINUSE') {
console.error(`ERROR: Port ${port} is already in use.`);
console.error(`Please choose a different port with -p or --port option.`);
process.exit(1);
} else if (err.code === 'EACCES') {
console.error(`ERROR: Permission denied to bind to port ${port}.`);
console.error(
`Try using a port number above 1024 or run with appropriate permissions.`,
);
process.exit(1);
} else {
console.error('Server error:', err.message);
process.exit(1);
}
});
// add server.shutdown() to gracefully stop serving
enableShutdown(server);
return {
app,
server,
startupPromise,
serving,
};
}
/**
* Stop the server gracefully
* @param {string} signal Name of the received signal
* @returns {void}
*/
function stopGracefully(signal) {
console.log(`Caught signal ${signal}, stopping gracefully`);
process.exit();
}
/**
* Starts and manages the server
* @param {object} opts - Configuration options for the server.
* @returns {Promise<object>} - A promise that resolves to the running server
*/
export async function server(opts) {
const running = await start(opts);
running.startupPromise.catch((err) => {
console.error(err.message);
process.exit(1);
});
process.on('SIGINT', stopGracefully);
process.on('SIGTERM', stopGracefully);
process.on('SIGHUP', (signal) => {
console.log(`Caught signal ${signal}, refreshing`);
console.log('Stopping server and reloading config');
running.server.shutdown(async () => {
const restarted = await start(opts);
if (!isLight) {
serve_rendered.clear(running.serving.rendered);
}
running.server = restarted.server;
running.app = restarted.app;
running.startupPromise = restarted.startupPromise;
running.serving = restarted.serving;
});
});
return running;
}