tileserver-gl-light 5.5.0-pre.0 → 5.5.0-pre.11

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +51 -33
  2. package/docs/config.rst +52 -11
  3. package/docs/endpoints.rst +12 -2
  4. package/docs/installation.rst +6 -6
  5. package/docs/usage.rst +35 -1
  6. package/package.json +15 -15
  7. package/public/resources/elevation-control.js +92 -21
  8. package/public/resources/maplibre-gl-inspect.js +2827 -2770
  9. package/public/resources/maplibre-gl-inspect.js.map +1 -1
  10. package/public/resources/maplibre-gl.css +1 -1
  11. package/public/resources/maplibre-gl.js +4 -4
  12. package/public/resources/maplibre-gl.js.map +1 -1
  13. package/src/main.js +31 -20
  14. package/src/pmtiles_adapter.js +104 -45
  15. package/src/promises.js +1 -1
  16. package/src/render.js +270 -93
  17. package/src/serve_data.js +266 -90
  18. package/src/serve_font.js +2 -2
  19. package/src/serve_light.js +2 -4
  20. package/src/serve_rendered.js +445 -236
  21. package/src/serve_style.js +29 -8
  22. package/src/server.js +115 -60
  23. package/src/utils.js +47 -20
  24. package/test/elevation.js +513 -0
  25. package/test/fixtures/visual/encoded-path-auto.png +0 -0
  26. package/test/fixtures/visual/linecap-linejoin-bevel-square.png +0 -0
  27. package/test/fixtures/visual/linecap-linejoin-round-round.png +0 -0
  28. package/test/fixtures/visual/path-auto.png +0 -0
  29. package/test/fixtures/visual/static-bbox.png +0 -0
  30. package/test/fixtures/visual/static-bearing-pitch.png +0 -0
  31. package/test/fixtures/visual/static-bearing.png +0 -0
  32. package/test/fixtures/visual/static-border-global.png +0 -0
  33. package/test/fixtures/visual/static-lat-lng.png +0 -0
  34. package/test/fixtures/visual/static-markers.png +0 -0
  35. package/test/fixtures/visual/static-multiple-paths.png +0 -0
  36. package/test/fixtures/visual/static-path-border-isolated.png +0 -0
  37. package/test/fixtures/visual/static-path-border-stroke.png +0 -0
  38. package/test/fixtures/visual/static-path-latlng.png +0 -0
  39. package/test/fixtures/visual/static-pixel-ratio-2x.png +0 -0
  40. package/test/static_images.js +241 -0
  41. package/test/tiles_data.js +1 -1
  42. package/test/utils/create_terrain_mbtiles.js +124 -0
package/src/main.js CHANGED
@@ -12,7 +12,8 @@ import fs from 'node:fs';
12
12
  import fsp from 'node:fs/promises';
13
13
  import path from 'path';
14
14
  import { fileURLToPath } from 'url';
15
- import axios from 'axios';
15
+ import { Readable } from 'node:stream';
16
+ import { pipeline } from 'node:stream/promises';
16
17
  import { server } from './server.js';
17
18
  import { isValidRemoteUrl } from './utils.js';
18
19
  import { openPMtiles, getPMtilesInfo } from './pmtiles_adapter.js';
@@ -81,7 +82,10 @@ program
81
82
  )
82
83
  .option(
83
84
  '-V, --verbose [level]',
84
- 'More verbose output (can specify level 1-3, default 1)',
85
+ 'More verbose output (level 1-3)\n' +
86
+ '\t-V, --verbose, -V 1, or --verbose 1: Important operations\n' +
87
+ '\t-V 2 or --verbose 2: Detailed operations\n' +
88
+ '\t-V 3 or --verbose 3: All requests and debug info',
85
89
  (value) => {
86
90
  // If no value provided, return 1 (boolean true case)
87
91
  if (value === undefined || value === true) return 1;
@@ -91,6 +95,14 @@ program
91
95
  return isNaN(level) ? 1 : Math.min(Math.max(level, 1), 3);
92
96
  },
93
97
  )
