@versatiles/container 1.4.2 → 1.5.1

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/README.md CHANGED
@@ -10,9 +10,11 @@ A client library for [VersaTiles containers](https://github.com/versatiles-org/v
10
10
 
11
11
  `npm i @versatiles/container`
12
12
 
13
- # Usage Example
13
+ # Usage Examples
14
14
 
15
- ```js
15
+ ## Basic: read a tile and write to disk
16
+
17
+ ```ts
16
18
  import { Container } from "@versatiles/container";
17
19
  import fs from "fs";
18
20
 
@@ -20,6 +22,63 @@ const container = new Container("https://example.org/planet.versatiles");
20
22
  const header = await container.getHeader();
21
23
  const tile = await container.getTileUncompressed(z, x, y);
22
24
  fs.writeFileSync("tile." + header.tileFormat, tile);
25
+
26
+ // don't forget to close file-backed containers
27
+ await container.close();
28
+ ```
29
+
30
+ ## Inspect tile metadata
31
+
32
+ VersaTiles containers can carry JSON metadata describing vector tile layers:
33
+
34
+ ```ts
35
+ import { Container } from "@versatiles/container";
36
+
37
+ const container = new Container("/path/to/layers.versatiles");
38
+
39
+ const header = await container.getHeader();
40
+ console.log("Format:", header.tileFormat); // e.g. "pbf"
41
+ console.log("Compression:", header.tileCompression);
42
+ console.log("Zoom range:", header.zoomMin, "→", header.zoomMax);
43
+ console.log("Bounding box:", header.bbox);
44
+
45
+ const metadata = await container.getMetadata();
46
+ if (metadata) {
47
+ const parsed = JSON.parse(metadata);
48
+ console.log("Vector layers:", parsed.vector_layers?.length);
49
+ }
50
+
51
+ await container.close();
52
+ ```
53
+
54
+ ## Handle missing tiles
55
+
56
+ Coordinates must lie within the zoom level's grid (`0 <= x, y < 2 ** z`), otherwise `getTile` throws a `RangeError`. For a valid coordinate that simply has no data, the container returns `null`:
57
+
58
+ ```ts
59
+ const tile = await container.getTileUncompressed(12, 100, 4000);
60
+ if (!tile) {
61
+ console.log("Tile does not exist in this container");
62
+ }
63
+ ```
64
+
65
+ ## Custom reader (e.g. S3, in-memory)
66
+
67
+ For custom storage backends, implement the `Reader` interface:
68
+
69
+ ```ts
70
+ import type { Reader } from "@versatiles/container";
71
+ import { Container } from "@versatiles/container";
72
+
73
+ const myReader: Reader = async (offset, length) => {
74
+ // e.g. fetch from an S3 bucket or read from a typed array
75
+ const buffer = Buffer.alloc(length);
76
+ // … fill buffer with data starting at offset …
77
+ return buffer;
78
+ };
79
+
80
+ const container = new Container(myReader);
81
+ const header = await container.getHeader();
23
82
  ```
24
83
 
25
84
  # API
package/dist/index.d.ts CHANGED
@@ -96,7 +96,7 @@ export declare class Container {
96
96
  * @returns A promise that resolves with the block index map.
97
97
  * @protected
98
98
  */
99
- getBlockIndex(): Promise<Map<string, Block>>;
99
+ protected getBlockIndex(): Promise<Map<string, Block>>;
100
100
  /**
101
101
  * Asynchronously retrieves the tile index for a specified block. This is an internal method used to
102
102
  * maintain a lookup for every tile within a block.
@@ -105,7 +105,17 @@ export declare class Container {
105
105
  * @returns A promise that resolves with the tile index.
106
106
  * @protected
107
107
  */
108
- getTileIndex(block: Block): Promise<TileIndex>;
108
+ protected getTileIndex(block: Block): Promise<TileIndex>;
109
+ /**
110
+ * Releases any resources held by the underlying reader, such as an open file
111
+ * descriptor when reading from a local file. Readers that hold no resources
112
+ * (e.g. the HTTP reader or a custom `Reader` function without a `close`
113
+ * method) are unaffected. After calling `close`, no further methods should
114
+ * be called on this container.
115
+ *
116
+ * @returns A promise that resolves once the reader has been closed.
117
+ */
118
+ close(): Promise<void>;
109
119
  /**
110
120
  * A protected method to read a chunk of data from the source based on the specified offset and length.
111
121
  *
package/dist/index.js CHANGED
@@ -37,25 +37,6 @@ import HttpReader from './lib/reader_http.js';
37
37
  import FileReader from './lib/reader_file.js';
38
38
  import { decompress } from './lib/decompress.js';
39
39
  const FORMATS = {
40
- c01: [
41
- 'png',
42
- 'jpg',
43
- 'webp',
44
- null,
45
- null,
46
- null,
47
- null,
48
- null,
49
- null,
50
- null,
51
- null,
52
- null,
53
- null,
54
- null,
55
- null,
56
- null,
57
- 'pbf',
58
- ],
59
40
  v01: ['png', 'jpg', 'webp', 'pbf'],
60
41
  v02: [
61
42
  'bin',
@@ -243,17 +224,31 @@ export class Container {
243
224
  * @returns A promise that resolves with the tile data as a Buffer, or null if the tile cannot be found.
244
225
  */
245
226
  async getTile(z, x, y) {
227
+ // validate coordinates: must be non-negative integers within the zoom level
228
+ if (!Number.isInteger(z) || !Number.isInteger(x) || !Number.isInteger(y)) {
229
+ throw new RangeError(`Tile coordinates must be integers, got z=${z}, x=${x}, y=${y}`);
230
+ }
231
+ // zoom is capped at 30: a tile coordinate at higher zoom would exceed 2^30
232
+ // and no longer be an exact integer under the arithmetic below
233
+ if (z < 0 || z > 30) {
234
+ throw new RangeError(`Zoom level must be between 0 and 30, got z=${z}`);
235
+ }
236
+ // x and y must lie inside the 2^z × 2^z tile grid for this zoom level
237
+ const size = 2 ** z;
238
+ if (x < 0 || x >= size || y < 0 || y >= size) {
239
+ throw new RangeError(`Tile coordinates out of range for zoom ${z}: x and y must be in [0, ${size}), got x=${x}, y=${y}`);
240
+ }
246
241
  // when y index is inverted
247
242
  if (this.#options.tms)
248
- y = Math.pow(2, z) - y - 1;
243
+ y = size - y - 1;
249
244
  // ensure block index is loaded
250
245
  const blockIndex = await this.getBlockIndex();
251
- // block xy
252
- const bx = x >> 8;
253
- const by = y >> 8;
246
+ // block xy (avoid bitwise ops: they coerce to int32 and break above zoom 30)
247
+ const bx = Math.floor(x / 256);
248
+ const by = Math.floor(y / 256);
254
249
  // tile xy (within block)
255
- const tx = x & 0xff;
256
- const ty = y & 0xff;
250
+ const tx = x % 256;
251
+ const ty = y % 256;
257
252
  // check if block containing tile is within bounds
258
253
  const blockKey = `${z},${bx},${by}`;
259
254
  const block = blockIndex.get(blockKey);
@@ -309,7 +304,6 @@ export class Container {
309
304
  // read index from buffer
310
305
  const blocks = [];
311
306
  switch (header.version) {
312
- case 'c01':
313
307
  case 'v01':
314
308
  // check block index length
315
309
  if (data.length % 29 !== 0)
@@ -364,15 +358,38 @@ export class Container {
364
358
  return block.tileIndex;
365
359
  let buffer = await this.read(block.tileIndexOffset, block.tileIndexLength);
366
360
  buffer = await decompress(buffer, 'br');
361
+ // each tile entry is 12 bytes (8-byte offset + 4-byte length); guard against
362
+ // a truncated/corrupt index before indexing into the buffer below
363
+ if (buffer.length < block.tileCount * 12) {
364
+ throw new Error('invalid tile index');
365
+ }
367
366
  const offsets = new Float64Array(block.tileCount);
368
367
  const lengths = new Float64Array(block.tileCount);
369
368
  for (let i = 0; i < block.tileCount; i++) {
370
- offsets[i] = Number(buffer.readBigUInt64BE(12 * i)) + block.blockOffset;
369
+ const offset = Number(buffer.readBigUInt64BE(12 * i)) + block.blockOffset;
370
+ // offsets are addressed as `number`; beyond 2^53 they can no longer be
371
+ // represented exactly, so surface it instead of reading corrupt data
372
+ if (!Number.isSafeInteger(offset)) {
373
+ throw new Error('invalid tile index: tile offset exceeds safe integer range (2^53)');
374
+ }
375
+ offsets[i] = offset;
371
376
  lengths[i] = buffer.readUInt32BE(12 * i + 8);
372
377
  }
373
378
  block.tileIndex = { offsets, lengths };
374
379
  return block.tileIndex;
375
380
  }
381
+ /**
382
+ * Releases any resources held by the underlying reader, such as an open file
383
+ * descriptor when reading from a local file. Readers that hold no resources
384
+ * (e.g. the HTTP reader or a custom `Reader` function without a `close`
385
+ * method) are unaffected. After calling `close`, no further methods should
386
+ * be called on this container.
387
+ *
388
+ * @returns A promise that resolves once the reader has been closed.
389
+ */
390
+ async close() {
391
+ await this.#reader.close?.();
392
+ }
376
393
  /**
377
394
  * A protected method to read a chunk of data from the source based on the specified offset and length.
378
395
  *
@@ -20,6 +20,9 @@ export type Decompressor = (data: Buffer, compression: Compression) => Promise<B
20
20
  *
21
21
  * This is useful for implementing new container readers, e.g. reading over other network protocols.
22
22
  *
23
+ * A reader may optionally expose a `close` method to release any underlying
24
+ * resources (e.g. a file descriptor). {@link Container.close} calls it if present.
25
+ *
23
26
  * @param {number} position - The byte offset at which to start reading.
24
27
  * @param {number} length - The number of bytes to read.
25
28
  * @returns {Promise<Buffer>} A promise that resolves with the data read as a Buffer.
@@ -27,7 +30,15 @@ export type Decompressor = (data: Buffer, compression: Compression) => Promise<B
27
30
  * @throws {RangeError} If the sum of `position` and `length` exceeds the size of the content (filesize).
28
31
  * @throws {Error} If there is any filesystem or network error such as the content not being accessible or readable.
29
32
  */
30
- export type Reader = (position: number, length: number) => Promise<Buffer>;
33
+ export interface Reader {
34
+ (position: number, length: number): Promise<Buffer>;
35
+ /**
36
+ * Releases any underlying resources held by the reader (e.g. an open file
37
+ * descriptor). Optional; readers that hold no resources may omit it.
38
+ * After `close` resolves, the reader must not be called again.
39
+ */
40
+ close?: () => Promise<void>;
41
+ }
31
42
  /**
32
43
  * Interface for the metadata header of a `*.Versatiles` container.
33
44
  *
@@ -92,6 +103,14 @@ export interface Block {
92
103
  /**
93
104
  * Interface for the index structure used for tiles within a block.
94
105
  *
106
+ * Byte offsets are stored as `Float64Array`, which represents integers exactly
107
+ * up to `Number.MAX_SAFE_INTEGER` (2^53 − 1, ~9 PB). This is not a limitation of
108
+ * this type specifically: the {@link Reader} contract addresses data by `number`
109
+ * position, and both `fs.read` and HTTP byte-range requests are likewise
110
+ * `Number`-based, so 2^53 is the effective addressing limit of the whole reader
111
+ * pipeline. Storing offsets as `BigInt` here would therefore not extend the
112
+ * reachable range.
113
+ *
95
114
  * @property {Float64Array} offsets - Array indicating the start byte positions of tiles within the block.
96
115
  * @property {Float64Array} lengths - Array specifying the byte lengths of the tiles. A length of 0 means the tile is not stored.
97
116
  */
@@ -37,7 +37,7 @@ export default function getFileReader(filename) {
37
37
  * the file descriptor becomes invalid due to the file being closed, the
38
38
  * promise will be rejected with an error.
39
39
  */
40
- return async function read(position, length) {
40
+ const read = async function read(position, length) {
41
41
  if (position < 0) {
42
42
  throw new RangeError(`Invalid read position: ${position}. The read position must be a non-negative integer.`);
43
43
  }
@@ -61,4 +61,14 @@ export default function getFileReader(filename) {
61
61
  });
62
62
  });
63
63
  };
64
+ /**
65
+ * Closes the underlying file descriptor. After this resolves, the reader
66
+ * must not be called again.
67
+ */
68
+ read.close = async function close() {
69
+ await new Promise((resolve, reject) => {
70
+ fs.close(fd, (err) => (err ? reject(err) : resolve()));
71
+ });
72
+ };
73
+ return read;
64
74
  }
@@ -5,6 +5,8 @@ import type { Reader } from './interfaces.js';
5
5
  * operations such as streaming or handling large data in segments.
6
6
  *
7
7
  * @param url - The URL from which data will be read.
8
+ * @param timeout - Idle timeout in milliseconds applied both while waiting for the
9
+ * response headers and between body chunks. Defaults to {@link DEFAULT_TIMEOUT}.
8
10
  * @returns A `Reader` function that asynchronously reads a specified chunk of data from the URL.
9
11
  */
10
- export default function getHTTPReader(url: string): Reader;
12
+ export default function getHTTPReader(url: string, timeout?: number): Reader;
@@ -14,9 +14,11 @@ const clients = {
14
14
  * operations such as streaming or handling large data in segments.
15
15
  *
16
16
  * @param url - The URL from which data will be read.
17
+ * @param timeout - Idle timeout in milliseconds applied both while waiting for the
18
+ * response headers and between body chunks. Defaults to {@link DEFAULT_TIMEOUT}.
17
19
  * @returns A `Reader` function that asynchronously reads a specified chunk of data from the URL.
18
20
  */
19
- export default function getHTTPReader(url) {
21
+ export default function getHTTPReader(url, timeout = DEFAULT_TIMEOUT) {
20
22
  const protocol = new URL(url).protocol.slice(0, -1);
21
23
  if (!(protocol in clients)) {
22
24
  throw new Error(`Unsupported protocol: ${protocol}`);
@@ -49,13 +51,13 @@ export default function getHTTPReader(url) {
49
51
  const watchdog = setTimeout(() => {
50
52
  req.destroy();
51
53
  reject(new Error('Request timed out'));
52
- }, DEFAULT_TIMEOUT);
54
+ }, timeout);
53
55
  const req = clients[protocol].client
54
56
  .request(url, {
55
57
  method: 'GET',
56
58
  agent: clients[protocol].agent,
57
59
  headers,
58
- timeout: DEFAULT_TIMEOUT,
60
+ timeout,
59
61
  })
60
62
  .on('response', (response) => {
61
63
  clearTimeout(watchdog);
@@ -68,38 +70,61 @@ export default function getHTTPReader(url) {
68
70
  })
69
71
  .end();
70
72
  });
71
- if (message.statusCode == null || Math.floor(message.statusCode / 100) !== 2) {
73
+ /**
74
+ * Destroys the response (draining the socket / freeing the keep-alive
75
+ * connection) and then throws the given error. Every validation failure
76
+ * below must go through this so an unread response body never leaks a
77
+ * connection back to the keep-alive agent's pool.
78
+ */
79
+ function fail(error) {
72
80
  message.destroy();
73
- throw new Error(`Server responded with status code: ${message.statusCode} `);
81
+ throw error;
82
+ }
83
+ if (message.statusCode == null || Math.floor(message.statusCode / 100) !== 2) {
84
+ fail(new Error(`Server responded with status code: ${message.statusCode} `));
74
85
  }
75
86
  const contentRange = message.headers['content-range'];
76
87
  if (contentRange == null)
77
- throw Error('The response header does not contain "content-range"');
88
+ fail(new Error('The response header does not contain "content-range"'));
78
89
  const parts = /^bytes (\d+)-(\d+)\/(\d+)/i.exec(contentRange);
79
90
  if (parts == null)
80
- throw Error('"content-range" in response header is malformed');
91
+ fail(new Error('"content-range" in response header is malformed'));
81
92
  if (position !== parseInt(parts[1], 10))
82
- throw Error(`requested position (${position}) and returned offset (${parts[1]}) must be equal`);
93
+ fail(new Error(`requested position (${position}) and returned offset (${parts[1]}) must be equal`));
83
94
  if (position + length > parseInt(parts[3], 10)) {
84
- throw new RangeError(`Read range out of bounds: The requested range ends at position ${position + length}, which exceeds the file's limit of ${parts[3]} bytes.`);
95
+ fail(new RangeError(`Read range out of bounds: The requested range ends at position ${position + length}, which exceeds the file's limit of ${parts[3]} bytes.`));
85
96
  }
