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.
@@ -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 (err) {
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 (err) {
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}`); //throw an error so that the server doesnt start
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
- process.on('SIGINT', stopGracefully);
1058
- process.on('SIGTERM', stopGracefully);
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
- running.server.shutdown(async () => {
1065
- const restarted = await start(opts);
1066
- if (!isLight) {
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
+ }
@@ -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/);