98
+ .option(
99
+ '--fetch-timeout <ms>',
100
+ 'External fetch timeout in milliseconds for renderer HTTP requests (default 15000)',
101
+ (value) => {
102
+ const v = parseInt(value, 10);
103
+ return isNaN(v) ? 15000 : v;
104
+ },
105
+ )
94
106
  .option('-s, --silent', 'Less verbose output')
95
107
  .option('-l|--log_file <file>', 'output log file (defaults to standard out)')
96
108
  .option(
@@ -118,6 +130,7 @@ const startServer = (configPath, config) => {
118
130
  silent: opts.silent,
119
131
  logFile: opts.log_file,
120
132
  logFormat: opts.log_format,
133
+ fetchTimeout: opts.fetchTimeout,
121
134
  publicUrl,
122
135
  });
123
136
  };
@@ -163,7 +176,6 @@ const startWithInputFile = async (inputFile) => {
163
176
  inputFile = path.resolve(process.cwd(), inputFile);
164
177
  inputFilePath = path.dirname(inputFile);
165
178
 
166
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- Validating local file from CLI argument
167
179
  const inputFileStats = await fsp.stat(inputFile);
168
180
  if (!inputFileStats.isFile() || inputFileStats.size === 0) {
169
181
  console.log(`ERROR: Not a valid input file: ${inputFile}`);
@@ -246,7 +258,7 @@ const startWithInputFile = async (inputFile) => {
246
258
  }
247
259
  }
248
260
 
249
- if (opts.verbose) {
261
+ if (opts.verbose >= 1) {
250
262
  console.log(JSON.stringify(config, undefined, 2));
251
263
  } else {
252
264
  console.log('Run with --verbose to see the config file here.');
@@ -304,7 +316,7 @@ const startWithInputFile = async (inputFile) => {
304
316
  };
305
317
  }
306
318
 
307
- if (opts.verbose) {
319
+ if (opts.verbose >= 1) {
308
320
  console.log(JSON.stringify(config, undefined, 2));
309
321
  } else {
310
322
  console.log('Run with --verbose to see the config file here.');
@@ -314,7 +326,6 @@ const startWithInputFile = async (inputFile) => {
314
326
  }
315
327
  };
316
328
 
317
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- Config path from CLI argument is expected behavior
318
329
  fs.stat(path.resolve(opts.config), async (err, stats) => {
319
330
  if (err || !stats.isFile() || stats.size === 0) {
320
331
  let inputFile;
@@ -331,7 +342,6 @@ fs.stat(path.resolve(opts.config), async (err, stats) => {
331
342
  const files = await fsp.readdir(process.cwd());
332
343
  for (const filename of files) {
333
344
  if (filename.endsWith('.mbtiles') || filename.endsWith('.pmtiles')) {
334
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- Scanning current directory for tile files
335
345
  const inputFilesStats = await fsp.stat(filename);
336
346
  if (inputFilesStats.isFile() && inputFilesStats.size > 0) {
337
347
  inputFile = filename;
@@ -346,25 +356,26 @@ fs.stat(path.resolve(opts.config), async (err, stats) => {
346
356
  const url =
347
357
  'https://github.com/maptiler/tileserver-gl/releases/download/v1.3.0/zurich_switzerland.mbtiles';
348
358
  const filename = 'zurich_switzerland.mbtiles';
349
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- Writing demo file to known filename
359
+
350
360
  const writer = fs.createWriteStream(filename);
351
361
  console.log(`No input file found`);
352
362
  console.log(`[DEMO] Downloading sample data (${filename}) from ${url}`);
353
363
 
354
364
  try {
355
- const response = await axios({
356
- url,
357
- method: 'GET',
358
- responseType: 'stream',
359
- });
360
-
361
- response.data.pipe(writer);
362
- writer.on('finish', () => startWithInputFile(filename));
363
- writer.on('error', (err) =>
364
- console.error(`Error writing file: ${err}`),
365
- );
365
+ const response = await fetch(url);
366
+
367
+ if (!response.ok) {
368
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
369
+ }
370
+
371
+ // Convert web ReadableStream to Node.js Readable stream and pipe to file
372
+ const nodeStream = Readable.fromWeb(response.body);
373
+ await pipeline(nodeStream, writer);
374
+
375
+ console.log('Download complete');
376
+ startWithInputFile(filename);
366
377
  } catch (error) {
367
- console.error(`Error downloading file: ${error}`);
378
+ console.error(`Error downloading file: ${error.message || error}`);
368
379
  }
369
380
  }
370
381
  }
@@ -17,31 +17,45 @@ class S3Source {
17
17
  * @param {string} [s3Profile] - Optional AWS credential profile name from config.
18
18
  * @param {boolean} [configRequestPayer] - Optional flag from config for requester pays buckets.
19
19
  * @param {string} [configRegion] - Optional AWS region from config.
20
- * @param {boolean} [verbose] - Whether to show verbose logging.
20
+ * @param {string} [s3UrlFormat] - Optional S3 URL format from config: 'aws' or 'custom'.
21
+ * @param {number} [verbose] - Verbosity level (1-3). 1=important, 2=detailed, 3=debug/all requests.
21
22
  */
22
23
  constructor(
23
24
  s3Url,
24
25
  s3Profile,
25
26
  configRequestPayer,
26
27
  configRegion,
28
+ s3UrlFormat,
27
29
  verbose = false,
28
30
  ) {
29
- const parsed = this.parseS3Url(s3Url);
31
+ const parsed = this.parseS3Url(s3Url, s3UrlFormat);
30
32
  this.bucket = parsed.bucket;
31
33
  this.key = parsed.key;
32
34
  this.endpoint = parsed.endpoint;
33
35
  this.url = s3Url;
34
36
  this.verbose = verbose;
35
37
 
36
- // Determine the final profile: Config takes precedence over URL
38
+ // Apply configuration precedence: Config > URL > Default
39
+ // Using || for strings (empty string = not set)
40
+ // Using ?? for booleans (false is valid value)
37
41
  const profile = s3Profile || parsed.profile;
38
-
39
- // Determine requestPayer: Config takes precedence over URL
40
42
  this.requestPayer = configRequestPayer ?? parsed.requestPayer;
41
-
42
- // Determine region: Config takes precedence over URL
43
43
  this.region = configRegion || parsed.region;
44
44
 
45
+ // Log precedence decisions for debugging
46
+ if (verbose >= 3) {
47
+ console.log(`S3 config precedence for ${s3Url}:`);
48
+ console.log(
49
+ ` Profile: ${s3Profile ? 'config' : parsed.profile ? 'url' : 'default'} = ${profile || 'none'}`,
50
+ );
51
+ console.log(
52
+ ` Region: ${configRegion ? 'config' : parsed.region !== (process.env.AWS_REGION || 'us-east-1') ? 'url' : 'env/default'} = ${this.region}`,
53
+ );
54
+ console.log(
55
+ ` RequestPayer: ${configRequestPayer !== undefined ? 'config' : parsed.requestPayer ? 'url' : 'default'} = ${this.requestPayer}`,
56
+ );
57
+ }
58
+
45
59
  // Create S3 client
46
60
  this.s3Client = this.createS3Client(
47
61
  parsed.endpoint,
@@ -54,60 +68,78 @@ class S3Source {
54
68
  /**
55
69
  * Parses various S3 URL formats into bucket, key, endpoint, region, and profile.
56
70
  * @param {string} url - The S3 URL to parse.
71
+ * @param {string} [s3UrlFormat] - Optional format override: 'aws' or 'custom'.
57
72
  * @returns {object} - An object containing bucket, key, endpoint, region, and profile.
58
73
  * @throws {Error} - Throws an error if the URL format is invalid.
59
74
  */
60
- parseS3Url(url) {
61
- // Initialize with defaults/environment
75
+ parseS3Url(url, s3UrlFormat) {
76
+ // Validate s3UrlFormat if provided
77
+ if (s3UrlFormat && s3UrlFormat !== 'aws' && s3UrlFormat !== 'custom') {
78
+ console.warn(
79
+ `Invalid s3UrlFormat: "${s3UrlFormat}". Must be "aws" or "custom". Using auto-detection.`,
80
+ );
81
+ s3UrlFormat = undefined;
82
+ }
83
+
62
84
  let region = process.env.AWS_REGION || 'us-east-1';
63
85
  let profile = null;
64
86
  let requestPayer = false;
65
87
 
66
- const queryString = url.split('?')[1];
88
+ // Parse URL parameters
89
+ const [cleanUrl, queryString] = url.split('?');
67
90
  if (queryString) {
68
91
  const params = new URLSearchParams(queryString);
69
- // Update profile if provided in url parameters
92
+ // URL parameters override defaults
70
93
  profile = params.get('profile') ?? profile;
71
- // Update region if provided in url parameters
72
94
  region = params.get('region') ?? region;
73
- // Update requestPayer if provided in url parameters
95
+ s3UrlFormat = s3UrlFormat ?? params.get('s3UrlFormat'); // Config overrides URL
96
+
74
97
  const payerVal = params.get('requestPayer');
75
98
  requestPayer = payerVal === 'true' || payerVal === '1';
76
99
  }
77
100
 
78
- // Clean URL for format detection (remove trailing slashes)
79
- const baseUrl = url.split('?')[0];
80
- const cleanUrl = baseUrl.replace(/\/+$/, '');
101
+ // Helper to build result object
102
+ const buildResult = (endpoint, bucket, key) => ({
103
+ endpoint: endpoint ? `https://${endpoint}` : null,
104
+ bucket,
105
+ key,
106
+ region,
107
+ profile,
108
+ requestPayer,
109
+ });
81
110
 
82
- // Format 1: s3://endpoint/bucket/key (S3-compatible storage)
83
- // Example: s3://storage.example.com/mybucket/path/to/tiles.pmtile
84
- const endpointMatch = cleanUrl.match(/^s3:\/\/([^\/]+)\/([^\/]+)\/(.+)$/);
85
- if (endpointMatch) {
86
- return {
87
- endpoint: `https://${endpointMatch[1]}`,
88
- bucket: endpointMatch[2],
89
- key: endpointMatch[3],
90
- region,
91
- profile,
92
- requestPayer,
93
- };
94
- }
111
+ // Define patterns based on format
112
+ const patterns = {
113
+ customWithDot: /^s3:\/\/([^/]*\.[^/]+)\/([^/]+)\/(.+)$/, // Auto-detect: requires dot
114
+ customForced: /^s3:\/\/([^/]+)\/([^/]+)\/(.+)$/, // Explicit: no dot required
115
+ aws: /^s3:\/\/([^/]+)\/(.+)$/,
116
+ };
95
117
 
96
- // Format 2: s3://bucket/key (AWS S3 default)
97
- // Example: s3://my-bucket/path/to/tiles.pmtiles
98
- const awsMatch = cleanUrl.match(/^s3:\/\/([^\/]+)\/(.+)$/);
99
- if (awsMatch) {
100
- return {
101
- endpoint: null, // Use default AWS endpoint
102
- bucket: awsMatch[1],
103
- key: awsMatch[2],
104
- region,
105
- profile,
106
- requestPayer,
107
- };
118
+ // Match based on s3UrlFormat or auto-detect
119
+ let match;
120
+
121
+ if (s3UrlFormat === 'custom') {
122
+ match = cleanUrl.match(patterns.customForced);
123
+ if (match) return buildResult(match[1], match[2], match[3]);
124
+ } else if (s3UrlFormat === 'aws') {
125
+ match = cleanUrl.match(patterns.aws);
126
+ if (match) return buildResult(null, match[1], match[2]);
127
+ } else {
128
+ // Auto-detection: try custom (with dot) first, then AWS
129
+ match = cleanUrl.match(patterns.customWithDot);
130
+ if (match) return buildResult(match[1], match[2], match[3]);
131
+
132
+ match = cleanUrl.match(patterns.aws);
133
+ if (match) return buildResult(null, match[1], match[2]);
108
134
  }
109
135
 
110
- throw new Error(`Invalid S3 URL format: ${url}`);
136
+ throw new Error(
137
+ `Invalid S3 URL format: ${url}\n` +
138
+ `Expected formats:\n` +
139
+ ` AWS S3: s3://bucket-name/path/to/file.pmtiles\n` +
140
+ ` Custom endpoint: s3://endpoint.com/bucket/path/to/file.pmtiles\n` +
141
+ `Use s3UrlFormat parameter to override auto-detection if needed.`,
142
+ );
111
143
  }
112
144
 
113
145
  /**
@@ -115,7 +147,7 @@ class S3Source {
115
147
  * @param {string|null} endpoint - The custom endpoint URL, or null for default AWS S3.
116
148
  * @param {string} region - The AWS region.
117
149
  * @param {string} [profile] - Optional AWS credential profile name.
118
- * @param {boolean} [verbose] - Whether to show verbose logging.
150
+ * @param {number} [verbose] - Verbosity level (1-3). 1=important, 2=detailed, 3=debug/all requests.
119
151
  * @returns {S3Client} - Configured S3Client instance.
120
152
  */
121
153
  createS3Client(endpoint, region, profile, verbose) {
@@ -276,13 +308,18 @@ async function readFileBytes(fd, buffer, offset) {
276
308
  });
277
309
  }
278
310
 
311
+ // Cache for PMTiles objects to avoid creating multiple instances for the same URL
312
+ const pmtilesCache = new Map();
313
+
279
314
  /**
280
315
  * Opens a PMTiles file from local filesystem, HTTP URL, or S3 URL.
316
+ * Uses caching to avoid creating multiple PMTiles instances for the same file.
281
317
  * @param {string} filePath - The path to the PMTiles file.
282
318
  * @param {string} [s3Profile] - Optional AWS credential profile name.
283
319
  * @param {boolean} [requestPayer] - Optional flag for requester pays buckets.
284
320
  * @param {string} [s3Region] - Optional AWS region.
285
- * @param {boolean} [verbose] - Whether to show verbose logging.
321
+ * @param {string} [s3UrlFormat] - Optional S3 URL format: 'aws' or 'custom'.
322
+ * @param {number} [verbose] - Verbosity level (1-3). 1=important, 2=detailed, 3=debug/all requests.
286
323
  * @returns {PMTiles} - A PMTiles instance.
287
324
  */
288
325
  export function openPMtiles(
@@ -290,8 +327,26 @@ export function openPMtiles(
290
327
  s3Profile,
291
328
  requestPayer,
292
329
  s3Region,
330
+ s3UrlFormat,
293
331
  verbose = 0,
294
332
  ) {
333
+ // Create a cache key that includes all parameters that affect the source
334
+ const cacheKey = JSON.stringify({
335
+ filePath,
336
+ s3Profile,
337
+ requestPayer,
338
+ s3Region,
339
+ s3UrlFormat,
340
+ });
341
+
342
+ // Check if we already have a PMTiles object for this configuration
343
+ if (pmtilesCache.has(cacheKey)) {
344
+ if (verbose >= 2) {
345
+ console.log(`Using cached PMTiles instance for: ${filePath}`);
346
+ }
347
+ return pmtilesCache.get(cacheKey);
348
+ }
349
+
295
350
  let pmtiles = undefined;
296
351
 
297
352
  if (isS3Url(filePath)) {
@@ -303,6 +358,7 @@ export function openPMtiles(
303
358
  s3Profile,
304
359
  requestPayer,
305
360
  s3Region,
361
+ s3UrlFormat,
306
362
  verbose,
307
363
  );
308
364
  pmtiles = new PMTiles(source);
@@ -316,12 +372,15 @@ export function openPMtiles(
316
372
  if (verbose >= 2) {
317
373
  console.log(`Opening PMTiles from local file: ${filePath}`);
318
374
  }
319
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- Opening local PMTiles file specified in config or CLI
375
+
320
376
  const fd = fs.openSync(filePath, 'r');
321
377
  const source = new PMTilesFileSource(fd);
322
378
  pmtiles = new PMTiles(source);
323
379
  }
324
380
 
381
+ // Cache the PMTiles object
382
+ pmtilesCache.set(cacheKey, pmtiles);
383
+
325
384
  return pmtiles;
326
385
  }
327
386
 
package/src/promises.js CHANGED
@@ -8,7 +8,7 @@ export const existsP = async (path) => {
8
8
  try {
9
9
  await fsp.access(path); // Defaults to F_OK: indicating that the file is visible to the calling process
10
10
  return true;
11
- } catch (err) {
11
+ } catch {
12
12
  return false;
13
13
  }
14
14
  };