tileserver-gl 5.6.0 → 5.7.0-pre.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/package.json +23 -20
- package/public/resources/maplibre-gl.css +1 -1
- package/public/resources/maplibre-gl.js +4 -4
- package/public/resources/maplibre-gl.js.map +1 -1
- package/src/main.js +9 -0
- package/src/mbtiles_wrapper.js +9 -0
- package/src/metrics.js +70 -0
- package/src/pmtiles_adapter.js +51 -3
- package/src/serve_data.js +47 -4
- package/src/serve_font.js +10 -0
- package/src/serve_rendered.js +150 -23
- package/src/serve_style.js +17 -0
- package/src/server.js +144 -31
- package/src/utils.js +3 -3
- package/test/fixtures/reload-config.json +34 -0
- package/test/fixtures/visual/diffs/static-bearing-pitch-diff.png +0 -0
- package/test/metrics.js +102 -0
- package/test/reload.js +98 -0
- package/test/style.js +36 -0
- package/test/utils/create_terrain_mbtiles.js +16 -11
package/src/serve_style.js
CHANGED
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
isValidHttpUrl,
|
|
15
15
|
} from './utils.js';
|
|
16
16
|
|
|
17
|
+
let metricsModule = null;
|
|
18
|
+
|
|
17
19
|
export const serve_style = {
|
|
18
20
|
/**
|
|
19
21
|
* Initializes the serve_style module.
|
|
@@ -24,6 +26,16 @@ export const serve_style = {
|
|
|
24
26
|
*/
|
|
25
27
|
init: function (options, repo, programOpts) {
|
|
26
28
|
const { verbose, allowedHosts } = programOpts;
|
|
29
|
+
// Cache metrics module if enabled. Safe because tests verify before production.
|
|
30
|
+
if (programOpts.metrics) {
|
|
31
|
+
import('./metrics.js')
|
|
32
|
+
.then((m) => {
|
|
33
|
+
metricsModule = m;
|
|
34
|
+
})
|
|
35
|
+
.catch((err) => {
|
|
36
|
+
console.error('Failed to import metrics module:', err);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
27
39
|
const app = express().disable('x-powered-by');
|
|
28
40
|
/**
|
|
29
41
|
* Handles requests for style.json files.
|
|
@@ -88,6 +100,9 @@ export const serve_style = {
|
|
|
88
100
|
allowedHosts,
|
|
89
101
|
);
|
|
90
102
|
}
|
|
103
|
+
if (metricsModule) {
|
|
104
|
+
metricsModule.tilesServedTotal.inc({ type: 'style', name: id });
|
|
105
|
+
}
|
|
91
106
|
return res.send(styleJSON_);
|
|
92
107
|
} catch (e) {
|
|
93
108
|
next(e);
|
|
@@ -246,6 +261,8 @@ export const serve_style = {
|
|
|
246
261
|
// eslint-disable-next-line security/detect-object-injection -- name is from Object.keys of styleForValidation.sources
|
|
247
262
|
styleForValidation.sources[name] &&
|
|
248
263
|
// eslint-disable-next-line security/detect-object-injection -- name is from Object.keys of styleForValidation.sources
|
|
264
|
+
typeof styleForValidation.sources[name] === 'object' &&
|
|
265
|
+
// eslint-disable-next-line security/detect-object-injection -- name is from Object.keys of styleForValidation.sources
|
|
249
266
|
'sparse' in styleForValidation.sources[name]
|
|
250
267
|
) {
|
|
251
268
|
try {
|
package/src/server.js
CHANGED
|
@@ -16,6 +16,7 @@ import morgan from 'morgan';
|
|
|
16
16
|
import { serve_data } from './serve_data.js';
|
|
17
17
|
import { serve_style } from './serve_style.js';
|
|
18
18
|
import { serve_font } from './serve_font.js';
|
|
19
|
+
import { clearPMtilesCache } from './pmtiles_adapter.js';
|
|
19
20
|
import {
|
|
20
21
|
allowedTileSizes,
|
|
21
22
|
getTileUrls,
|
|
@@ -45,6 +46,7 @@ const { serve_rendered } = await import(
|
|
|
45
46
|
* @returns {Promise<object>} - A promise that resolves to the server object.
|
|
46
47
|
*/
|
|
47
48
|
async function start(opts) {
|
|
49
|
+
let metricsModule = null;
|
|
48
50
|
console.log('Starting server');
|
|
49
51
|
|
|
50
52
|
const app = express().disable('x-powered-by');
|
|
@@ -54,9 +56,41 @@ async function start(opts) {
|
|
|
54
56
|
data: {},
|
|
55
57
|
fonts: {},
|
|
56
58
|
};
|
|
59
|
+
let cleanup = async () => {};
|
|
57
60
|
|
|
58
61
|
app.enable('trust proxy');
|
|
59
62
|
|
|
63
|
+
// Import metrics module early if enabled, so middleware doesn't miss requests
|
|
64
|
+
if (opts.metrics) {
|
|
65
|
+
try {
|
|
66
|
+
const m = await import('./metrics.js');
|
|
67
|
+
metricsModule = m;
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.warn(`[metrics] Failed to import metrics module: ${err.message}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Prometheus HTTP metrics middleware (only register if metrics enabled)
|
|
74
|
+
if (opts.metrics) {
|
|
75
|
+
app.use((req, res, next) => {
|
|
76
|
+
const start = process.hrtime.bigint();
|
|
77
|
+
res.on('finish', () => {
|
|
78
|
+
const route = req.route?.path ?? '<unknown>';
|
|
79
|
+
const durationSec = Number(process.hrtime.bigint() - start) / 1e9;
|
|
80
|
+
metricsModule.httpRequestsTotal.inc({
|
|
81
|
+
method: req.method,
|
|
82
|
+
route,
|
|
83
|
+
status_code: String(res.statusCode),
|
|
84
|
+
});
|
|
85
|
+
metricsModule.httpRequestDuration.observe(
|
|
86
|
+
{ method: req.method, route, status_code: String(res.statusCode) },
|
|
87
|
+
durationSec,
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
next();
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
60
94
|
if (process.env.NODE_ENV !== 'test') {
|
|
61
95
|
const defaultLogFormat =
|
|
62
96
|
process.env.NODE_ENV === 'production' ? 'tiny' : 'dev';
|
|
@@ -249,10 +283,12 @@ async function start(opts) {
|
|
|
249
283
|
// eslint-disable-next-line security/detect-object-injection -- dataItemId is validated above
|
|
250
284
|
const dataSource = data[dataItemId];
|
|
251
285
|
const fileType = dataSource.pmtiles ? 'pmtiles' : 'mbtiles';
|
|
286
|
+
// eslint-disable-next-line security/detect-object-injection -- fileType is either 'pmtiles' or 'mbtiles'
|
|
252
287
|
const fileName = dataSource[fileType];
|
|
253
288
|
|
|
254
289
|
// Skip validation for remote URLs
|
|
255
290
|
if (fileName && !isValidRemoteUrl(fileName)) {
|
|
291
|
+
// eslint-disable-next-line security/detect-object-injection -- fileType is either 'pmtiles' or 'mbtiles'
|
|
256
292
|
const filePath = path.resolve(options.paths[fileType], fileName);
|
|
257
293
|
try {
|
|
258
294
|
const stats = fs.statSync(filePath);
|
|
@@ -264,7 +300,7 @@ async function start(opts) {
|
|
|
264
300
|
// File missing but flag not set - let it fail later
|
|
265
301
|
return dataItemId;
|
|
266
302
|
}
|
|
267
|
-
} catch (
|
|
303
|
+
} catch (_err) {
|
|
268
304
|
// File doesn't exist
|
|
269
305
|
if (opts.ignoreMissingFiles) {
|
|
270
306
|
return undefined;
|
|
@@ -299,7 +335,7 @@ async function start(opts) {
|
|
|
299
335
|
return undefined;
|
|
300
336
|
}
|
|
301
337
|
}
|
|
302
|
-
} catch (
|
|
338
|
+
} catch (_err) {
|
|
303
339
|
// File doesn't exist
|
|
304
340
|
if (opts.ignoreMissingFiles) {
|
|
305
341
|
return undefined;
|
|
@@ -500,6 +536,10 @@ async function start(opts) {
|
|
|
500
536
|
continue;
|
|
501
537
|
}
|
|
502
538
|
stylePromises.push(addStyle(id, item, true, true));
|
|
539
|
+
// Pre-initialize error counter for this style so metric appears in output
|
|
540
|
+
if (metricsModule) {
|
|
541
|
+
metricsModule.tileErrorsTotal.inc({ type: 'rendered', name: id }, 0);
|
|
542
|
+
}
|
|
503
543
|
}
|
|
504
544
|
|
|
505
545
|
// Wait for styles to finish loading, then load data sources
|
|
@@ -551,6 +591,9 @@ async function start(opts) {
|
|
|
551
591
|
path.join(options.paths.styles, '*.json'),
|
|
552
592
|
{},
|
|
553
593
|
);
|
|
594
|
+
cleanup = async () => {
|
|
595
|
+
await watcher.close();
|
|
596
|
+
};
|
|
554
597
|
watcher.on('all', (eventType, filename) => {
|
|
555
598
|
if (filename) {
|
|
556
599
|
const id = path.basename(filename, '.json');
|
|
@@ -613,7 +656,7 @@ async function start(opts) {
|
|
|
613
656
|
for (const id of Object.keys(serving[type])) {
|
|
614
657
|
// eslint-disable-next-line security/detect-object-injection -- type is 'rendered' or 'data', id is from Object.keys
|
|
615
658
|
const info = clone(serving[type][id].tileJSON);
|
|
616
|
-
let path
|
|
659
|
+
let path;
|
|
617
660
|
if (type === 'rendered') {
|
|
618
661
|
path = `styles/${id}`;
|
|
619
662
|
} else {
|
|
@@ -711,7 +754,7 @@ async function start(opts) {
|
|
|
711
754
|
if (opts.verbose >= 1) {
|
|
712
755
|
console.log(`Serving template at path: ${urlPath}`);
|
|
713
756
|
}
|
|
714
|
-
let data
|
|
757
|
+
let data;
|
|
715
758
|
if (dataGetter) {
|
|
716
759
|
data = dataGetter(req);
|
|
717
760
|
if (data) {
|
|
@@ -738,7 +781,7 @@ async function start(opts) {
|
|
|
738
781
|
});
|
|
739
782
|
} catch (err) {
|
|
740
783
|
console.error(`Error reading template file: ${templateFile}`, err);
|
|
741
|
-
throw new Error(`Template not found: ${err.message}
|
|
784
|
+
throw new Error(`Template not found: ${err.message}`, { cause: err }); //throw an error so that the server doesnt start
|
|
742
785
|
}
|
|
743
786
|
}
|
|
744
787
|
|
|
@@ -911,26 +954,12 @@ async function start(opts) {
|
|
|
911
954
|
return null;
|
|
912
955
|
}
|
|
913
956
|
|
|
914
|
-
let baseUrl;
|
|
915
|
-
if (opts.publicUrl) {
|
|
916
|
-
baseUrl = opts.publicUrl;
|
|
917
|
-
} else {
|
|
918
|
-
const parsedAllowed = parseAllowedHosts(opts.allowedHosts);
|
|
919
|
-
const candidateHost = getCandidateHost(req);
|
|
920
|
-
if (!isHostAllowed(candidateHost, parsedAllowed)) {
|
|
921
|
-
baseUrl = '/';
|
|
922
|
-
} else {
|
|
923
|
-
const proto = getSafeProtocol(req);
|
|
924
|
-
baseUrl = `${proto}://${candidateHost}/`;
|
|
925
|
-
}
|
|
926
|
-
}
|
|
927
|
-
|
|
928
957
|
return {
|
|
929
958
|
...wmts,
|
|
930
959
|
id,
|
|
931
960
|
// eslint-disable-next-line security/detect-object-injection -- id is route parameter from URL
|
|
932
961
|
name: (serving.styles[id] || serving.rendered[id]).name,
|
|
933
|
-
baseUrl,
|
|
962
|
+
baseUrl: getPublicUrl(opts.publicUrl, req, opts.allowedHosts),
|
|
934
963
|
};
|
|
935
964
|
});
|
|
936
965
|
|
|
@@ -1024,11 +1053,40 @@ async function start(opts) {
|
|
|
1024
1053
|
// add server.shutdown() to gracefully stop serving
|
|
1025
1054
|
enableShutdown(server);
|
|
1026
1055
|
|
|
1056
|
+
// Prometheus metrics server (separate port, opt-in)
|
|
1057
|
+
let metricsServer = null;
|
|
1058
|
+
if (opts.metrics && metricsModule) {
|
|
1059
|
+
try {
|
|
1060
|
+
const metricsApp = express();
|
|
1061
|
+
metricsApp.get('/metrics', async (_req, res) => {
|
|
1062
|
+
res.set('Content-Type', metricsModule.registry.contentType);
|
|
1063
|
+
res.end(await metricsModule.registry.metrics());
|
|
1064
|
+
});
|
|
1065
|
+
await new Promise((resolve) => {
|
|
1066
|
+
metricsServer = metricsApp.listen(opts.metricsPort, '127.0.0.1');
|
|
1067
|
+
metricsServer.once('error', (err) => {
|
|
1068
|
+
console.warn(
|
|
1069
|
+
`[metrics] Failed to start metrics server: ${err.message}`,
|
|
1070
|
+
);
|
|
1071
|
+
resolve(); // don't crash — metrics are non-critical
|
|
1072
|
+
});
|
|
1073
|
+
metricsServer.once('listening', resolve);
|
|
1074
|
+
});
|
|
1075
|
+
console.log(
|
|
1076
|
+
`Prometheus metrics available at http://localhost:${opts.metricsPort}/metrics`,
|
|
1077
|
+
);
|
|
1078
|
+
} catch (err) {
|
|
1079
|
+
console.warn(`[metrics] Failed to initialize metrics: ${err.message}`);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1027
1083
|
return {
|
|
1028
1084
|
app,
|
|
1029
1085
|
server,
|
|
1030
1086
|
startupPromise,
|
|
1031
1087
|
serving,
|
|
1088
|
+
cleanup,
|
|
1089
|
+
metricsServer,
|
|
1032
1090
|
};
|
|
1033
1091
|
}
|
|
1034
1092
|
/**
|
|
@@ -1041,6 +1099,17 @@ function stopGracefully(signal) {
|
|
|
1041
1099
|
process.exit();
|
|
1042
1100
|
}
|
|
1043
1101
|
|
|
1102
|
+
/**
|
|
1103
|
+
* Registers a process signal handler once.
|
|
1104
|
+
* @param {string} signal Name of the process signal.
|
|
1105
|
+
* @returns {void}
|
|
1106
|
+
*/
|
|
1107
|
+
function registerSignalHandler(signal) {
|
|
1108
|
+
if (!process.listeners(signal).includes(stopGracefully)) {
|
|
1109
|
+
process.on(signal, stopGracefully);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1044
1113
|
/**
|
|
1045
1114
|
* Starts and manages the server
|
|
1046
1115
|
* @param {object} opts - Configuration options for the server.
|
|
@@ -1048,28 +1117,72 @@ function stopGracefully(signal) {
|
|
|
1048
1117
|
*/
|
|
1049
1118
|
export async function server(opts) {
|
|
1050
1119
|
const running = await start(opts);
|
|
1120
|
+
let reloading = false;
|
|
1121
|
+
let pendingReload = false;
|
|
1051
1122
|
|
|
1052
1123
|
running.startupPromise.catch((err) => {
|
|
1053
1124
|
console.error(err.message);
|
|
1054
1125
|
process.exit(1);
|
|
1055
1126
|
});
|
|
1056
1127
|
|
|
1057
|
-
|
|
1058
|
-
|
|
1128
|
+
registerSignalHandler('SIGINT');
|
|
1129
|
+
registerSignalHandler('SIGTERM');
|
|
1130
|
+
|
|
1131
|
+
const reload = async () => {
|
|
1132
|
+
if (reloading) {
|
|
1133
|
+
pendingReload = true;
|
|
1134
|
+
console.log('Reload already in progress, queueing another refresh');
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
reloading = true;
|
|
1139
|
+
let reloadAgain = true;
|
|
1140
|
+
|
|
1141
|
+
try {
|
|
1142
|
+
while (reloadAgain) {
|
|
1143
|
+
reloadAgain = false;
|
|
1144
|
+
pendingReload = false;
|
|
1145
|
+
await new Promise((resolve, reject) => {
|
|
1146
|
+
running.server.shutdown((err) => {
|
|
1147
|
+
if (err) {
|
|
1148
|
+
reject(err);
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
resolve();
|
|
1152
|
+
});
|
|
1153
|
+
});
|
|
1154
|
+
if (running.metricsServer) {
|
|
1155
|
+
await new Promise((resolve) => running.metricsServer.close(resolve));
|
|
1156
|
+
}
|
|
1157
|
+
await running.cleanup();
|
|
1158
|
+
await serve_data.clear(running.serving.data);
|
|
1159
|
+
if (!isLight) {
|
|
1160
|
+
await serve_rendered.clear(running.serving.rendered);
|
|
1161
|
+
}
|
|
1162
|
+
clearPMtilesCache();
|
|
1163
|
+
|
|
1164
|
+
const restarted = await start(opts);
|
|
1165
|
+
running.server = restarted.server;
|
|
1166
|
+
running.app = restarted.app;
|
|
1167
|
+
running.startupPromise = restarted.startupPromise;
|
|
1168
|
+
running.serving = restarted.serving;
|
|
1169
|
+
running.cleanup = restarted.cleanup;
|
|
1170
|
+
running.metricsServer = restarted.metricsServer;
|
|
1171
|
+
await running.startupPromise;
|
|
1172
|
+
reloadAgain = pendingReload;
|
|
1173
|
+
}
|
|
1174
|
+
} finally {
|
|
1175
|
+
reloading = false;
|
|
1176
|
+
}
|
|
1177
|
+
};
|
|
1059
1178
|
|
|
1060
1179
|
process.on('SIGHUP', (signal) => {
|
|
1061
1180
|
console.log(`Caught signal ${signal}, refreshing`);
|
|
1062
1181
|
console.log('Stopping server and reloading config');
|
|
1063
1182
|
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
serve_rendered.clear(running.serving.rendered);
|
|
1068
|
-
}
|
|
1069
|
-
running.server = restarted.server;
|
|
1070
|
-
running.app = restarted.app;
|
|
1071
|
-
running.startupPromise = restarted.startupPromise;
|
|
1072
|
-
running.serving = restarted.serving;
|
|
1183
|
+
reload().catch((err) => {
|
|
1184
|
+
console.error(err.message);
|
|
1185
|
+
process.exit(1);
|
|
1073
1186
|
});
|
|
1074
1187
|
});
|
|
1075
1188
|
return running;
|
package/src/utils.js
CHANGED
|
@@ -138,7 +138,7 @@ export function parseAllowedHosts(allowedHosts) {
|
|
|
138
138
|
* Returns true if host is allowed (allowlist is "*" or host is in the list). Port stripped, comparison case-insensitive.
|
|
139
139
|
* @param {string} host - Host to check (e.g. from request or X-Forwarded-Host).
|
|
140
140
|
* @param {string|string[]} allowedHosts - Result of parseAllowedHosts().
|
|
141
|
-
* @returns {boolean}
|
|
141
|
+
* @returns {boolean} - True if the host is allowed, false otherwise.
|
|
142
142
|
*/
|
|
143
143
|
export function isHostAllowed(host, allowedHosts) {
|
|
144
144
|
if (!host || typeof host !== 'string') {
|
|
@@ -161,7 +161,7 @@ const BAD_HOST_RE = /[\s/]/;
|
|
|
161
161
|
* Candidate host from request: X-Forwarded-Host or Host header.
|
|
162
162
|
* Returns undefined if the value looks malformed (contains / or whitespace), so it is treated as not allowed.
|
|
163
163
|
* @param {object} req - Express request.
|
|
164
|
-
* @returns {string|undefined}
|
|
164
|
+
* @returns {string|undefined} - The candidate host string, or undefined if the value is malformed.
|
|
165
165
|
*/
|
|
166
166
|
export function getCandidateHost(req) {
|
|
167
167
|
const check = (raw) => {
|
|
@@ -475,7 +475,7 @@ async function getFontPbf(allowedFonts, fontPath, name, range, fallbacks) {
|
|
|
475
475
|
delete fallbacks[fallbackName];
|
|
476
476
|
return getFontPbf(null, fontPath, fallbackName, range, fallbacks);
|
|
477
477
|
} else {
|
|
478
|
-
throw new Error('Font load error');
|
|
478
|
+
throw new Error('Font load error', { cause: err });
|
|
479
479
|
}
|
|
480
480
|
}
|
|
481
481
|
} else {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"options": {
|
|
3
|
+
"serveAllStyles": true,
|
|
4
|
+
"paths": {
|
|
5
|
+
"root": "../../test_data",
|
|
6
|
+
"fonts": "fonts",
|
|
7
|
+
"styles": "styles"
|
|
8
|
+
}
|
|
9
|
+
},
|
|
10
|
+
"styles": {
|
|
11
|
+
"test-style": {
|
|
12
|
+
"style": "osm-bright/style.json",
|
|
13
|
+
"tilejson": {
|
|
14
|
+
"type": "overlay",
|
|
15
|
+
"bounds": [8.529446, 47.364758, 8.55232, 47.380539]
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"maptiler-basic": {
|
|
19
|
+
"style": "maptiler-basic/style.json",
|
|
20
|
+
"tilejson": {
|
|
21
|
+
"type": "overlay",
|
|
22
|
+
"bounds": [8.529446, 47.364758, 8.55232, 47.380539]
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"data": {
|
|
27
|
+
"openmaptiles": {
|
|
28
|
+
"mbtiles": "zurich_switzerland.mbtiles"
|
|
29
|
+
},
|
|
30
|
+
"terrain": {
|
|
31
|
+
"mbtiles": "terrain.mbtiles"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
Binary file
|
package/test/metrics.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { expect } from 'chai';
|
|
4
|
+
import { server } from '../src/server.js';
|
|
5
|
+
import http from 'node:http';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Helper: GET a URL, returns { statusCode, headers, body }
|
|
9
|
+
* @param {string} url URL to fetch
|
|
10
|
+
* @returns {Promise<{statusCode: number, headers: object, body: string}>} Response object
|
|
11
|
+
*/
|
|
12
|
+
function httpGet(url) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
http
|
|
15
|
+
.get(url, (res) => {
|
|
16
|
+
let body = '';
|
|
17
|
+
res.on('data', (chunk) => (body += chunk));
|
|
18
|
+
res.on('end', () =>
|
|
19
|
+
resolve({ statusCode: res.statusCode, headers: res.headers, body }),
|
|
20
|
+
);
|
|
21
|
+
})
|
|
22
|
+
.on('error', reject);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('Prometheus metrics', function () {
|
|
27
|
+
this.timeout(15000);
|
|
28
|
+
|
|
29
|
+
let metricsServer;
|
|
30
|
+
let tileServer;
|
|
31
|
+
|
|
32
|
+
before(async function () {
|
|
33
|
+
const running = await server({
|
|
34
|
+
configPath: 'config.json',
|
|
35
|
+
port: 8889,
|
|
36
|
+
publicUrl: '/test/',
|
|
37
|
+
metrics: true,
|
|
38
|
+
metricsPort: 9999,
|
|
39
|
+
});
|
|
40
|
+
tileServer = running.server;
|
|
41
|
+
metricsServer = running.metricsServer;
|
|
42
|
+
// give metrics server a moment if needed
|
|
43
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
after(function () {
|
|
47
|
+
tileServer?.close();
|
|
48
|
+
metricsServer?.close();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('GET /metrics returns 200 with text/plain content type', async function () {
|
|
52
|
+
const { statusCode, headers } = await httpGet(
|
|
53
|
+
'http://localhost:9999/metrics',
|
|
54
|
+
);
|
|
55
|
+
expect(statusCode).to.equal(200);
|
|
56
|
+
expect(headers['content-type']).to.include('text/plain');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('GET /metrics body includes expected metric names', async function () {
|
|
60
|
+
const { body } = await httpGet('http://localhost:9999/metrics');
|
|
61
|
+
expect(body).to.include('tileserver_http_requests_total');
|
|
62
|
+
expect(body).to.include('tileserver_tiles_served_total');
|
|
63
|
+
expect(body).to.include('tileserver_render_pool_size');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('making a request increments tileserver_http_requests_total', async function () {
|
|
67
|
+
await httpGet('http://localhost:8889/test/health');
|
|
68
|
+
const { body } = await httpGet('http://localhost:9999/metrics');
|
|
69
|
+
const match = body.match(/tileserver_http_requests_total\{[^}]+\}\s+(\d+)/);
|
|
70
|
+
expect(match).to.not.equal(null);
|
|
71
|
+
expect(parseInt(match[1], 10)).to.be.greaterThan(0);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('metrics server does NOT start when metrics: false', function (done) {
|
|
75
|
+
server({
|
|
76
|
+
configPath: 'config.json',
|
|
77
|
+
port: 8890,
|
|
78
|
+
publicUrl: '/test/',
|
|
79
|
+
metrics: false,
|
|
80
|
+
metricsPort: 9998,
|
|
81
|
+
})
|
|
82
|
+
.then((running) => {
|
|
83
|
+
running.startupPromise
|
|
84
|
+
.then(() => {
|
|
85
|
+
setTimeout(() => {
|
|
86
|
+
http
|
|
87
|
+
.get('http://localhost:9998/metrics', () => {
|
|
88
|
+
running.server.close();
|
|
89
|
+
done(new Error('Should not have connected'));
|
|
90
|
+
})
|
|
91
|
+
.on('error', (err) => {
|
|
92
|
+
running.server.close();
|
|
93
|
+
expect(err.code).to.equal('ECONNREFUSED');
|
|
94
|
+
done();
|
|
95
|
+
});
|
|
96
|
+
}, 200);
|
|
97
|
+
})
|
|
98
|
+
.catch(done);
|
|
99
|
+
})
|
|
100
|
+
.catch(done);
|
|
101
|
+
});
|
|
102
|
+
});
|
package/test/reload.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { server } from '../src/server.js';
|
|
4
|
+
|
|
5
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Returns listeners added after a previous listener count.
|
|
9
|
+
* @param {string} eventName Process event name.
|
|
10
|
+
* @param {number} previousListenerCount Previously registered listener count.
|
|
11
|
+
* @returns {Array<(...args: unknown[]) => void>} Newly registered listeners.
|
|
12
|
+
*/
|
|
13
|
+
function getAddedListeners(eventName, previousListenerCount) {
|
|
14
|
+
return process.listeners(eventName).slice(previousListenerCount);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Waits until a condition becomes true.
|
|
19
|
+
* @param {() => boolean} condition Predicate to check.
|
|
20
|
+
* @param {number} [timeoutMs] Maximum wait time.
|
|
21
|
+
* @returns {Promise<void>}
|
|
22
|
+
*/
|
|
23
|
+
async function waitFor(condition, timeoutMs = 5000) {
|
|
24
|
+
const startedAt = Date.now();
|
|
25
|
+
|
|
26
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
27
|
+
if (condition()) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
throw new Error('Timed out waiting for condition');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('SIGHUP reload', function () {
|
|
38
|
+
it('runs runtime cleanup before replacing the server generation', async function () {
|
|
39
|
+
const previousListeners = [
|
|
40
|
+
{
|
|
41
|
+
eventName: 'SIGHUP',
|
|
42
|
+
listenerCount: process.listeners('SIGHUP').length,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
eventName: 'SIGINT',
|
|
46
|
+
listenerCount: process.listeners('SIGINT').length,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
eventName: 'SIGTERM',
|
|
50
|
+
listenerCount: process.listeners('SIGTERM').length,
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
let running;
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
running = await server({
|
|
57
|
+
configPath: path.join(__dirname, 'fixtures/reload-config.json'),
|
|
58
|
+
port: 0,
|
|
59
|
+
publicUrl: '/test/',
|
|
60
|
+
});
|
|
61
|
+
await running.startupPromise;
|
|
62
|
+
|
|
63
|
+
const reloadListeners = getAddedListeners(
|
|
64
|
+
'SIGHUP',
|
|
65
|
+
previousListeners[0].listenerCount,
|
|
66
|
+
);
|
|
67
|
+
expect(reloadListeners).to.have.lengthOf(1);
|
|
68
|
+
|
|
69
|
+
let cleanupCalls = 0;
|
|
70
|
+
const cleanup = running.cleanup;
|
|
71
|
+
running.cleanup = async () => {
|
|
72
|
+
cleanupCalls += 1;
|
|
73
|
+
await cleanup();
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const startupPromise = running.startupPromise;
|
|
77
|
+
reloadListeners[0]('SIGHUP');
|
|
78
|
+
|
|
79
|
+
await waitFor(() => running.startupPromise !== startupPromise);
|
|
80
|
+
await running.startupPromise;
|
|
81
|
+
|
|
82
|
+
expect(cleanupCalls).to.equal(1);
|
|
83
|
+
} finally {
|
|
84
|
+
if (running) {
|
|
85
|
+
await running.cleanup();
|
|
86
|
+
if (running.server.listening) {
|
|
87
|
+
await new Promise((resolve) => running.server.close(resolve));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const { eventName, listenerCount } of previousListeners) {
|
|
92
|
+
for (const listener of getAddedListeners(eventName, listenerCount)) {
|
|
93
|
+
process.removeListener(eventName, listener);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
});
|
package/test/style.js
CHANGED
|
@@ -53,6 +53,42 @@ describe('Styles', function () {
|
|
|
53
53
|
});
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
+
describe('WMTS', function () {
|
|
57
|
+
describe('/styles/' + prefix + '/wmts.xml', function () {
|
|
58
|
+
testIs('/styles/' + prefix + '/wmts.xml', /text\/xml/);
|
|
59
|
+
|
|
60
|
+
it('contains a valid absolute baseUrl using public_url', function (done) {
|
|
61
|
+
supertest(app)
|
|
62
|
+
.get('/styles/' + prefix + '/wmts.xml')
|
|
63
|
+
.expect(function (res) {
|
|
64
|
+
// The server is started with publicUrl: '/test/' in setup.js.
|
|
65
|
+
// getPublicUrl resolves a relative publicUrl against the request host,
|
|
66
|
+
// so all URLs in the document must be absolute (not just '/test/').
|
|
67
|
+
expect(res.text).to.not.include('href="/test/');
|
|
68
|
+
expect(res.text).to.include('href="http://');
|
|
69
|
+
expect(res.text).to.include('/test/styles/' + prefix + '/wmts.xml');
|
|
70
|
+
})
|
|
71
|
+
.end(done);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('contains valid absolute tile resource URLs using public_url', function (done) {
|
|
75
|
+
supertest(app)
|
|
76
|
+
.get('/styles/' + prefix + '/wmts.xml')
|
|
77
|
+
.expect(function (res) {
|
|
78
|
+
expect(res.text).to.not.include('template="/test/');
|
|
79
|
+
expect(res.text).to.include('template="http://');
|
|
80
|
+
expect(res.text).to.include('/test/styles/' + prefix + '/256/');
|
|
81
|
+
expect(res.text).to.include('/test/styles/' + prefix + '/512/');
|
|
82
|
+
})
|
|
83
|
+
.end(done);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('/styles/non_existent/wmts.xml returns 404', function () {
|
|
88
|
+
testIs('/styles/non_existent/wmts.xml', /./, 404);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
56
92
|
describe('Fonts', function () {
|
|
57
93
|
testIs('/fonts/Open Sans Bold/0-255.pbf', /application\/x-protobuf/);
|
|
58
94
|
testIs('/fonts/Open Sans Regular/65280-65535.pbf', /application\/x-protobuf/);
|