tileserver-gl-light 5.4.1-pre.0 → 5.5.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 +2 -2
- package/docs/config.rst +158 -2
- package/docs/usage.rst +58 -3
- package/package.json +7 -6
- package/public/resources/maplibre-gl.js +4 -4
- package/public/resources/maplibre-gl.js.map +1 -1
- package/src/main.js +89 -14
- package/src/mbtiles_wrapper.js +5 -6
- package/src/pmtiles_adapter.js +413 -60
- package/src/render.js +48 -13
- package/src/serve_data.js +28 -9
- package/src/serve_rendered.js +78 -27
- package/src/serve_style.js +13 -8
- package/src/server.js +115 -25
- package/src/utils.js +79 -11
package/src/pmtiles_adapter.js
CHANGED
|
@@ -1,14 +1,252 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import { PMTiles, FetchSource } from 'pmtiles';
|
|
3
|
-
import { isValidHttpUrl } from './utils.js';
|
|
2
|
+
import { PMTiles, FetchSource, EtagMismatch } from 'pmtiles';
|
|
3
|
+
import { isValidHttpUrl, isS3Url } from './utils.js';
|
|
4
|
+
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
|
|
5
|
+
import { fromIni } from '@aws-sdk/credential-provider-ini';
|
|
4
6
|
|
|
7
|
+
/**
|
|
8
|
+
* S3 Source for PMTiles
|
|
9
|
+
* Supports:
|
|
10
|
+
* - AWS S3: s3://bucket-name/path/to/file.pmtiles
|
|
11
|
+
* - S3-compatible with endpoint: s3://endpoint-url/bucket/path/to/file.pmtiles
|
|
12
|
+
*/
|
|
13
|
+
class S3Source {
|
|
14
|
+
/**
|
|
15
|
+
* Creates an S3Source instance.
|
|
16
|
+
* @param {string} s3Url - The S3 URL in one of the supported formats.
|
|
17
|
+
* @param {string} [s3Profile] - Optional AWS credential profile name from config.
|
|
18
|
+
* @param {boolean} [configRequestPayer] - Optional flag from config for requester pays buckets.
|
|
19
|
+
* @param {string} [configRegion] - Optional AWS region from config.
|
|
20
|
+
* @param {boolean} [verbose] - Whether to show verbose logging.
|
|
21
|
+
*/
|
|
22
|
+
constructor(
|
|
23
|
+
s3Url,
|
|
24
|
+
s3Profile,
|
|
25
|
+
configRequestPayer,
|
|
26
|
+
configRegion,
|
|
27
|
+
verbose = false,
|
|
28
|
+
) {
|
|
29
|
+
const parsed = this.parseS3Url(s3Url);
|
|
30
|
+
this.bucket = parsed.bucket;
|
|
31
|
+
this.key = parsed.key;
|
|
32
|
+
this.endpoint = parsed.endpoint;
|
|
33
|
+
this.url = s3Url;
|
|
34
|
+
this.verbose = verbose;
|
|
35
|
+
|
|
36
|
+
// Determine the final profile: Config takes precedence over URL
|
|
37
|
+
const profile = s3Profile || parsed.profile;
|
|
38
|
+
|
|
39
|
+
// Determine requestPayer: Config takes precedence over URL
|
|
40
|
+
this.requestPayer = configRequestPayer ?? parsed.requestPayer;
|
|
41
|
+
|
|
42
|
+
// Determine region: Config takes precedence over URL
|
|
43
|
+
this.region = configRegion || parsed.region;
|
|
44
|
+
|
|
45
|
+
// Create S3 client
|
|
46
|
+
this.s3Client = this.createS3Client(
|
|
47
|
+
parsed.endpoint,
|
|
48
|
+
this.region,
|
|
49
|
+
profile,
|
|
50
|
+
this.verbose,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parses various S3 URL formats into bucket, key, endpoint, region, and profile.
|
|
56
|
+
* @param {string} url - The S3 URL to parse.
|
|
57
|
+
* @returns {object} - An object containing bucket, key, endpoint, region, and profile.
|
|
58
|
+
* @throws {Error} - Throws an error if the URL format is invalid.
|
|
59
|
+
*/
|
|
60
|
+
parseS3Url(url) {
|
|
61
|
+
// Initialize with defaults/environment
|
|
62
|
+
let region = process.env.AWS_REGION || 'us-east-1';
|
|
63
|
+
let profile = null;
|
|
64
|
+
let requestPayer = false;
|
|
65
|
+
|
|
66
|
+
const queryString = url.split('?')[1];
|
|
67
|
+
if (queryString) {
|
|
68
|
+
const params = new URLSearchParams(queryString);
|
|
69
|
+
// Update profile if provided in url parameters
|
|
70
|
+
profile = params.get('profile') ?? profile;
|
|
71
|
+
// Update region if provided in url parameters
|
|
72
|
+
region = params.get('region') ?? region;
|
|
73
|
+
// Update requestPayer if provided in url parameters
|
|
74
|
+
const payerVal = params.get('requestPayer');
|
|
75
|
+
requestPayer = payerVal === 'true' || payerVal === '1';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Clean URL for format detection (remove trailing slashes)
|
|
79
|
+
const baseUrl = url.split('?')[0];
|
|
80
|
+
const cleanUrl = baseUrl.replace(/\/+$/, '');
|
|
81
|
+
|
|
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
|
+
}
|
|
95
|
+
|
|
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
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
throw new Error(`Invalid S3 URL format: ${url}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Creates an S3 client with optional custom endpoint and AWS profile support.
|
|
115
|
+
* @param {string|null} endpoint - The custom endpoint URL, or null for default AWS S3.
|
|
116
|
+
* @param {string} region - The AWS region.
|
|
117
|
+
* @param {string} [profile] - Optional AWS credential profile name.
|
|
118
|
+
* @param {boolean} [verbose] - Whether to show verbose logging.
|
|
119
|
+
* @returns {S3Client} - Configured S3Client instance.
|
|
120
|
+
*/
|
|
121
|
+
createS3Client(endpoint, region, profile, verbose) {
|
|
122
|
+
const config = {
|
|
123
|
+
region: region,
|
|
124
|
+
requestHandler: {
|
|
125
|
+
connectionTimeout: 5000,
|
|
126
|
+
socketTimeout: 5000,
|
|
127
|
+
},
|
|
128
|
+
forcePathStyle: !!endpoint,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
if (endpoint) {
|
|
132
|
+
config.endpoint = endpoint;
|
|
133
|
+
if (verbose >= 2) {
|
|
134
|
+
console.log(`Using custom S3 endpoint: ${endpoint}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (profile) {
|
|
139
|
+
config.credentials = fromIni({ profile });
|
|
140
|
+
if (verbose >= 2) {
|
|
141
|
+
console.log(`Using AWS profile: ${profile}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return new S3Client(config);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Returns the unique key for this S3 source.
|
|
149
|
+
* @returns {string} - The S3 URL.
|
|
150
|
+
*/
|
|
151
|
+
getKey() {
|
|
152
|
+
return this.url;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Fetches a byte range from the S3 object.
|
|
157
|
+
* @param {number} offset - The starting byte offset.
|
|
158
|
+
* @param {number} length - The number of bytes to fetch.
|
|
159
|
+
* @param {AbortSignal} [signal] - Optional abort signal for cancelling the request.
|
|
160
|
+
* @param {string} [etag] - Optional ETag for conditional requests.
|
|
161
|
+
* @returns {Promise<object>} - A promise that resolves to an object containing data, etag, expires, and cacheControl.
|
|
162
|
+
* @throws {EtagMismatch} - Throws if ETag doesn't match.
|
|
163
|
+
* @throws {Error} - Throws on S3 errors like NoSuchKey, AccessDenied, NoSuchBucket.
|
|
164
|
+
*/
|
|
165
|
+
async getBytes(offset, length, signal, etag) {
|
|
166
|
+
try {
|
|
167
|
+
const commandParams = {
|
|
168
|
+
Bucket: this.bucket,
|
|
169
|
+
Key: this.key,
|
|
170
|
+
Range: `bytes=${offset}-${offset + length - 1}`,
|
|
171
|
+
IfMatch: etag,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
if (this.requestPayer) {
|
|
175
|
+
commandParams.RequestPayer = 'requester';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const command = new GetObjectCommand(commandParams);
|
|
179
|
+
|
|
180
|
+
const response = await this.s3Client.send(command, {
|
|
181
|
+
abortSignal: signal,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const arr = await response.Body.transformToByteArray();
|
|
185
|
+
|
|
186
|
+
if (!arr) {
|
|
187
|
+
throw new Error('Failed to read S3 response body');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
data: arr.buffer,
|
|
192
|
+
etag: response.ETag,
|
|
193
|
+
expires: response.Expires?.toISOString(),
|
|
194
|
+
cacheControl: response.CacheControl,
|
|
195
|
+
};
|
|
196
|
+
} catch (error) {
|
|
197
|
+
// Handle AWS SDK errors
|
|
198
|
+
if (error.name === 'PreconditionFailed') {
|
|
199
|
+
throw new EtagMismatch();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (error.name === 'NoSuchKey') {
|
|
203
|
+
throw new Error(`PMTiles file not found: ${this.bucket}/${this.key}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (error.name === 'AccessDenied') {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`Access denied: ${this.bucket}/${this.key}. Check credentials and bucket permissions.`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (error.name === 'NoSuchBucket') {
|
|
213
|
+
throw new Error(
|
|
214
|
+
`Bucket not found: ${this.bucket}. Check bucket name and endpoint.`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
console.error(`S3 error for ${this.bucket}/${this.key}:`, error.message);
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Local file source for PMTiles using Node.js file descriptors.
|
|
226
|
+
*/
|
|
5
227
|
class PMTilesFileSource {
|
|
228
|
+
/**
|
|
229
|
+
* Creates a PMTilesFileSource instance.
|
|
230
|
+
* @param {number} fd - The file descriptor for the opened PMTiles file.
|
|
231
|
+
*/
|
|
6
232
|
constructor(fd) {
|
|
7
233
|
this.fd = fd;
|
|
8
234
|
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Returns the unique key for this file source.
|
|
238
|
+
* @returns {number} - The file descriptor.
|
|
239
|
+
*/
|
|
9
240
|
getKey() {
|
|
10
241
|
return this.fd;
|
|
11
242
|
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Reads a byte range from the local file.
|
|
246
|
+
* @param {number} offset - The starting byte offset.
|
|
247
|
+
* @param {number} length - The number of bytes to read.
|
|
248
|
+
* @returns {Promise<object>} - A promise that resolves to an object containing the data as an ArrayBuffer.
|
|
249
|
+
*/
|
|
12
250
|
async getBytes(offset, length) {
|
|
13
251
|
const buffer = Buffer.alloc(length);
|
|
14
252
|
await readFileBytes(this.fd, buffer, offset);
|
|
@@ -21,10 +259,11 @@ class PMTilesFileSource {
|
|
|
21
259
|
}
|
|
22
260
|
|
|
23
261
|
/**
|
|
24
|
-
*
|
|
25
|
-
* @param fd
|
|
26
|
-
* @param buffer
|
|
27
|
-
* @param offset
|
|
262
|
+
* Reads bytes from a file descriptor into a buffer.
|
|
263
|
+
* @param {number} fd - The file descriptor.
|
|
264
|
+
* @param {Buffer} buffer - The buffer to read data into.
|
|
265
|
+
* @param {number} offset - The file offset to start reading from.
|
|
266
|
+
* @returns {Promise<void>} - A promise that resolves when the read operation completes.
|
|
28
267
|
*/
|
|
29
268
|
async function readFileBytes(fd, buffer, offset) {
|
|
30
269
|
return new Promise((resolve, reject) => {
|
|
@@ -38,86 +277,200 @@ async function readFileBytes(fd, buffer, offset) {
|
|
|
38
277
|
}
|
|
39
278
|
|
|
40
279
|
/**
|
|
41
|
-
*
|
|
42
|
-
* @param
|
|
280
|
+
* Opens a PMTiles file from local filesystem, HTTP URL, or S3 URL.
|
|
281
|
+
* @param {string} filePath - The path to the PMTiles file.
|
|
282
|
+
* @param {string} [s3Profile] - Optional AWS credential profile name.
|
|
283
|
+
* @param {boolean} [requestPayer] - Optional flag for requester pays buckets.
|
|
284
|
+
* @param {string} [s3Region] - Optional AWS region.
|
|
285
|
+
* @param {boolean} [verbose] - Whether to show verbose logging.
|
|
286
|
+
* @returns {PMTiles} - A PMTiles instance.
|
|
43
287
|
*/
|
|
44
|
-
export function openPMtiles(
|
|
288
|
+
export function openPMtiles(
|
|
289
|
+
filePath,
|
|
290
|
+
s3Profile,
|
|
291
|
+
requestPayer,
|
|
292
|
+
s3Region,
|
|
293
|
+
verbose = 0,
|
|
294
|
+
) {
|
|
45
295
|
let pmtiles = undefined;
|
|
46
296
|
|
|
47
|
-
if (
|
|
48
|
-
|
|
297
|
+
if (isS3Url(filePath)) {
|
|
298
|
+
if (verbose >= 2) {
|
|
299
|
+
console.log(`Opening PMTiles from S3: ${filePath}`);
|
|
300
|
+
}
|
|
301
|
+
const source = new S3Source(
|
|
302
|
+
filePath,
|
|
303
|
+
s3Profile,
|
|
304
|
+
requestPayer,
|
|
305
|
+
s3Region,
|
|
306
|
+
verbose,
|
|
307
|
+
);
|
|
308
|
+
pmtiles = new PMTiles(source);
|
|
309
|
+
} else if (isValidHttpUrl(filePath)) {
|
|
310
|
+
if (verbose >= 2) {
|
|
311
|
+
console.log(`Opening PMTiles from HTTP: ${filePath}`);
|
|
312
|
+
}
|
|
313
|
+
const source = new FetchSource(filePath);
|
|
49
314
|
pmtiles = new PMTiles(source);
|
|
50
315
|
} else {
|
|
51
|
-
|
|
316
|
+
if (verbose >= 2) {
|
|
317
|
+
console.log(`Opening PMTiles from local file: ${filePath}`);
|
|
318
|
+
}
|
|
319
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Opening local PMTiles file specified in config or CLI
|
|
320
|
+
const fd = fs.openSync(filePath, 'r');
|
|
52
321
|
const source = new PMTilesFileSource(fd);
|
|
53
322
|
pmtiles = new PMTiles(source);
|
|
54
323
|
}
|
|
324
|
+
|
|
55
325
|
return pmtiles;
|
|
56
326
|
}
|
|
57
327
|
|
|
58
328
|
/**
|
|
59
|
-
*
|
|
60
|
-
* @param pmtiles
|
|
329
|
+
* Retrieves metadata and header information from a PMTiles archive with retry logic for rate limiting.
|
|
330
|
+
* @param {PMTiles} pmtiles - The PMTiles instance.
|
|
331
|
+
* @param {string} inputFile - The input file path (used for error messages).
|
|
332
|
+
* @param {number} [maxRetries] - Maximum number of retry attempts for rate-limited requests.
|
|
333
|
+
* @returns {Promise<object>} - A promise that resolves to a metadata object containing format, bounds, zoom levels, and center.
|
|
334
|
+
* @throws {Error} - Throws an error if metadata cannot be retrieved after all retry attempts.
|
|
61
335
|
*/
|
|
62
|
-
export async function getPMtilesInfo(pmtiles) {
|
|
63
|
-
|
|
64
|
-
const metadata = await pmtiles.getMetadata();
|
|
65
|
-
|
|
66
|
-
//Add missing metadata from header
|
|
67
|
-
metadata['format'] = getPmtilesTileType(header.tileType).type;
|
|
68
|
-
metadata['minzoom'] = header.minZoom;
|
|
69
|
-
metadata['maxzoom'] = header.maxZoom;
|
|
70
|
-
|
|
71
|
-
if (header.minLon && header.minLat && header.maxLon && header.maxLat) {
|
|
72
|
-
metadata['bounds'] = [
|
|
73
|
-
header.minLon,
|
|
74
|
-
header.minLat,
|
|
75
|
-
header.maxLon,
|
|
76
|
-
header.maxLat,
|
|
77
|
-
];
|
|
78
|
-
} else {
|
|
79
|
-
metadata['bounds'] = [-180, -85.05112877980659, 180, 85.0511287798066];
|
|
80
|
-
}
|
|
336
|
+
export async function getPMtilesInfo(pmtiles, inputFile, maxRetries = 3) {
|
|
337
|
+
let lastError;
|
|
81
338
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
header.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
339
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
340
|
+
try {
|
|
341
|
+
const header = await pmtiles.getHeader();
|
|
342
|
+
const metadata = await pmtiles.getMetadata();
|
|
343
|
+
|
|
344
|
+
metadata['format'] = getPmtilesTileType(header.tileType).type;
|
|
345
|
+
metadata['minzoom'] = header.minZoom;
|
|
346
|
+
metadata['maxzoom'] = header.maxZoom;
|
|
347
|
+
|
|
348
|
+
// Check if bounds are defined (handles null, undefined, but allows 0)
|
|
349
|
+
const hasBounds =
|
|
350
|
+
typeof header.minLon === 'number' &&
|
|
351
|
+
typeof header.minLat === 'number' &&
|
|
352
|
+
typeof header.maxLon === 'number' &&
|
|
353
|
+
typeof header.maxLat === 'number' &&
|
|
354
|
+
!(
|
|
355
|
+
header.minLon === 0 &&
|
|
356
|
+
header.minLat === 0 &&
|
|
357
|
+
header.maxLon === 0 &&
|
|
358
|
+
header.maxLat === 0
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
if (hasBounds) {
|
|
362
|
+
metadata['bounds'] = [
|
|
363
|
+
header.minLon,
|
|
364
|
+
header.minLat,
|
|
365
|
+
header.maxLon,
|
|
366
|
+
header.maxLat,
|
|
367
|
+
];
|
|
368
|
+
} else {
|
|
369
|
+
metadata['bounds'] = [-180, -85.05112877980659, 180, 85.0511287798066];
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (header.centerZoom) {
|
|
373
|
+
metadata['center'] = [
|
|
374
|
+
header.centerLon,
|
|
375
|
+
header.centerLat,
|
|
376
|
+
header.centerZoom,
|
|
377
|
+
];
|
|
378
|
+
} else {
|
|
379
|
+
metadata['center'] = [
|
|
380
|
+
header.centerLon,
|
|
381
|
+
header.centerLat,
|
|
382
|
+
parseInt(metadata['maxzoom']) / 2,
|
|
383
|
+
];
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return metadata;
|
|
387
|
+
} catch (error) {
|
|
388
|
+
lastError = error;
|
|
389
|
+
|
|
390
|
+
if (
|
|
391
|
+
error.message &&
|
|
392
|
+
error.message.includes('429') &&
|
|
393
|
+
attempt < maxRetries - 1
|
|
394
|
+
) {
|
|
395
|
+
const delay = Math.pow(2, attempt) * 1000;
|
|
396
|
+
console.warn(
|
|
397
|
+
`Rate limited fetching metadata, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`,
|
|
398
|
+
);
|
|
399
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// If not a 429 or last retry, throw immediately
|
|
404
|
+
if (!error.message?.includes('429') || attempt === maxRetries - 1) {
|
|
405
|
+
const errorMessage = `${error.message} for file: ${inputFile}`;
|
|
406
|
+
throw new Error(errorMessage);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
94
409
|
}
|
|
95
410
|
|
|
96
|
-
|
|
411
|
+
// This should never be reached, but just in case
|
|
412
|
+
throw new Error(
|
|
413
|
+
`Failed to get PMTiles info after ${maxRetries} attempts: ${lastError?.message || 'Unknown error'}`,
|
|
414
|
+
);
|
|
97
415
|
}
|
|
98
416
|
|
|
99
417
|
/**
|
|
100
|
-
*
|
|
101
|
-
* @param pmtiles
|
|
102
|
-
* @param z
|
|
103
|
-
* @param x
|
|
104
|
-
* @param y
|
|
418
|
+
* Fetches a tile from a PMTiles archive with retry logic for rate limiting and error handling.
|
|
419
|
+
* @param {PMTiles} pmtiles - The PMTiles instance.
|
|
420
|
+
* @param {number} z - The zoom level.
|
|
421
|
+
* @param {number} x - The x coordinate of the tile.
|
|
422
|
+
* @param {number} y - The y coordinate of the tile.
|
|
423
|
+
* @param {number} [maxRetries] - Maximum number of retry attempts for rate-limited requests.
|
|
424
|
+
* @returns {Promise<object>} - A promise that resolves to an object with data (Buffer or undefined) and header (content-type).
|
|
105
425
|
*/
|
|
106
|
-
export async function getPMtilesTile(pmtiles, z, x, y) {
|
|
426
|
+
export async function getPMtilesTile(pmtiles, z, x, y, maxRetries = 3) {
|
|
107
427
|
const header = await pmtiles.getHeader();
|
|
108
428
|
const tileType = getPmtilesTileType(header.tileType);
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
429
|
+
|
|
430
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
431
|
+
try {
|
|
432
|
+
let zxyTile = await pmtiles.getZxy(z, x, y);
|
|
433
|
+
|
|
434
|
+
if (zxyTile && zxyTile.data) {
|
|
435
|
+
zxyTile = Buffer.from(zxyTile.data);
|
|
436
|
+
} else {
|
|
437
|
+
zxyTile = undefined;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return { data: zxyTile, header: tileType.header };
|
|
441
|
+
} catch (error) {
|
|
442
|
+
if (
|
|
443
|
+
error.message &&
|
|
444
|
+
error.message.includes('429') &&
|
|
445
|
+
attempt < maxRetries - 1
|
|
446
|
+
) {
|
|
447
|
+
const delay = Math.pow(2, attempt) * 1000;
|
|
448
|
+
console.warn(
|
|
449
|
+
`Rate limited for tile ${z}/${x}/${y}, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`,
|
|
450
|
+
);
|
|
451
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (error.message && error.message.includes('Bad response code:')) {
|
|
456
|
+
console.error(`HTTP error for tile ${z}/${x}/${y}: ${error.message}`);
|
|
457
|
+
return { data: undefined, header: tileType.header };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
throw error;
|
|
461
|
+
}
|
|
114
462
|
}
|
|
115
|
-
|
|
463
|
+
|
|
464
|
+
console.error(
|
|
465
|
+
`Failed to fetch tile ${z}/${x}/${y} after ${maxRetries} attempts`,
|
|
466
|
+
);
|
|
467
|
+
return { data: undefined, header: tileType.header };
|
|
116
468
|
}
|
|
117
469
|
|
|
118
470
|
/**
|
|
119
|
-
*
|
|
120
|
-
* @param typenum
|
|
471
|
+
* Maps PMTiles tile type number to tile format string and Content-Type header.
|
|
472
|
+
* @param {number} typenum - The PMTiles tile type number (0=Unknown, 1=MVT/PBF, 2=PNG, 3=JPEG, 4=WebP, 5=AVIF).
|
|
473
|
+
* @returns {object} - An object containing type (string) and header (object with Content-Type).
|
|
121
474
|
*/
|
|
122
475
|
function getPmtilesTileType(typenum) {
|
|
123
476
|
let head = {};
|
package/src/render.js
CHANGED
|
@@ -7,8 +7,9 @@ const mercator = new SphericalMercator();
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Transforms coordinates to pixels.
|
|
10
|
-
* @param {
|
|
11
|
-
* @param {number} zoom Map zoom level.
|
|
10
|
+
* @param {Array<number>} ll - Longitude/Latitude coordinate pair.
|
|
11
|
+
* @param {number} zoom - Map zoom level.
|
|
12
|
+
* @returns {Array<number>} Pixel coordinates as [x, y].
|
|
12
13
|
*/
|
|
13
14
|
const precisePx = (ll, zoom) => {
|
|
14
15
|
const px = mercator.px(ll, 20);
|
|
@@ -18,9 +19,10 @@ const precisePx = (ll, zoom) => {
|
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Draws a marker in canvas context.
|
|
21
|
-
* @param {
|
|
22
|
-
* @param {object} marker Marker object parsed by extractMarkersFromQuery.
|
|
23
|
-
* @param {number} z Map zoom level.
|
|
22
|
+
* @param {CanvasRenderingContext2D} ctx - Canvas context object.
|
|
23
|
+
* @param {object} marker - Marker object parsed by extractMarkersFromQuery.
|
|
24
|
+
* @param {number} z - Map zoom level.
|
|
25
|
+
* @returns {Promise<void>} A promise that resolves when the marker is drawn.
|
|
24
26
|
*/
|
|
25
27
|
const drawMarker = (ctx, marker, z) => {
|
|
26
28
|
return new Promise((resolve) => {
|
|
@@ -89,9 +91,10 @@ const drawMarker = (ctx, marker, z) => {
|
|
|
89
91
|
* Wraps drawing of markers into list of promises and awaits them.
|
|
90
92
|
* It's required because images are expected to load asynchronous in canvas js
|
|
91
93
|
* even when provided from a local disk.
|
|
92
|
-
* @param {
|
|
93
|
-
* @param {
|
|
94
|
-
* @param {number} z Map zoom level.
|
|
94
|
+
* @param {CanvasRenderingContext2D} ctx - Canvas context object.
|
|
95
|
+
* @param {Array<object>} markers - Marker objects parsed by extractMarkersFromQuery.
|
|
96
|
+
* @param {number} z - Map zoom level.
|
|
97
|
+
* @returns {Promise<void>} A promise that resolves when all markers are drawn.
|
|
95
98
|
*/
|
|
96
99
|
const drawMarkers = async (ctx, markers, z) => {
|
|
97
100
|
const markerPromises = [];
|
|
@@ -107,11 +110,12 @@ const drawMarkers = async (ctx, markers, z) => {
|
|
|
107
110
|
|
|
108
111
|
/**
|
|
109
112
|
* Draws a list of coordinates onto a canvas and styles the resulting path.
|
|
110
|
-
* @param {
|
|
111
|
-
* @param {
|
|
112
|
-
* @param {object} query Request query parameters.
|
|
113
|
-
* @param {string} pathQuery Path query parameter.
|
|
114
|
-
* @param {number} z Map zoom level.
|
|
113
|
+
* @param {CanvasRenderingContext2D} ctx - Canvas context object.
|
|
114
|
+
* @param {Array<Array<number>>} path - List of coordinate pairs.
|
|
115
|
+
* @param {object} query - Request query parameters.
|
|
116
|
+
* @param {string} pathQuery - Path query parameter string.
|
|
117
|
+
* @param {number} z - Map zoom level.
|
|
118
|
+
* @returns {void}
|
|
115
119
|
*/
|
|
116
120
|
const drawPath = (ctx, path, query, pathQuery, z) => {
|
|
117
121
|
const splitPaths = pathQuery.split('|');
|
|
@@ -210,6 +214,21 @@ const drawPath = (ctx, path, query, pathQuery, z) => {
|
|
|
210
214
|
ctx.stroke();
|
|
211
215
|
};
|
|
212
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Renders an overlay with paths and markers on a map tile.
|
|
219
|
+
* @param {number} z - Map zoom level.
|
|
220
|
+
* @param {number} x - Longitude of center point.
|
|
221
|
+
* @param {number} y - Latitude of center point.
|
|
222
|
+
* @param {number} bearing - Map bearing in degrees.
|
|
223
|
+
* @param {number} pitch - Map pitch in degrees.
|
|
224
|
+
* @param {number} w - Width of the canvas.
|
|
225
|
+
* @param {number} h - Height of the canvas.
|
|
226
|
+
* @param {number} scale - Scale factor for rendering.
|
|
227
|
+
* @param {Array<Array<Array<number>>>} paths - Array of path coordinate arrays.
|
|
228
|
+
* @param {Array<object>} markers - Array of marker objects.
|
|
229
|
+
* @param {object} query - Request query parameters.
|
|
230
|
+
* @returns {Promise<Buffer|null>} A promise that resolves with the canvas buffer or null if no overlay is needed.
|
|
231
|
+
*/
|
|
213
232
|
export const renderOverlay = async (
|
|
214
233
|
z,
|
|
215
234
|
x,
|
|
@@ -262,6 +281,14 @@ export const renderOverlay = async (
|
|
|
262
281
|
return canvas.toBuffer();
|
|
263
282
|
};
|
|
264
283
|
|
|
284
|
+
/**
|
|
285
|
+
* Renders a watermark on a canvas.
|
|
286
|
+
* @param {number} width - Width of the canvas.
|
|
287
|
+
* @param {number} height - Height of the canvas.
|
|
288
|
+
* @param {number} scale - Scale factor for rendering.
|
|
289
|
+
* @param {string} text - Watermark text to render.
|
|
290
|
+
* @returns {object} The canvas with the rendered attribution.
|
|
291
|
+
*/
|
|
265
292
|
export const renderWatermark = (width, height, scale, text) => {
|
|
266
293
|
const canvas = createCanvas(scale * width, scale * height);
|
|
267
294
|
const ctx = canvas.getContext('2d');
|
|
@@ -277,6 +304,14 @@ export const renderWatermark = (width, height, scale, text) => {
|
|
|
277
304
|
return canvas;
|
|
278
305
|
};
|
|
279
306
|
|
|
307
|
+
/**
|
|
308
|
+
* Renders an attribution box on a canvas.
|
|
309
|
+
* @param {number} width - Width of the canvas.
|
|
310
|
+
* @param {number} height - Height of the canvas.
|
|
311
|
+
* @param {number} scale - Scale factor for rendering.
|
|
312
|
+
* @param {string} text - Attribution text to render.
|
|
313
|
+
* @returns {object} The canvas with the rendered attribution.
|
|
314
|
+
*/
|
|
280
315
|
export const renderAttribution = (width, height, scale, text) => {
|
|
281
316
|
const canvas = createCanvas(scale * width, scale * height);
|
|
282
317
|
const ctx = canvas.getContext('2d');
|