tileserver-gl-light 5.6.1-pre.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 +6 -1
- package/package.json +6 -5
- package/src/main.js +9 -0
- package/src/mbtiles_wrapper.js +9 -0
- package/src/metrics.js +70 -0
- package/src/pmtiles_adapter.js +44 -0
- package/src/serve_data.js +45 -2
- package/src/serve_font.js +10 -0
- package/src/serve_rendered.js +148 -21
- package/src/serve_style.js +15 -0
- package/src/server.js +137 -26
- 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/CHANGELOG.md
CHANGED
|
@@ -7,9 +7,14 @@
|
|
|
7
7
|
### 🐞 Bug fixes
|
|
8
8
|
- _...Add new stuff here..._
|
|
9
9
|
|
|
10
|
-
## 5.
|
|
10
|
+
## 5.7.0-pre.0
|
|
11
|
+
### ✨ Features and improvements
|
|
12
|
+
- feat: add opt-in Prometheus metrics endpoint ([#2211](https://github.com/maptiler/tileserver-gl/pull/2211)) (by [navidnabavi](https://github.com/navidnabavi))
|
|
13
|
+
|
|
11
14
|
### 🐞 Bug fixes
|
|
12
15
|
- fix: TypeError when style source value is a string (e.g. sprite path) ([#2179](https://github.com/maptiler/tileserver-gl/pull/2179)) (by [app/copilot-swe-agent](https://github.com/app/copilot-swe-agent))
|
|
16
|
+
- fix: clean stale tile-source state on SIGHUP reload ([#2158](https://github.com/maptiler/tileserver-gl/pull/2158)) (by [bvitlas](https://github.com/bvitlas))
|
|
17
|
+
- fix: correctly handle public_url in wmts endpoint ([#2205](https://github.com/maptiler/tileserver-gl/pull/2205)) (by [andrewlaguna824](https://github.com/andrewlaguna824))
|
|
13
18
|
|
|
14
19
|
## 5.6.0
|
|
15
20
|
### ✨ Features and improvements
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tileserver-gl-light",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.7.0-pre.0",
|
|
4
4
|
"description": "Map tile server for JSON GL styles - serving vector tiles",
|
|
5
5
|
"main": "src/main.js",
|
|
6
6
|
"bin": {
|
|
@@ -37,13 +37,13 @@
|
|
|
37
37
|
"docker": "docker build . && docker run --rm -i -p 8080:8080 $(docker build -q .)"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@aws-sdk/client-s3": "^3.
|
|
40
|
+
"@aws-sdk/client-s3": "^3.1055.0",
|
|
41
41
|
"@jsse/pbfont": "^0.3.3",
|
|
42
42
|
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
|
|
43
43
|
"@mapbox/mbtiles": "0.12.1",
|
|
44
44
|
"@mapbox/polyline": "^1.2.1",
|
|
45
45
|
"@mapbox/sphericalmercator": "2.0.2",
|
|
46
|
-
"@mapbox/vector-tile": "
|
|
46
|
+
"@mapbox/vector-tile": "3.0.0",
|
|
47
47
|
"@maplibre/maplibre-gl-inspect": "1.8.2",
|
|
48
48
|
"@maplibre/maplibre-gl-style-spec": "24.8.5",
|
|
49
49
|
"@sindresorhus/fnv1a": "3.1.0",
|
|
@@ -61,12 +61,13 @@
|
|
|
61
61
|
"leaflet-hash": "0.2.1",
|
|
62
62
|
"maplibre-gl": "5.24.0",
|
|
63
63
|
"morgan": "1.10.1",
|
|
64
|
-
"pbf": "
|
|
64
|
+
"pbf": "5.0.0",
|
|
65
65
|
"pmtiles": "4.4.1",
|
|
66
66
|
"proj4": "2.20.8",
|
|
67
|
+
"prom-client": "^15.1.3",
|
|
67
68
|
"sanitize-filename": "1.6.4",
|
|
68
69
|
"secure-json-parse": "^4.1.0",
|
|
69
|
-
"semver": "^7.8.
|
|
70
|
+
"semver": "^7.8.1",
|
|
70
71
|
"tileserver-gl-styles": "2.0.0"
|
|
71
72
|
},
|
|
72
73
|
"keywords": [
|
package/src/main.js
CHANGED
|
@@ -113,6 +113,10 @@ program
|
|
|
113
113
|
'--ignore-missing-files',
|
|
114
114
|
'Continue startup even if configured mbtiles/pmtiles files are missing',
|
|
115
115
|
)
|
|
116
|
+
.option(
|
|
117
|
+
'--metrics',
|
|
118
|
+
'Enable Prometheus metrics endpoint (env: TILESERVER_GL_METRICS)',
|
|
119
|
+
)
|
|
116
120
|
.version(packageJson.version, '-v, --version');
|
|
117
121
|
program.parse(process.argv);
|
|
118
122
|
const opts = program.opts();
|
|
@@ -166,6 +170,11 @@ const startServer = (configPath, config) => {
|
|
|
166
170
|
publicUrl,
|
|
167
171
|
allowedHosts,
|
|
168
172
|
ignoreMissingFiles: opts.ignoreMissingFiles,
|
|
173
|
+
metrics: opts.metrics || process.env.TILESERVER_GL_METRICS === 'true',
|
|
174
|
+
metricsPort: (() => {
|
|
175
|
+
const port = parseInt(process.env.METRICS_PORT, 10);
|
|
176
|
+
return !isNaN(port) && port > 0 ? port : 9090;
|
|
177
|
+
})(),
|
|
169
178
|
});
|
|
170
179
|
};
|
|
171
180
|
|
package/src/mbtiles_wrapper.js
CHANGED
|
@@ -8,6 +8,7 @@ class MBTilesWrapper {
|
|
|
8
8
|
constructor(mbtiles) {
|
|
9
9
|
this._mbtiles = mbtiles;
|
|
10
10
|
this._getInfoP = util.promisify(mbtiles.getInfo.bind(mbtiles));
|
|
11
|
+
this._closeP = util.promisify(mbtiles.close.bind(mbtiles));
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -25,6 +26,14 @@ class MBTilesWrapper {
|
|
|
25
26
|
getInfo() {
|
|
26
27
|
return this._getInfoP();
|
|
27
28
|
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Closes the underlying MBTiles database handle.
|
|
32
|
+
* @returns {Promise<void>} A promise that resolves when the database is closed.
|
|
33
|
+
*/
|
|
34
|
+
close() {
|
|
35
|
+
return this._closeP();
|
|
36
|
+
}
|
|
28
37
|
}
|
|
29
38
|
|
|
30
39
|
/**
|
package/src/metrics.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// src/metrics.js
|
|
2
|
+
import {
|
|
3
|
+
Registry,
|
|
4
|
+
Counter,
|
|
5
|
+
Histogram,
|
|
6
|
+
Gauge,
|
|
7
|
+
collectDefaultMetrics,
|
|
8
|
+
} from 'prom-client';
|
|
9
|
+
|
|
10
|
+
export const registry = new Registry();
|
|
11
|
+
|
|
12
|
+
collectDefaultMetrics({ register: registry });
|
|
13
|
+
|
|
14
|
+
export const httpRequestsTotal = new Counter({
|
|
15
|
+
name: 'tileserver_http_requests_total',
|
|
16
|
+
help: 'Total number of HTTP requests',
|
|
17
|
+
labelNames: ['method', 'route', 'status_code'],
|
|
18
|
+
registers: [registry],
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const httpRequestDuration = new Histogram({
|
|
22
|
+
name: 'tileserver_http_request_duration_seconds',
|
|
23
|
+
help: 'HTTP request duration in seconds',
|
|
24
|
+
labelNames: ['method', 'route', 'status_code'],
|
|
25
|
+
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
|
|
26
|
+
registers: [registry],
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const tilesServedTotal = new Counter({
|
|
30
|
+
name: 'tileserver_tiles_served_total',
|
|
31
|
+
help: 'Total number of tiles served',
|
|
32
|
+
labelNames: ['type', 'name'],
|
|
33
|
+
registers: [registry],
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export const tileRenderDuration = new Histogram({
|
|
37
|
+
name: 'tileserver_tile_render_duration_seconds',
|
|
38
|
+
help: 'Tile pipeline duration in seconds (includes pool wait + render + encode)',
|
|
39
|
+
labelNames: ['name', 'zoom'],
|
|
40
|
+
buckets: [0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
|
|
41
|
+
registers: [registry],
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export const tileErrorsTotal = new Counter({
|
|
45
|
+
name: 'tileserver_tile_errors_total',
|
|
46
|
+
help: 'Total number of tile errors',
|
|
47
|
+
labelNames: ['type', 'name'],
|
|
48
|
+
registers: [registry],
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export const renderPoolSize = new Gauge({
|
|
52
|
+
name: 'tileserver_render_pool_size',
|
|
53
|
+
help: 'Total objects in render pool',
|
|
54
|
+
labelNames: ['name'],
|
|
55
|
+
registers: [registry],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export const renderPoolActive = new Gauge({
|
|
59
|
+
name: 'tileserver_render_pool_active',
|
|
60
|
+
help: 'Active (borrowed) objects in render pool',
|
|
61
|
+
labelNames: ['name'],
|
|
62
|
+
registers: [registry],
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export const renderPoolWaiting = new Gauge({
|
|
66
|
+
name: 'tileserver_render_pool_waiting',
|
|
67
|
+
help: 'Clients waiting for a render pool object',
|
|
68
|
+
labelNames: ['name'],
|
|
69
|
+
registers: [registry],
|
|
70
|
+
});
|
package/src/pmtiles_adapter.js
CHANGED
|
@@ -292,6 +292,23 @@ class PMTilesFileSource {
|
|
|
292
292
|
);
|
|
293
293
|
return { data: ab };
|
|
294
294
|
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Closes the underlying file descriptor for local PMTiles sources.
|
|
298
|
+
* @returns {void}
|
|
299
|
+
*/
|
|
300
|
+
close() {
|
|
301
|
+
if (typeof this.fd === 'number') {
|
|
302
|
+
const fd = this.fd;
|
|
303
|
+
try {
|
|
304
|
+
fs.closeSync(fd);
|
|
305
|
+
} catch (err) {
|
|
306
|
+
console.warn(`Failed to close PMTiles file descriptor ${fd}:`, err);
|
|
307
|
+
} finally {
|
|
308
|
+
this.fd = null;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
295
312
|
}
|
|
296
313
|
|
|
297
314
|
/**
|
|
@@ -315,6 +332,22 @@ async function readFileBytes(fd, buffer, offset) {
|
|
|
315
332
|
// Cache for PMTiles objects to avoid creating multiple instances for the same URL
|
|
316
333
|
const pmtilesCache = new Map();
|
|
317
334
|
|
|
335
|
+
/**
|
|
336
|
+
* Closes a PMTiles instance if it owns a closeable local file source.
|
|
337
|
+
* @param {PMTiles} pmtiles - The PMTiles instance to close.
|
|
338
|
+
* @returns {void}
|
|
339
|
+
*/
|
|
340
|
+
function closePMTiles(pmtiles) {
|
|
341
|
+
if (!pmtiles) {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const source = pmtiles.source;
|
|
346
|
+
if (source && typeof source.close === 'function') {
|
|
347
|
+
source.close();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
318
351
|
/**
|
|
319
352
|
* Opens a PMTiles file from local filesystem, HTTP URL, or S3 URL.
|
|
320
353
|
* Uses caching to avoid creating multiple PMTiles instances for the same file.
|
|
@@ -388,6 +421,17 @@ export function openPMtiles(
|
|
|
388
421
|
return pmtiles;
|
|
389
422
|
}
|
|
390
423
|
|
|
424
|
+
/**
|
|
425
|
+
* Clears the PMTiles cache and closes any local file descriptors owned by cached sources.
|
|
426
|
+
* @returns {void}
|
|
427
|
+
*/
|
|
428
|
+
export function clearPMtilesCache() {
|
|
429
|
+
for (const pmtiles of pmtilesCache.values()) {
|
|
430
|
+
closePMTiles(pmtiles);
|
|
431
|
+
}
|
|
432
|
+
pmtilesCache.clear();
|
|
433
|
+
}
|
|
434
|
+
|
|
391
435
|
/**
|
|
392
436
|
* Retrieves metadata and header information from a PMTiles archive with retry logic for rate limiting.
|
|
393
437
|
* @param {PMTiles} pmtiles - The PMTiles instance.
|
package/src/serve_data.js
CHANGED
|
@@ -5,7 +5,7 @@ import path from 'path';
|
|
|
5
5
|
|
|
6
6
|
import clone from 'clone';
|
|
7
7
|
import express from 'express';
|
|
8
|
-
import
|
|
8
|
+
import { PbfReader } from 'pbf';
|
|
9
9
|
import { VectorTile } from '@mapbox/vector-tile';
|
|
10
10
|
import { SphericalMercator } from '@mapbox/sphericalmercator';
|
|
11
11
|
|
|
@@ -21,6 +21,8 @@ import { gunzipP, gzipP } from './promises.js';
|
|
|
21
21
|
import { openMbTilesWrapper } from './mbtiles_wrapper.js';
|
|
22
22
|
|
|
23
23
|
import fs from 'node:fs';
|
|
24
|
+
|
|
25
|
+
let metricsModule = null;
|
|
24
26
|
import { fileURLToPath } from 'url';
|
|
25
27
|
|
|
26
28
|
const packageJson = JSON.parse(
|
|
@@ -45,6 +47,16 @@ export const serve_data = {
|
|
|
45
47
|
*/
|
|
46
48
|
init: function (options, repo, programOpts) {
|
|
47
49
|
const { verbose, allowedHosts } = programOpts;
|
|
50
|
+
// Cache metrics module if enabled. Safe because tests verify before production.
|
|
51
|
+
if (programOpts.metrics) {
|
|
52
|
+
import('./metrics.js')
|
|
53
|
+
.then((m) => {
|
|
54
|
+
metricsModule = m;
|
|
55
|
+
})
|
|
56
|
+
.catch((err) => {
|
|
57
|
+
console.error('Failed to import metrics module:', err);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
48
60
|
const app = express().disable('x-powered-by');
|
|
49
61
|
app.use(express.json());
|
|
50
62
|
|
|
@@ -142,7 +154,7 @@ export const serve_data = {
|
|
|
142
154
|
headers['Content-Type'] = 'application/x-protobuf';
|
|
143
155
|
} else if (format === 'geojson') {
|
|
144
156
|
headers['Content-Type'] = 'application/json';
|
|
145
|
-
const tile = new VectorTile(new
|
|
157
|
+
const tile = new VectorTile(new PbfReader(data));
|
|
146
158
|
const geojson = {
|
|
147
159
|
type: 'FeatureCollection',
|
|
148
160
|
features: [],
|
|
@@ -167,6 +179,12 @@ export const serve_data = {
|
|
|
167
179
|
|
|
168
180
|
data = await gzipP(data);
|
|
169
181
|
|
|
182
|
+
if (metricsModule) {
|
|
183
|
+
metricsModule.tilesServedTotal.inc({
|
|
184
|
+
type: 'vector',
|
|
185
|
+
name: req.params.id,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
170
188
|
return res.status(200).send(data);
|
|
171
189
|
});
|
|
172
190
|
|
|
@@ -624,4 +642,29 @@ export const serve_data = {
|
|
|
624
642
|
sparse,
|
|
625
643
|
};
|
|
626
644
|
},
|
|
645
|
+
/**
|
|
646
|
+
* Removes all items from the repository and closes owned local data sources.
|
|
647
|
+
* @param {object} repo Repository object.
|
|
648
|
+
* @returns {Promise<void>}
|
|
649
|
+
*/
|
|
650
|
+
clear: async function (repo) {
|
|
651
|
+
await Promise.all(
|
|
652
|
+
Object.keys(repo).map(async (id) => {
|
|
653
|
+
// eslint-disable-next-line security/detect-object-injection -- id is from Object.keys() iteration
|
|
654
|
+
const item = repo[id];
|
|
655
|
+
try {
|
|
656
|
+
if (item && item.sourceType === 'mbtiles' && item.source) {
|
|
657
|
+
await new Promise((resolve, reject) => {
|
|
658
|
+
item.source.close((err) => (err ? reject(err) : resolve()));
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
} catch (err) {
|
|
662
|
+
console.warn(`Failed to close data source "${id}":`, err);
|
|
663
|
+
} finally {
|
|
664
|
+
// eslint-disable-next-line security/detect-object-injection -- id is from Object.keys() iteration
|
|
665
|
+
delete repo[id];
|
|
666
|
+
}
|
|
667
|
+
}),
|
|
668
|
+
);
|
|
669
|
+
},
|
|
627
670
|
};
|
package/src/serve_font.js
CHANGED
|
@@ -4,6 +4,8 @@ import express from 'express';
|
|
|
4
4
|
|
|
5
5
|
import { getFontsPbf, listFonts } from './utils.js';
|
|
6
6
|
|
|
7
|
+
let metricsModule = null;
|
|
8
|
+
|
|
7
9
|
/**
|
|
8
10
|
* Initializes and returns an Express app that serves font files.
|
|
9
11
|
* @param {object} options - Configuration options for the server.
|
|
@@ -13,6 +15,11 @@ import { getFontsPbf, listFonts } from './utils.js';
|
|
|
13
15
|
*/
|
|
14
16
|
export async function serve_font(options, allowedFonts, programOpts) {
|
|
15
17
|
const { verbose } = programOpts;
|
|
18
|
+
// Cache metrics module if enabled. Safe because tests verify before production.
|
|
19
|
+
if (programOpts.metrics) {
|
|
20
|
+
const m = await import('./metrics.js');
|
|
21
|
+
metricsModule = m;
|
|
22
|
+
}
|
|
16
23
|
const app = express().disable('x-powered-by');
|
|
17
24
|
|
|
18
25
|
const lastModified = new Date().toUTCString();
|
|
@@ -64,6 +71,9 @@ export async function serve_font(options, allowedFonts, programOpts) {
|
|
|
64
71
|
);
|
|
65
72
|
res.header('Content-type', 'application/x-protobuf');
|
|
66
73
|
res.header('Last-Modified', lastModified);
|
|
74
|
+
if (metricsModule) {
|
|
75
|
+
metricsModule.tilesServedTotal.inc({ type: 'font', name: sFontStack });
|
|
76
|
+
}
|
|
67
77
|
return res.send(concatenated);
|
|
68
78
|
} catch (err) {
|
|
69
79
|
console.error(
|
package/src/serve_rendered.js
CHANGED
|
@@ -539,6 +539,7 @@ function calcZForBBox(bbox, w, h, query) {
|
|
|
539
539
|
* @param {object} res Express response object.
|
|
540
540
|
* @param {Buffer|null} overlay Optional overlay image.
|
|
541
541
|
* @param {string} mode Rendering mode ('tile' or 'static').
|
|
542
|
+
* @param {string|null} id Style or dataset ID for metrics labeling.
|
|
542
543
|
* @returns {Promise<void>}
|
|
543
544
|
*/
|
|
544
545
|
async function respondImage(
|
|
@@ -556,6 +557,7 @@ async function respondImage(
|
|
|
556
557
|
res,
|
|
557
558
|
overlay = null,
|
|
558
559
|
mode = 'tile',
|
|
560
|
+
id = null,
|
|
559
561
|
) {
|
|
560
562
|
if (
|
|
561
563
|
Math.abs(lon) > 180 ||
|
|
@@ -599,10 +601,14 @@ async function respondImage(
|
|
|
599
601
|
}
|
|
600
602
|
|
|
601
603
|
pool.acquire(async (err, renderer) => {
|
|
604
|
+
const renderStart = process.hrtime.bigint();
|
|
602
605
|
// Check if pool.acquire failed or returned null/invalid renderer
|
|
603
606
|
if (err) {
|
|
604
607
|
console.error('Failed to acquire renderer from pool:', err);
|
|
605
608
|
if (!res.headersSent) {
|
|
609
|
+
if (metricsModule) {
|
|
610
|
+
metricsModule.tileErrorsTotal.inc({ type: 'rendered', name: id });
|
|
611
|
+
}
|
|
606
612
|
return res.status(503).send('Renderer pool error');
|
|
607
613
|
}
|
|
608
614
|
return;
|
|
@@ -613,6 +619,9 @@ async function respondImage(
|
|
|
613
619
|
'Renderer is null - likely crashed or failed to initialize',
|
|
614
620
|
);
|
|
615
621
|
if (!res.headersSent) {
|
|
622
|
+
if (metricsModule) {
|
|
623
|
+
metricsModule.tileErrorsTotal.inc({ type: 'rendered', name: id });
|
|
624
|
+
}
|
|
616
625
|
return res.status(503).send('Renderer unavailable');
|
|
617
626
|
}
|
|
618
627
|
return;
|
|
@@ -627,6 +636,9 @@ async function respondImage(
|
|
|
627
636
|
console.error('Error removing bad renderer:', e);
|
|
628
637
|
}
|
|
629
638
|
if (!res.headersSent) {
|
|
639
|
+
if (metricsModule) {
|
|
640
|
+
metricsModule.tileErrorsTotal.inc({ type: 'rendered', name: id });
|
|
641
|
+
}
|
|
630
642
|
return res.status(503).send('Renderer invalid');
|
|
631
643
|
}
|
|
632
644
|
return;
|
|
@@ -693,6 +705,9 @@ async function respondImage(
|
|
|
693
705
|
console.error('Error removing failed renderer:', e);
|
|
694
706
|
}
|
|
695
707
|
if (!res.headersSent) {
|
|
708
|
+
if (metricsModule) {
|
|
709
|
+
metricsModule.tileErrorsTotal.inc({ type: 'rendered', name: id });
|
|
710
|
+
}
|
|
696
711
|
return res
|
|
697
712
|
.status(500)
|
|
698
713
|
.header('Content-Type', 'text/plain')
|
|
@@ -791,12 +806,34 @@ async function respondImage(
|
|
|
791
806
|
if (err || !buffer) {
|
|
792
807
|
console.error('Sharp error:', err);
|
|
793
808
|
if (!res.headersSent) {
|
|
809
|
+
if (metricsModule) {
|
|
810
|
+
metricsModule.tileErrorsTotal.inc({
|
|
811
|
+
type: 'rendered',
|
|
812
|
+
name: id,
|
|
813
|
+
});
|
|
814
|
+
}
|
|
794
815
|
return res.status(500).send('Image processing failed');
|
|
795
816
|
}
|
|
796
817
|
return;
|
|
797
818
|
}
|
|
798
819
|
|
|
799
820
|
if (!res.headersSent) {
|
|
821
|
+
if (metricsModule) {
|
|
822
|
+
const renderDurationSec =
|
|
823
|
+
Number(process.hrtime.bigint() - renderStart) / 1e9;
|
|
824
|
+
metricsModule.tilesServedTotal.inc({
|
|
825
|
+
type: 'rendered',
|
|
826
|
+
name: id,
|
|
827
|
+
});
|
|
828
|
+
const zoomLabel =
|
|
829
|
+
process.env.TILESERVER_GL_METRICS_ZOOM === 'true'
|
|
830
|
+
? String(z)
|
|
831
|
+
: 'all';
|
|
832
|
+
metricsModule.tileRenderDuration.observe(
|
|
833
|
+
{ name: id, zoom: zoomLabel },
|
|
834
|
+
renderDurationSec,
|
|
835
|
+
);
|
|
836
|
+
}
|
|
800
837
|
res.set({
|
|
801
838
|
'Last-Modified': item.lastModified,
|
|
802
839
|
'Content-Type': `image/${format}`,
|
|
@@ -814,6 +851,9 @@ async function respondImage(
|
|
|
814
851
|
console.error('Error removing renderer after error:', e);
|
|
815
852
|
}
|
|
816
853
|
if (!res.headersSent) {
|
|
854
|
+
if (metricsModule) {
|
|
855
|
+
metricsModule.tileErrorsTotal.inc({ type: 'rendered', name: id });
|
|
856
|
+
}
|
|
817
857
|
return res.status(500).send('Render failed');
|
|
818
858
|
}
|
|
819
859
|
}
|
|
@@ -905,7 +945,7 @@ async function handleTileRequest(
|
|
|
905
945
|
|
|
906
946
|
// prettier-ignore
|
|
907
947
|
return await respondImage(
|
|
908
|
-
options, item, z, tileCenter[0], tileCenter[1], 0, 0, parsedTileSize, parsedTileSize, scale, format, res,
|
|
948
|
+
options, item, z, tileCenter[0], tileCenter[1], 0, 0, parsedTileSize, parsedTileSize, scale, format, res, null, 'tile', id,
|
|
909
949
|
);
|
|
910
950
|
}
|
|
911
951
|
|
|
@@ -1012,7 +1052,7 @@ async function handleStaticRequest(
|
|
|
1012
1052
|
|
|
1013
1053
|
// prettier-ignore
|
|
1014
1054
|
return await respondImage(
|
|
1015
|
-
options, item, z, x, y, bearing, pitch, parsedWidth, parsedHeight, scale, format, res, overlay, 'static',
|
|
1055
|
+
options, item, z, x, y, bearing, pitch, parsedWidth, parsedHeight, scale, format, res, overlay, 'static', id,
|
|
1016
1056
|
);
|
|
1017
1057
|
} else if (staticTypeMatch.groups.minx) {
|
|
1018
1058
|
// Area Based Static Image
|
|
@@ -1052,7 +1092,7 @@ async function handleStaticRequest(
|
|
|
1052
1092
|
|
|
1053
1093
|
// prettier-ignore
|
|
1054
1094
|
return await respondImage(
|
|
1055
|
-
options, item, z, x, y, bearing, pitch, parsedWidth, parsedHeight, scale, format, res, overlay, 'static',
|
|
1095
|
+
options, item, z, x, y, bearing, pitch, parsedWidth, parsedHeight, scale, format, res, overlay, 'static', id,
|
|
1056
1096
|
);
|
|
1057
1097
|
} else if (staticTypeMatch.groups.auto) {
|
|
1058
1098
|
// Area Static Image
|
|
@@ -1111,7 +1151,7 @@ async function handleStaticRequest(
|
|
|
1111
1151
|
|
|
1112
1152
|
// prettier-ignore
|
|
1113
1153
|
return await respondImage(
|
|
1114
|
-
options, item, z, x, y, bearing, pitch, parsedWidth, parsedHeight, scale, format, res, overlay, 'static',
|
|
1154
|
+
options, item, z, x, y, bearing, pitch, parsedWidth, parsedHeight, scale, format, res, overlay, 'static', id,
|
|
1115
1155
|
);
|
|
1116
1156
|
} else {
|
|
1117
1157
|
return res.sendStatus(404);
|
|
@@ -1119,6 +1159,7 @@ async function handleStaticRequest(
|
|
|
1119
1159
|
}
|
|
1120
1160
|
const existingFonts = {};
|
|
1121
1161
|
let maxScaleFactor = 2;
|
|
1162
|
+
let metricsModule = null;
|
|
1122
1163
|
|
|
1123
1164
|
export const serve_rendered = {
|
|
1124
1165
|
/**
|
|
@@ -1312,6 +1353,13 @@ export const serve_rendered = {
|
|
|
1312
1353
|
|
|
1313
1354
|
const { publicUrl, verbose, fetchTimeout } = programOpts;
|
|
1314
1355
|
|
|
1356
|
+
// Cache metrics module if enabled (avoids per-request dynamic imports).
|
|
1357
|
+
// Guard prevents re-importing per style added. Safe because tests verify before production.
|
|
1358
|
+
if (programOpts.metrics && !metricsModule) {
|
|
1359
|
+
const m = await import('./metrics.js');
|
|
1360
|
+
metricsModule = m;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1315
1363
|
const styleJSON = clone(style);
|
|
1316
1364
|
|
|
1317
1365
|
// Global sparse flag for HTTP/remote sources (from config options)
|
|
@@ -1857,6 +1905,31 @@ export const serve_rendered = {
|
|
|
1857
1905
|
maxPoolSize,
|
|
1858
1906
|
);
|
|
1859
1907
|
}
|
|
1908
|
+
|
|
1909
|
+
if (metricsModule) {
|
|
1910
|
+
map._metricsInterval = setInterval(() => {
|
|
1911
|
+
[map.renderers, map.renderersStatic].forEach((poolArr) => {
|
|
1912
|
+
poolArr.forEach((pool) => {
|
|
1913
|
+
if (!pool) return;
|
|
1914
|
+
try {
|
|
1915
|
+
const total = pool.size ?? 0;
|
|
1916
|
+
const available = pool.available ?? 0;
|
|
1917
|
+
metricsModule.renderPoolSize.set({ name: id }, total);
|
|
1918
|
+
metricsModule.renderPoolActive.set(
|
|
1919
|
+
{ name: id },
|
|
1920
|
+
total - available,
|
|
1921
|
+
);
|
|
1922
|
+
metricsModule.renderPoolWaiting.set(
|
|
1923
|
+
{ name: id },
|
|
1924
|
+
pool.pending ?? 0,
|
|
1925
|
+
);
|
|
1926
|
+
} catch (_) {
|
|
1927
|
+
/* pool may be mid-teardown */
|
|
1928
|
+
}
|
|
1929
|
+
});
|
|
1930
|
+
});
|
|
1931
|
+
}, 5000);
|
|
1932
|
+
}
|
|
1860
1933
|
},
|
|
1861
1934
|
/**
|
|
1862
1935
|
* Removes an item from the repository.
|
|
@@ -1868,6 +1941,26 @@ export const serve_rendered = {
|
|
|
1868
1941
|
// eslint-disable-next-line security/detect-object-injection -- id is function parameter for removal
|
|
1869
1942
|
const item = repo[id];
|
|
1870
1943
|
if (item) {
|
|
1944
|
+
if (item.map._metricsInterval) {
|
|
1945
|
+
clearInterval(item.map._metricsInterval);
|
|
1946
|
+
}
|
|
1947
|
+
Object.keys(item.map.sources || {}).forEach((sourceId) => {
|
|
1948
|
+
// eslint-disable-next-line security/detect-object-injection -- sourceId is from Object.keys() iteration
|
|
1949
|
+
const source = item.map.sources[sourceId];
|
|
1950
|
+
// eslint-disable-next-line security/detect-object-injection -- sourceId is from Object.keys() iteration
|
|
1951
|
+
const sourceType = item.map.sourceTypes[sourceId];
|
|
1952
|
+
if (
|
|
1953
|
+
sourceType === 'mbtiles' &&
|
|
1954
|
+
source &&
|
|
1955
|
+
typeof source.close === 'function'
|
|
1956
|
+
) {
|
|
1957
|
+
source.close((err) => {
|
|
1958
|
+
if (err) {
|
|
1959
|
+
console.warn('Failed to close MBTiles source:', err);
|
|
1960
|
+
}
|
|
1961
|
+
});
|
|
1962
|
+
}
|
|
1963
|
+
});
|
|
1871
1964
|
item.map.renderers.forEach((pool) => {
|
|
1872
1965
|
pool.close();
|
|
1873
1966
|
});
|
|
@@ -1879,25 +1972,59 @@ export const serve_rendered = {
|
|
|
1879
1972
|
delete repo[id];
|
|
1880
1973
|
},
|
|
1881
1974
|
/**
|
|
1882
|
-
* Removes all items from the repository.
|
|
1975
|
+
* Removes all items from the repository and closes owned local data sources.
|
|
1883
1976
|
* @param {object} repo Repository object.
|
|
1884
|
-
* @returns {void}
|
|
1977
|
+
* @returns {Promise<void>}
|
|
1885
1978
|
*/
|
|
1886
|
-
clear: function (repo) {
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1979
|
+
clear: async function (repo) {
|
|
1980
|
+
await Promise.all(
|
|
1981
|
+
Object.keys(repo).map(async (id) => {
|
|
1982
|
+
// eslint-disable-next-line security/detect-object-injection -- id is from Object.keys() iteration
|
|
1983
|
+
const item = repo[id];
|
|
1984
|
+
try {
|
|
1985
|
+
if (!item) {
|
|
1986
|
+
return;
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
await Promise.all(
|
|
1990
|
+
Object.keys(item.map.sources || {}).map(async (sourceId) => {
|
|
1991
|
+
// eslint-disable-next-line security/detect-object-injection -- sourceId is from Object.keys() iteration
|
|
1992
|
+
const source = item.map.sources[sourceId];
|
|
1993
|
+
// eslint-disable-next-line security/detect-object-injection -- sourceId is from Object.keys() iteration
|
|
1994
|
+
const sourceType = item.map.sourceTypes[sourceId];
|
|
1995
|
+
if (
|
|
1996
|
+
sourceType === 'mbtiles' &&
|
|
1997
|
+
source &&
|
|
1998
|
+
typeof source.close === 'function'
|
|
1999
|
+
) {
|
|
2000
|
+
await new Promise((resolve) => {
|
|
2001
|
+
source.close((err) => {
|
|
2002
|
+
if (err) {
|
|
2003
|
+
console.warn(
|
|
2004
|
+
`Failed to close MBTiles source "${sourceId}" while clearing rendered repo entry "${id}":`,
|
|
2005
|
+
err,
|
|
2006
|
+
);
|
|
2007
|
+
}
|
|
2008
|
+
resolve();
|
|
2009
|
+
});
|
|
2010
|
+
});
|
|
2011
|
+
}
|
|
2012
|
+
}),
|
|
2013
|
+
);
|
|
2014
|
+
item.map.renderers.forEach((pool) => {
|
|
2015
|
+
pool.close();
|
|
2016
|
+
});
|
|
2017
|
+
item.map.renderersStatic.forEach((pool) => {
|
|
2018
|
+
pool.close();
|
|
2019
|
+
});
|
|
2020
|
+
} catch (err) {
|
|
2021
|
+
console.warn(`Failed to clear rendered repo entry "${id}":`, err);
|
|
2022
|
+
} finally {
|
|
2023
|
+
// eslint-disable-next-line security/detect-object-injection -- id is from Object.keys() iteration
|
|
2024
|
+
delete repo[id];
|
|
2025
|
+
}
|
|
2026
|
+
}),
|
|
2027
|
+
);
|
|
1901
2028
|
},
|
|
1902
2029
|
|
|
1903
2030
|
/**
|
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);
|
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';
|
|
@@ -502,6 +536,10 @@ async function start(opts) {
|
|
|
502
536
|
continue;
|
|
503
537
|
}
|
|
504
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
|
+
}
|
|
505
543
|
}
|
|
506
544
|
|
|
507
545
|
// Wait for styles to finish loading, then load data sources
|
|
@@ -553,6 +591,9 @@ async function start(opts) {
|
|
|
553
591
|
path.join(options.paths.styles, '*.json'),
|
|
554
592
|
{},
|
|
555
593
|
);
|
|
594
|
+
cleanup = async () => {
|
|
595
|
+
await watcher.close();
|
|
596
|
+
};
|
|
556
597
|
watcher.on('all', (eventType, filename) => {
|
|
557
598
|
if (filename) {
|
|
558
599
|
const id = path.basename(filename, '.json');
|
|
@@ -913,26 +954,12 @@ async function start(opts) {
|
|
|
913
954
|
return null;
|
|
914
955
|
}
|
|
915
956
|
|
|
916
|
-
let baseUrl;
|
|
917
|
-
if (opts.publicUrl) {
|
|
918
|
-
baseUrl = opts.publicUrl;
|
|
919
|
-
} else {
|
|
920
|
-
const parsedAllowed = parseAllowedHosts(opts.allowedHosts);
|
|
921
|
-
const candidateHost = getCandidateHost(req);
|
|
922
|
-
if (!isHostAllowed(candidateHost, parsedAllowed)) {
|
|
923
|
-
baseUrl = '/';
|
|
924
|
-
} else {
|
|
925
|
-
const proto = getSafeProtocol(req);
|
|
926
|
-
baseUrl = `${proto}://${candidateHost}/`;
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
|
|
930
957
|
return {
|
|
931
958
|
...wmts,
|
|
932
959
|
id,
|
|
933
960
|
// eslint-disable-next-line security/detect-object-injection -- id is route parameter from URL
|
|
934
961
|
name: (serving.styles[id] || serving.rendered[id]).name,
|
|
935
|
-
baseUrl,
|
|
962
|
+
baseUrl: getPublicUrl(opts.publicUrl, req, opts.allowedHosts),
|
|
936
963
|
};
|
|
937
964
|
});
|
|
938
965
|
|
|
@@ -1026,11 +1053,40 @@ async function start(opts) {
|
|
|
1026
1053
|
// add server.shutdown() to gracefully stop serving
|
|
1027
1054
|
enableShutdown(server);
|
|
1028
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
|
+
|
|
1029
1083
|
return {
|
|
1030
1084
|
app,
|
|
1031
1085
|
server,
|
|
1032
1086
|
startupPromise,
|
|
1033
1087
|
serving,
|
|
1088
|
+
cleanup,
|
|
1089
|
+
metricsServer,
|
|
1034
1090
|
};
|
|
1035
1091
|
}
|
|
1036
1092
|
/**
|
|
@@ -1043,6 +1099,17 @@ function stopGracefully(signal) {
|
|
|
1043
1099
|
process.exit();
|
|
1044
1100
|
}
|
|
1045
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
|
+
|
|
1046
1113
|
/**
|
|
1047
1114
|
* Starts and manages the server
|
|
1048
1115
|
* @param {object} opts - Configuration options for the server.
|
|
@@ -1050,28 +1117,72 @@ function stopGracefully(signal) {
|
|
|
1050
1117
|
*/
|
|
1051
1118
|
export async function server(opts) {
|
|
1052
1119
|
const running = await start(opts);
|
|
1120
|
+
let reloading = false;
|
|
1121
|
+
let pendingReload = false;
|
|
1053
1122
|
|
|
1054
1123
|
running.startupPromise.catch((err) => {
|
|
1055
1124
|
console.error(err.message);
|
|
1056
1125
|
process.exit(1);
|
|
1057
1126
|
});
|
|
1058
1127
|
|
|
1059
|
-
|
|
1060
|
-
|
|
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
|
+
};
|
|
1061
1178
|
|
|
1062
1179
|
process.on('SIGHUP', (signal) => {
|
|
1063
1180
|
console.log(`Caught signal ${signal}, refreshing`);
|
|
1064
1181
|
console.log('Stopping server and reloading config');
|
|
1065
1182
|
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
serve_rendered.clear(running.serving.rendered);
|
|
1070
|
-
}
|
|
1071
|
-
running.server = restarted.server;
|
|
1072
|
-
running.app = restarted.app;
|
|
1073
|
-
running.startupPromise = restarted.startupPromise;
|
|
1074
|
-
running.serving = restarted.serving;
|
|
1183
|
+
reload().catch((err) => {
|
|
1184
|
+
console.error(err.message);
|
|
1185
|
+
process.exit(1);
|
|
1075
1186
|
});
|
|
1076
1187
|
});
|
|
1077
1188
|
return running;
|
|
@@ -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/);
|