86
97
  const returnedLength = parseInt(parts[2], 10) + 1 - position;
87
98
  if (length !== returnedLength) {
88
- throw new Error(`Returned length (${returnedLength}) is not requested length (${length}).`);
99
+ fail(new Error(`Returned length (${returnedLength}) is not requested length (${length}).`));
89
100
  }
90
101
  /**
91
102
  * Collects and concatenates response data chunks into a buffer.
103
+ *
104
+ * An idle watchdog guards the download: it aborts the request if no data
105
+ * chunk arrives within `timeout` ms. The timer is reset on every chunk,
106
+ * so a slow-but-progressing transfer is not killed, while a stalled socket is.
92
107
  * @type {Buffer}
93
108
  */
94
109
  const body = await new Promise((resolve, reject) => {
95
110
  const buffers = [];
111
+ const watchdog = setTimeout(onTimeout, timeout);
112
+ function onTimeout() {
113
+ message.destroy();
114
+ reject(new Error('Request timed out'));
115
+ }
96
116
  message
97
- .on('data', (chunk) => buffers.push(chunk))
117
+ .on('data', (chunk) => {
118
+ watchdog.refresh();
119
+ buffers.push(chunk);
120
+ })
98
121
  .on('error', (err) => {
122
+ clearTimeout(watchdog);
99
123
  message.destroy();
100
124
  reject(err);
101
125
  })
102
126
  .once('end', () => {
127
+ clearTimeout(watchdog);
103
128
  resolve(Buffer.concat(buffers));
104
129
  });
105
130
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@versatiles/container",
3
- "version": "1.4.2",
3
+ "version": "1.5.1",
4
4
  "description": "A client library for reading VersaTiles containers",
5
5
  "scripts": {
6
6
  "build": "rm -rf dist && tsc -p tsconfig.build.json && npm run doc-graph",
@@ -54,18 +54,21 @@
54
54
  ],
55
55
  "devDependencies": {
56
56
  "@eslint/js": "^10.0.1",
57
- "@types/node": "^25.8.0",
58
- "@typescript-eslint/eslint-plugin": "^8.59.3",
59
- "@typescript-eslint/parser": "^8.59.3",
60
- "@versatiles/release-tool": "^2.8.0",
61
- "@vitest/coverage-v8": "^4.1.6",
62
- "eslint": "^10.4.0",
57
+ "@types/node": "^26.2.0",
58
+ "@typescript-eslint/eslint-plugin": "^8.67.0",
59
+ "@typescript-eslint/parser": "^8.67.0",
60
+ "@versatiles/release-tool": "^2.9.0",
61
+ "@vitest/coverage-v8": "^4.1.11",
62
+ "eslint": "^10.8.1",
63
63
  "eslint-config-prettier": "^10.1.8",
64
- "prettier": "^3.8.3",
65
- "ts-node": "^10.9.2",
66
- "tsx": "^4.22.0",
64
+ "prettier": "^3.9.6",
65
+ "tsx": "^4.23.12",
67
66
  "typescript": "^6.0.3",
68
- "typescript-eslint": "^8.59.3",
69
- "vitest": "^4.1.6"
67
+ "typescript-eslint": "^8.67.0",
68
+ "vitest": "^4.1.11"
69
+ },
70
+ "allowScripts": {
71
+ "esbuild": true,
72
+ "fsevents": true
70
73
  }
71
74
  }