@tileflow/dev 0.1.0-alpha.19 → 0.1.0-alpha.20
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 +88 -44
- package/dist/artifacts.d.ts +42 -3
- package/dist/artifacts.js +13 -4
- package/dist/{chunk-BKHIZ7H2.js → chunk-6MGFMKG3.js} +3 -6
- package/dist/{chunk-BALG4DSG.js → chunk-CENQR5Q2.js} +2 -2
- package/dist/{chunk-OEABXOJH.js → chunk-KH7CMHT3.js} +117 -39
- package/dist/{chunk-ZRI6PNFL.js → chunk-LNCTVFUD.js} +32 -7
- package/dist/{chunk-GWUMIHL6.js → chunk-MIGBHCXX.js} +29 -15
- package/dist/{chunk-ZOYFZ3MT.js → chunk-MKPSQSMX.js} +7 -3
- package/dist/chunk-ONYQNLNA.js +1108 -0
- package/dist/{chunk-VEB43KDS.js → chunk-RSBV4L2Y.js} +7 -6
- package/dist/{chunk-O7PE6FB2.js → chunk-XTRHREXO.js} +629 -151
- package/dist/config.js +1 -1
- package/dist/fonts.js +1 -1
- package/dist/icons.js +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.js +27 -9
- package/dist/inspect.d.ts +3 -1
- package/dist/inspect.js +7 -5
- package/dist/preview.d.ts +1 -1
- package/dist/preview.js +2 -2
- package/dist/server.d.ts +7 -3
- package/dist/server.js +6 -5
- package/dist/tilesets-C0tGeenP.d.ts +94 -0
- package/dist/tilesets.d.ts +1 -0
- package/dist/tilesets.js +6 -0
- package/dist/validation.d.ts +7 -1
- package/dist/validation.js +3 -1
- package/package.json +9 -4
|
@@ -0,0 +1,1108 @@
|
|
|
1
|
+
// src/tileset-inspection.ts
|
|
2
|
+
import { VectorTile } from "@mapbox/vector-tile";
|
|
3
|
+
import { open, stat } from "fs/promises";
|
|
4
|
+
import { resolve } from "path";
|
|
5
|
+
import { gunzip } from "zlib";
|
|
6
|
+
import { PbfReader } from "pbf";
|
|
7
|
+
import {
|
|
8
|
+
bytesToHeader,
|
|
9
|
+
Compression,
|
|
10
|
+
findTile,
|
|
11
|
+
PMTiles,
|
|
12
|
+
ResolvedValueCache,
|
|
13
|
+
TileType,
|
|
14
|
+
zxyToTileId
|
|
15
|
+
} from "pmtiles";
|
|
16
|
+
import { compareCodeUnits } from "@tileflow/core";
|
|
17
|
+
var inspectionLimits = Object.freeze({
|
|
18
|
+
maxBytes: 24e6,
|
|
19
|
+
maxDistinctValuesPerField: 256,
|
|
20
|
+
maxFeatures: 2e4,
|
|
21
|
+
maxIncludedValueFields: 32,
|
|
22
|
+
maxTiles: 8,
|
|
23
|
+
maxValueLength: 256,
|
|
24
|
+
maxValuesPerField: 16
|
|
25
|
+
});
|
|
26
|
+
var maxArchiveBytes = 8e9;
|
|
27
|
+
var maximumDecodedBytes = 4 * 1024 * 1024 * 1024;
|
|
28
|
+
var minimumDecodedBytes = 64 * 1024 * 1024;
|
|
29
|
+
var maxDecompressedSectionBytes = 16 * 1024 * 1024;
|
|
30
|
+
var defaultMaximumDirectoryDepth = 32;
|
|
31
|
+
var maxDirectoryEntries = 16777216;
|
|
32
|
+
var maxParserMemoryBytes = 32 * 1024 * 1024;
|
|
33
|
+
var estimatedRangeMemoryBytes = 128;
|
|
34
|
+
var maxMetadataLayers = 256;
|
|
35
|
+
var maxMetadataFields = 512;
|
|
36
|
+
var maxTileBytes = 6e6;
|
|
37
|
+
var fieldNamePattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,191}$/u;
|
|
38
|
+
async function inspectTileflowPmtilesForLocalUse(archivePath) {
|
|
39
|
+
const path = resolve(archivePath);
|
|
40
|
+
const archiveStat = await stat(path).catch(() => null);
|
|
41
|
+
if (!archiveStat || !archiveStat.isFile() || archiveStat.size < 127 || archiveStat.size > maxArchiveBytes) {
|
|
42
|
+
throw inspectionError(
|
|
43
|
+
"TF_TILESET_ARCHIVE_INVALID",
|
|
44
|
+
"archive",
|
|
45
|
+
"Expected one bounded PMTiles file."
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
const handle = await open(path, "r").catch(() => null);
|
|
49
|
+
if (!handle) {
|
|
50
|
+
throw inspectionError(
|
|
51
|
+
"TF_TILESET_ARCHIVE_INVALID",
|
|
52
|
+
"archive",
|
|
53
|
+
"Expected one readable PMTiles file."
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const budget = new InspectionBudget(archiveStat.size);
|
|
58
|
+
const source = new NodeFilePmtilesSource(path, handle, archiveStat.size, budget);
|
|
59
|
+
const header = await readValidatedHeader(source, archiveStat.size);
|
|
60
|
+
const root = await source.getBytes(header.rootDirectoryOffset, header.rootDirectoryLength);
|
|
61
|
+
if (root.data.byteLength !== header.rootDirectoryLength) {
|
|
62
|
+
throw inspectionError(
|
|
63
|
+
"TF_TILESET_ARCHIVE_INVALID",
|
|
64
|
+
"archive",
|
|
65
|
+
"Short PMTiles root directory."
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
await createDirectoryDecompressor(budget)(root.data, header.internalCompression);
|
|
69
|
+
const metadata = await readMetadata(source, header, budget);
|
|
70
|
+
const sourceLayerMetadata = normalizeSourceLayers(metadata);
|
|
71
|
+
const warnings = [];
|
|
72
|
+
if ((header.tileType === TileType.Mlt || header.tileType === TileType.Mvt) && !sourceLayerMetadata.declared) {
|
|
73
|
+
warnings.push({
|
|
74
|
+
code: "TF_TILESET_SOURCE_LAYERS_UNDECLARED",
|
|
75
|
+
message: "PMTiles metadata does not declare vector_layers; source-layer compatibility cannot be proven.",
|
|
76
|
+
path: "contract.sourceLayers"
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return Object.freeze({
|
|
80
|
+
contract: Object.freeze({
|
|
81
|
+
sourceLayers: sourceLayerMetadata.layers,
|
|
82
|
+
sourceLayersDeclared: sourceLayerMetadata.declared,
|
|
83
|
+
tileType: tileTypeName(header.tileType)
|
|
84
|
+
}),
|
|
85
|
+
warnings: Object.freeze(warnings)
|
|
86
|
+
});
|
|
87
|
+
} finally {
|
|
88
|
+
await handle.close();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async function inspectTileflowPmtiles(archivePath, options = {}) {
|
|
92
|
+
const includedValueFields = normalizeIncludedValueFields(options.includeValues);
|
|
93
|
+
const maximumDirectoryDepth = normalizeMaximumDirectoryDepth(options.maximumDirectoryDepth);
|
|
94
|
+
if (options.sample === false && includedValueFields.length > 0) {
|
|
95
|
+
throw inspectionError(
|
|
96
|
+
"TF_TILESET_INSPECTION_OPTIONS_INVALID",
|
|
97
|
+
"includeValues",
|
|
98
|
+
"Value inspection requires bounded MVT sampling."
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
const path = resolve(archivePath);
|
|
102
|
+
const archiveStat = await stat(path).catch(() => null);
|
|
103
|
+
if (!archiveStat || !archiveStat.isFile() || archiveStat.size < 127 || archiveStat.size > maxArchiveBytes) {
|
|
104
|
+
throw inspectionError(
|
|
105
|
+
"TF_TILESET_ARCHIVE_INVALID",
|
|
106
|
+
"archive",
|
|
107
|
+
"Expected one bounded PMTiles file."
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
const handle = await open(path, "r").catch(() => null);
|
|
111
|
+
if (!handle) {
|
|
112
|
+
throw inspectionError(
|
|
113
|
+
"TF_TILESET_ARCHIVE_INVALID",
|
|
114
|
+
"archive",
|
|
115
|
+
"Expected one readable PMTiles file."
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
const budget = new InspectionBudget(archiveStat.size);
|
|
120
|
+
const source = new NodeFilePmtilesSource(path, handle, archiveStat.size, budget);
|
|
121
|
+
const header = await readValidatedHeader(source, archiveStat.size);
|
|
122
|
+
const counts = await validatePmtilesDirectoryTree(
|
|
123
|
+
source,
|
|
124
|
+
header,
|
|
125
|
+
budget,
|
|
126
|
+
maximumDirectoryDepth,
|
|
127
|
+
options.signal
|
|
128
|
+
);
|
|
129
|
+
const metadata = await readMetadata(source, header, budget);
|
|
130
|
+
const sourceLayerMetadata = normalizeSourceLayers(metadata);
|
|
131
|
+
const warnings = [];
|
|
132
|
+
if ((header.tileType === TileType.Mlt || header.tileType === TileType.Mvt) && !sourceLayerMetadata.declared) {
|
|
133
|
+
warnings.push({
|
|
134
|
+
code: "TF_TILESET_SOURCE_LAYERS_UNDECLARED",
|
|
135
|
+
message: "PMTiles metadata does not declare vector_layers; source-layer compatibility cannot be proven.",
|
|
136
|
+
path: "contract.sourceLayers"
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
const contract = Object.freeze({
|
|
140
|
+
authority: "pmtiles-header-and-tilejson-v1",
|
|
141
|
+
...metadataAttribution(metadata),
|
|
142
|
+
bounds: Object.freeze([
|
|
143
|
+
normalizeCoordinate(header.minLon),
|
|
144
|
+
normalizeCoordinate(header.minLat),
|
|
145
|
+
normalizeCoordinate(header.maxLon),
|
|
146
|
+
normalizeCoordinate(header.maxLat)
|
|
147
|
+
]),
|
|
148
|
+
center: Object.freeze([
|
|
149
|
+
normalizeCoordinate(header.centerLon),
|
|
150
|
+
normalizeCoordinate(header.centerLat),
|
|
151
|
+
header.centerZoom
|
|
152
|
+
]),
|
|
153
|
+
counts: Object.freeze({
|
|
154
|
+
addressedTiles: counts.addressedTiles,
|
|
155
|
+
declaredTileContents: header.numTileContents === 0 ? null : header.numTileContents,
|
|
156
|
+
tileEntries: counts.tileEntries
|
|
157
|
+
}),
|
|
158
|
+
maxzoom: header.maxZoom,
|
|
159
|
+
minzoom: header.minZoom,
|
|
160
|
+
sourceLayersDeclared: sourceLayerMetadata.declared,
|
|
161
|
+
sourceLayers: sourceLayerMetadata.layers,
|
|
162
|
+
tileType: tileTypeName(header.tileType)
|
|
163
|
+
});
|
|
164
|
+
const observation = options.sample === false || header.tileType !== TileType.Mvt ? void 0 : await inspectMvtSample(
|
|
165
|
+
new DeepPmtiles(
|
|
166
|
+
source,
|
|
167
|
+
new ResolvedValueCache(100, true, createDirectoryDecompressor(budget)),
|
|
168
|
+
createSectionDecompressor(budget),
|
|
169
|
+
maximumDirectoryDepth
|
|
170
|
+
),
|
|
171
|
+
header,
|
|
172
|
+
includedValueFields,
|
|
173
|
+
options.signal
|
|
174
|
+
);
|
|
175
|
+
return Object.freeze({
|
|
176
|
+
schemaVersion: 1,
|
|
177
|
+
contract,
|
|
178
|
+
...observation ? { observation } : {},
|
|
179
|
+
warnings: Object.freeze(warnings)
|
|
180
|
+
});
|
|
181
|
+
} finally {
|
|
182
|
+
await handle.close();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
var NodeFilePmtilesSource = class {
|
|
186
|
+
constructor(path, handle, size, budget) {
|
|
187
|
+
this.path = path;
|
|
188
|
+
this.handle = handle;
|
|
189
|
+
this.size = size;
|
|
190
|
+
this.budget = budget;
|
|
191
|
+
}
|
|
192
|
+
path;
|
|
193
|
+
handle;
|
|
194
|
+
size;
|
|
195
|
+
budget;
|
|
196
|
+
getKey() {
|
|
197
|
+
return this.path;
|
|
198
|
+
}
|
|
199
|
+
async getBytes(offset, length, signal) {
|
|
200
|
+
signal?.throwIfAborted();
|
|
201
|
+
if (!Number.isSafeInteger(offset) || !Number.isSafeInteger(length) || offset < 0 || length < 0 || length > maxDecompressedSectionBytes || offset >= this.size) {
|
|
202
|
+
throw inspectionError("TF_TILESET_ARCHIVE_INVALID", "archive", "Invalid PMTiles byte range.");
|
|
203
|
+
}
|
|
204
|
+
const available = Math.min(length, this.size - offset);
|
|
205
|
+
this.budget.consumeRead(available);
|
|
206
|
+
const bytes = new Uint8Array(available);
|
|
207
|
+
let read = 0;
|
|
208
|
+
while (read < available) {
|
|
209
|
+
const result = await this.handle.read(bytes, read, available - read, offset + read);
|
|
210
|
+
if (result.bytesRead === 0) {
|
|
211
|
+
throw inspectionError("TF_TILESET_ARCHIVE_INVALID", "archive", "Short PMTiles byte range.");
|
|
212
|
+
}
|
|
213
|
+
read += result.bytesRead;
|
|
214
|
+
}
|
|
215
|
+
return { data: bytes.buffer };
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
async function readValidatedHeader(source, archiveLength) {
|
|
219
|
+
try {
|
|
220
|
+
const response = await source.getBytes(0, 127);
|
|
221
|
+
if (response.data.byteLength !== 127) throw new Error("Short PMTiles header.");
|
|
222
|
+
if (new DataView(response.data).getUint16(0, true) !== 19792) {
|
|
223
|
+
throw new Error("Wrong PMTiles magic number.");
|
|
224
|
+
}
|
|
225
|
+
const header = bytesToHeader(response.data);
|
|
226
|
+
validatePmtilesHeader(header, archiveLength);
|
|
227
|
+
return header;
|
|
228
|
+
} catch (error) {
|
|
229
|
+
throw asInspectionError(
|
|
230
|
+
error,
|
|
231
|
+
"TF_TILESET_ARCHIVE_INVALID",
|
|
232
|
+
"archive",
|
|
233
|
+
"PMTiles header or directory is invalid."
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
async function readMetadata(source, header, budget) {
|
|
238
|
+
try {
|
|
239
|
+
const response = await source.getBytes(header.jsonMetadataOffset, header.jsonMetadataLength);
|
|
240
|
+
if (response.data.byteLength !== header.jsonMetadataLength) {
|
|
241
|
+
throw new Error("Short PMTiles metadata section.");
|
|
242
|
+
}
|
|
243
|
+
const decompressed = await createSectionDecompressor(budget)(
|
|
244
|
+
response.data,
|
|
245
|
+
header.internalCompression
|
|
246
|
+
);
|
|
247
|
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(decompressed));
|
|
248
|
+
} catch (error) {
|
|
249
|
+
throw asInspectionError(
|
|
250
|
+
error,
|
|
251
|
+
"TF_TILESET_METADATA_INVALID",
|
|
252
|
+
"contract",
|
|
253
|
+
"PMTiles metadata is invalid."
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function createSectionDecompressor(budget) {
|
|
258
|
+
return async (buffer, compression) => {
|
|
259
|
+
if (compression === Compression.None) {
|
|
260
|
+
if (buffer.byteLength > maxDecompressedSectionBytes) {
|
|
261
|
+
throw inspectionError(
|
|
262
|
+
"TF_TILESET_ARCHIVE_INVALID",
|
|
263
|
+
"archive",
|
|
264
|
+
"PMTiles section exceeds the inspection limit."
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
budget.consumeDecoded(buffer.byteLength);
|
|
268
|
+
return buffer;
|
|
269
|
+
}
|
|
270
|
+
if (compression !== Compression.Gzip) {
|
|
271
|
+
throw inspectionError(
|
|
272
|
+
"TF_TILESET_ARCHIVE_INVALID",
|
|
273
|
+
"archive",
|
|
274
|
+
"PMTiles compression is unsupported."
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
const remaining = budget.remainingDecoded();
|
|
278
|
+
if (remaining < 1) {
|
|
279
|
+
throw inspectionError(
|
|
280
|
+
"TF_TILESET_ARCHIVE_INVALID",
|
|
281
|
+
"archive",
|
|
282
|
+
"PMTiles decompression exceeded its operational budget."
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
const output = await new Promise((resolveOutput, rejectOutput) => {
|
|
286
|
+
gunzip(
|
|
287
|
+
new Uint8Array(buffer),
|
|
288
|
+
{ maxOutputLength: Math.min(maxDecompressedSectionBytes, remaining) },
|
|
289
|
+
(error, result) => error ? rejectOutput(error) : resolveOutput(result)
|
|
290
|
+
);
|
|
291
|
+
}).catch((error) => {
|
|
292
|
+
throw asInspectionError(
|
|
293
|
+
error,
|
|
294
|
+
"TF_TILESET_ARCHIVE_INVALID",
|
|
295
|
+
"archive",
|
|
296
|
+
"Compressed PMTiles section is invalid or too large."
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
budget.consumeDecoded(output.byteLength);
|
|
300
|
+
return output.buffer.slice(
|
|
301
|
+
output.byteOffset,
|
|
302
|
+
output.byteOffset + output.byteLength
|
|
303
|
+
);
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
function createDirectoryDecompressor(budget) {
|
|
307
|
+
return async (buffer, compression) => {
|
|
308
|
+
const output = await createSectionDecompressor(budget)(buffer, compression);
|
|
309
|
+
validateSerializedDirectory(new Uint8Array(output));
|
|
310
|
+
return output;
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
function validateSerializedDirectory(bytes) {
|
|
314
|
+
const position = { offset: 0 };
|
|
315
|
+
const entryCount = readDirectoryVarint(bytes, position);
|
|
316
|
+
if (entryCount < 1 || entryCount > maxDirectoryEntries || entryCount > Math.floor((bytes.byteLength - position.offset) / 4)) {
|
|
317
|
+
throw inspectionError(
|
|
318
|
+
"TF_TILESET_ARCHIVE_INVALID",
|
|
319
|
+
"archive",
|
|
320
|
+
"Invalid PMTiles directory entry count."
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
if (entryCount * estimatedRangeMemoryBytes > maxParserMemoryBytes) {
|
|
324
|
+
throw inspectionError(
|
|
325
|
+
"TF_TILESET_ARCHIVE_INVALID",
|
|
326
|
+
"archive",
|
|
327
|
+
"PMTiles directory exceeds the parser memory budget."
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
const tileIds = [];
|
|
331
|
+
let lastTileId = 0;
|
|
332
|
+
for (let index = 0; index < entryCount; index += 1) {
|
|
333
|
+
const delta = readDirectoryVarint(bytes, position);
|
|
334
|
+
if (index > 0 && delta === 0) {
|
|
335
|
+
throw inspectionError("TF_TILESET_ARCHIVE_INVALID", "archive", "PMTiles TileIDs repeat.");
|
|
336
|
+
}
|
|
337
|
+
const tileId = lastTileId + delta;
|
|
338
|
+
if (!Number.isSafeInteger(tileId)) {
|
|
339
|
+
throw inspectionError("TF_TILESET_ARCHIVE_INVALID", "archive", "PMTiles TileID overflow.");
|
|
340
|
+
}
|
|
341
|
+
tileIds.push(tileId);
|
|
342
|
+
lastTileId = tileId;
|
|
343
|
+
}
|
|
344
|
+
const runLengths = Array.from({ length: entryCount }, () => readDirectoryVarint(bytes, position));
|
|
345
|
+
const lengths = Array.from({ length: entryCount }, () => readDirectoryVarint(bytes, position));
|
|
346
|
+
const entries = [];
|
|
347
|
+
for (let index = 0; index < entryCount; index += 1) {
|
|
348
|
+
const encodedOffset = readDirectoryVarint(bytes, position);
|
|
349
|
+
const length = lengths[index];
|
|
350
|
+
if (length < 1 || index === 0 && encodedOffset === 0) {
|
|
351
|
+
throw inspectionError(
|
|
352
|
+
"TF_TILESET_ARCHIVE_INVALID",
|
|
353
|
+
"archive",
|
|
354
|
+
"Invalid PMTiles directory range."
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
const offset = encodedOffset === 0 ? entries[index - 1].offset + entries[index - 1].length : encodedOffset - 1;
|
|
358
|
+
const runLength = runLengths[index];
|
|
359
|
+
const tileId = tileIds[index];
|
|
360
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(tileId + Math.max(0, runLength - 1))) {
|
|
361
|
+
throw inspectionError(
|
|
362
|
+
"TF_TILESET_ARCHIVE_INVALID",
|
|
363
|
+
"archive",
|
|
364
|
+
"Invalid PMTiles directory range."
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
entries.push({ length, offset, runLength, tileId });
|
|
368
|
+
}
|
|
369
|
+
if (position.offset !== bytes.byteLength) {
|
|
370
|
+
throw inspectionError(
|
|
371
|
+
"TF_TILESET_ARCHIVE_INVALID",
|
|
372
|
+
"archive",
|
|
373
|
+
"Invalid trailing PMTiles directory data."
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
return entries;
|
|
377
|
+
}
|
|
378
|
+
var InspectionBudget = class {
|
|
379
|
+
#limit;
|
|
380
|
+
#decoded = 0;
|
|
381
|
+
#read = 0;
|
|
382
|
+
constructor(archiveBytes) {
|
|
383
|
+
this.#limit = Math.min(maximumDecodedBytes, Math.max(minimumDecodedBytes, archiveBytes));
|
|
384
|
+
}
|
|
385
|
+
consumeDecoded(bytes) {
|
|
386
|
+
if (!Number.isSafeInteger(bytes) || bytes < 0 || this.#decoded + bytes > this.#limit) {
|
|
387
|
+
throw inspectionError(
|
|
388
|
+
"TF_TILESET_ARCHIVE_INVALID",
|
|
389
|
+
"archive",
|
|
390
|
+
"PMTiles decompression exceeded its operational budget."
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
this.#decoded += bytes;
|
|
394
|
+
}
|
|
395
|
+
consumeRead(bytes) {
|
|
396
|
+
if (!Number.isSafeInteger(bytes) || bytes < 0 || this.#read + bytes > this.#limit) {
|
|
397
|
+
throw inspectionError(
|
|
398
|
+
"TF_TILESET_ARCHIVE_INVALID",
|
|
399
|
+
"archive",
|
|
400
|
+
"PMTiles reads exceeded their operational budget."
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
this.#read += bytes;
|
|
404
|
+
}
|
|
405
|
+
remainingDecoded() {
|
|
406
|
+
return Math.max(0, this.#limit - this.#decoded);
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
async function validatePmtilesDirectoryTree(source, header, budget, maximumDirectoryDepth, signal) {
|
|
410
|
+
const pending = [
|
|
411
|
+
{
|
|
412
|
+
depth: 0,
|
|
413
|
+
key: `${header.rootDirectoryOffset}:${header.rootDirectoryLength}`,
|
|
414
|
+
kind: "directory",
|
|
415
|
+
length: header.rootDirectoryLength,
|
|
416
|
+
maximumTileId: maximumTileIdForZoom(header.maxZoom),
|
|
417
|
+
minimumTileId: 0,
|
|
418
|
+
offset: header.rootDirectoryOffset
|
|
419
|
+
}
|
|
420
|
+
];
|
|
421
|
+
const countedDirectories = /* @__PURE__ */ new Set();
|
|
422
|
+
const processedContexts = /* @__PURE__ */ new Set();
|
|
423
|
+
const clusteredContents = /* @__PURE__ */ new Set();
|
|
424
|
+
const decompressDirectory = createSectionDecompressor(budget);
|
|
425
|
+
let clusteredEnd = null;
|
|
426
|
+
let addressedTiles = 0;
|
|
427
|
+
let totalEntries = 0;
|
|
428
|
+
let tileEntries = 0;
|
|
429
|
+
while (pending.length > 0) {
|
|
430
|
+
signal?.throwIfAborted();
|
|
431
|
+
const item = pending.pop();
|
|
432
|
+
if (item.kind === "clustered-content") {
|
|
433
|
+
const key = `${item.offset}:${item.length}`;
|
|
434
|
+
if (clusteredContents.has(key)) continue;
|
|
435
|
+
if (clusteredEnd === null ? item.offset !== 0 : item.offset !== clusteredEnd) {
|
|
436
|
+
throw inspectionError(
|
|
437
|
+
"TF_TILESET_ARCHIVE_INVALID",
|
|
438
|
+
"archive",
|
|
439
|
+
"PMTiles clustered contents are not contiguous in TileID order."
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
clusteredContents.add(key);
|
|
443
|
+
clusteredEnd = item.offset + item.length;
|
|
444
|
+
assertParserMemory(
|
|
445
|
+
processedContexts.size + countedDirectories.size + clusteredContents.size + pending.length
|
|
446
|
+
);
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
const current = item;
|
|
450
|
+
if (current.depth > maximumDirectoryDepth || hasDirectoryAncestor(current.parent, current.key)) {
|
|
451
|
+
throw inspectionError(
|
|
452
|
+
"TF_TILESET_ARCHIVE_INVALID",
|
|
453
|
+
"archive",
|
|
454
|
+
"PMTiles directory depth or cycle is invalid."
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
const contextKey = `${current.key}:${current.minimumTileId}:${current.maximumTileId}`;
|
|
458
|
+
if (processedContexts.has(contextKey)) continue;
|
|
459
|
+
processedContexts.add(contextKey);
|
|
460
|
+
const firstPhysicalVisit = !countedDirectories.has(current.key);
|
|
461
|
+
countedDirectories.add(current.key);
|
|
462
|
+
assertParserMemory(
|
|
463
|
+
processedContexts.size + countedDirectories.size + clusteredContents.size + pending.length
|
|
464
|
+
);
|
|
465
|
+
const response = await source.getBytes(current.offset, current.length, signal);
|
|
466
|
+
if (response.data.byteLength !== current.length) {
|
|
467
|
+
throw inspectionError("TF_TILESET_ARCHIVE_INVALID", "archive", "Short PMTiles directory.");
|
|
468
|
+
}
|
|
469
|
+
const decoded = await decompressDirectory(response.data, header.internalCompression);
|
|
470
|
+
const entries = validateSerializedDirectory(new Uint8Array(decoded));
|
|
471
|
+
if (current.depth > 0 && entries[0]?.tileId !== current.minimumTileId) {
|
|
472
|
+
throw inspectionError(
|
|
473
|
+
"TF_TILESET_ARCHIVE_INVALID",
|
|
474
|
+
"archive",
|
|
475
|
+
"PMTiles child directory does not begin at its parent TileID."
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
if (firstPhysicalVisit) totalEntries += entries.length;
|
|
479
|
+
if (!Number.isSafeInteger(totalEntries) || totalEntries > maxDirectoryEntries) {
|
|
480
|
+
throw inspectionError(
|
|
481
|
+
"TF_TILESET_ARCHIVE_INVALID",
|
|
482
|
+
"archive",
|
|
483
|
+
"PMTiles directories exceed the entry budget."
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
const nextItems = [];
|
|
487
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
488
|
+
const entry = entries[index];
|
|
489
|
+
const nextTileId = entries[index + 1]?.tileId;
|
|
490
|
+
if (entry.tileId < current.minimumTileId || entry.tileId > current.maximumTileId || nextTileId !== void 0 && nextTileId > current.maximumTileId + 1) {
|
|
491
|
+
throw inspectionError(
|
|
492
|
+
"TF_TILESET_ARCHIVE_INVALID",
|
|
493
|
+
"archive",
|
|
494
|
+
"PMTiles directory TileID lies outside its parent interval."
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
if (entry.runLength === 0) {
|
|
498
|
+
checkedRelativeRange(entry.offset, entry.length, header.leafDirectoryLength ?? 0);
|
|
499
|
+
const offset = header.leafDirectoryOffset + entry.offset;
|
|
500
|
+
const key = `${offset}:${entry.length}`;
|
|
501
|
+
if (hasDirectoryAncestor(current, key)) {
|
|
502
|
+
throw inspectionError(
|
|
503
|
+
"TF_TILESET_ARCHIVE_INVALID",
|
|
504
|
+
"archive",
|
|
505
|
+
"PMTiles directory cycle is invalid."
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
nextItems.push({
|
|
509
|
+
depth: current.depth + 1,
|
|
510
|
+
key,
|
|
511
|
+
kind: "directory",
|
|
512
|
+
length: entry.length,
|
|
513
|
+
maximumTileId: nextTileId === void 0 ? current.maximumTileId : nextTileId - 1,
|
|
514
|
+
minimumTileId: entry.tileId,
|
|
515
|
+
offset,
|
|
516
|
+
parent: current
|
|
517
|
+
});
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
checkedRelativeRange(entry.offset, entry.length, header.tileDataLength ?? 0);
|
|
521
|
+
const runEnd = entry.tileId + entry.runLength - 1;
|
|
522
|
+
if (!Number.isSafeInteger(runEnd) || runEnd > current.maximumTileId || nextTileId !== void 0 && runEnd >= nextTileId) {
|
|
523
|
+
throw inspectionError(
|
|
524
|
+
"TF_TILESET_ARCHIVE_INVALID",
|
|
525
|
+
"archive",
|
|
526
|
+
"PMTiles tile run overlaps or escapes its directory interval."
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
if (entry.length > maxDecompressedSectionBytes) {
|
|
530
|
+
throw inspectionError(
|
|
531
|
+
"TF_TILESET_ARCHIVE_INVALID",
|
|
532
|
+
"archive",
|
|
533
|
+
"PMTiles tile range exceeds the delivery budget."
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
if (firstPhysicalVisit) {
|
|
537
|
+
addressedTiles += entry.runLength;
|
|
538
|
+
tileEntries += 1;
|
|
539
|
+
if (header.clustered) {
|
|
540
|
+
nextItems.push({
|
|
541
|
+
kind: "clustered-content",
|
|
542
|
+
length: entry.length,
|
|
543
|
+
offset: entry.offset
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (!Number.isSafeInteger(addressedTiles) || !Number.isSafeInteger(tileEntries)) {
|
|
548
|
+
throw inspectionError("TF_TILESET_ARCHIVE_INVALID", "archive", "PMTiles counts overflow.");
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
for (let index = nextItems.length - 1; index >= 0; index -= 1) {
|
|
552
|
+
pending.push(nextItems[index]);
|
|
553
|
+
}
|
|
554
|
+
assertParserMemory(
|
|
555
|
+
processedContexts.size + countedDirectories.size + clusteredContents.size + pending.length
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
if (header.numAddressedTiles !== 0 && addressedTiles !== header.numAddressedTiles || header.numTileEntries !== 0 && tileEntries !== header.numTileEntries) {
|
|
559
|
+
throw inspectionError(
|
|
560
|
+
"TF_TILESET_ARCHIVE_INVALID",
|
|
561
|
+
"archive",
|
|
562
|
+
"PMTiles header counters do not match its directory tree."
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
return { addressedTiles, tileEntries };
|
|
566
|
+
}
|
|
567
|
+
function maximumTileIdForZoom(maxZoom) {
|
|
568
|
+
const value = (4 ** (maxZoom + 1) - 1) / 3 - 1;
|
|
569
|
+
if (!Number.isSafeInteger(value)) {
|
|
570
|
+
throw inspectionError(
|
|
571
|
+
"TF_TILESET_ARCHIVE_INVALID",
|
|
572
|
+
"archive",
|
|
573
|
+
"PMTiles TileID range is unsafe."
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
return value;
|
|
577
|
+
}
|
|
578
|
+
function checkedRelativeRange(offset, length, sectionLength) {
|
|
579
|
+
if (!Number.isSafeInteger(sectionLength) || sectionLength < 0 || offset < 0 || length < 1 || offset > sectionLength - length) {
|
|
580
|
+
throw inspectionError(
|
|
581
|
+
"TF_TILESET_ARCHIVE_INVALID",
|
|
582
|
+
"archive",
|
|
583
|
+
"PMTiles directory range lies outside its section."
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
function hasDirectoryAncestor(candidate, key) {
|
|
588
|
+
for (let current = candidate; current; current = current.parent) {
|
|
589
|
+
if (current.key === key) return true;
|
|
590
|
+
}
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
function assertParserMemory(rangeCount) {
|
|
594
|
+
if (rangeCount * estimatedRangeMemoryBytes > maxParserMemoryBytes) {
|
|
595
|
+
throw inspectionError(
|
|
596
|
+
"TF_TILESET_ARCHIVE_INVALID",
|
|
597
|
+
"archive",
|
|
598
|
+
"PMTiles traversal exceeded its parser memory budget."
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
var DeepPmtiles = class extends PMTiles {
|
|
603
|
+
#maximumDirectoryDepth;
|
|
604
|
+
constructor(source, cache, decompress, maximumDirectoryDepth) {
|
|
605
|
+
super(source, cache, decompress);
|
|
606
|
+
this.#maximumDirectoryDepth = maximumDirectoryDepth;
|
|
607
|
+
}
|
|
608
|
+
async getZxyAttempt(z, x, y, signal) {
|
|
609
|
+
const tileId = zxyToTileId(z, x, y);
|
|
610
|
+
const header = await this.cache.getHeader(this.source);
|
|
611
|
+
signal?.throwIfAborted();
|
|
612
|
+
if (z < header.minZoom || z > header.maxZoom) return void 0;
|
|
613
|
+
let offset = header.rootDirectoryOffset;
|
|
614
|
+
let length = header.rootDirectoryLength;
|
|
615
|
+
const ancestors = /* @__PURE__ */ new Set();
|
|
616
|
+
for (let depth = 0; depth <= this.#maximumDirectoryDepth; depth += 1) {
|
|
617
|
+
const key = `${offset}:${length}`;
|
|
618
|
+
if (ancestors.has(key)) throw new Error("PMTiles directory cycle is invalid.");
|
|
619
|
+
ancestors.add(key);
|
|
620
|
+
const directory = await this.cache.getDirectory(this.source, offset, length, header, signal);
|
|
621
|
+
const entry = findTile(directory, tileId);
|
|
622
|
+
if (!entry) return void 0;
|
|
623
|
+
if (entry.runLength > 0) {
|
|
624
|
+
checkedRelativeRange(entry.offset, entry.length, header.tileDataLength ?? 0);
|
|
625
|
+
const response = await this.source.getBytes(
|
|
626
|
+
header.tileDataOffset + entry.offset,
|
|
627
|
+
entry.length,
|
|
628
|
+
signal,
|
|
629
|
+
header.etag
|
|
630
|
+
);
|
|
631
|
+
return {
|
|
632
|
+
data: await this.decompress(response.data, header.tileCompression),
|
|
633
|
+
cacheControl: response.cacheControl,
|
|
634
|
+
expires: response.expires
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
checkedRelativeRange(entry.offset, entry.length, header.leafDirectoryLength ?? 0);
|
|
638
|
+
offset = header.leafDirectoryOffset + entry.offset;
|
|
639
|
+
length = entry.length;
|
|
640
|
+
}
|
|
641
|
+
throw new Error("PMTiles directory depth exceeds the operational limit.");
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
function normalizeMaximumDirectoryDepth(value) {
|
|
645
|
+
const depth = value ?? defaultMaximumDirectoryDepth;
|
|
646
|
+
if (!Number.isInteger(depth) || depth < 1 || depth > 64) {
|
|
647
|
+
throw inspectionError(
|
|
648
|
+
"TF_TILESET_INSPECTION_OPTIONS_INVALID",
|
|
649
|
+
"maximumDirectoryDepth",
|
|
650
|
+
"PMTiles maximumDirectoryDepth must be an integer from 1 to 64."
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
return depth;
|
|
654
|
+
}
|
|
655
|
+
function readDirectoryVarint(bytes, position) {
|
|
656
|
+
let result = 0;
|
|
657
|
+
let multiplier = 1;
|
|
658
|
+
for (let index = 0; index < 10; index += 1) {
|
|
659
|
+
const byte = bytes[position.offset];
|
|
660
|
+
if (byte === void 0) {
|
|
661
|
+
throw inspectionError(
|
|
662
|
+
"TF_TILESET_ARCHIVE_INVALID",
|
|
663
|
+
"archive",
|
|
664
|
+
"Unexpected end of PMTiles directory."
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
position.offset += 1;
|
|
668
|
+
result += (byte & 127) * multiplier;
|
|
669
|
+
if (!Number.isSafeInteger(result)) {
|
|
670
|
+
throw inspectionError(
|
|
671
|
+
"TF_TILESET_ARCHIVE_INVALID",
|
|
672
|
+
"archive",
|
|
673
|
+
"PMTiles directory integer is too large."
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
if (byte < 128) return result;
|
|
677
|
+
multiplier *= 128;
|
|
678
|
+
}
|
|
679
|
+
throw inspectionError(
|
|
680
|
+
"TF_TILESET_ARCHIVE_INVALID",
|
|
681
|
+
"archive",
|
|
682
|
+
"PMTiles directory varint is too long."
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
function validatePmtilesHeader(header, archiveLength) {
|
|
686
|
+
if (header.specVersion !== 3 || !isSupportedCompression(header.internalCompression) || !isSupportedCompression(header.tileCompression) || header.tileType === TileType.Unknown || !isSafeCount(header.numAddressedTiles) || !isSafeCount(header.numTileEntries) || !isSafeCount(header.numTileContents)) {
|
|
687
|
+
throw inspectionError("TF_TILESET_ARCHIVE_INVALID", "archive", "Invalid PMTiles header.");
|
|
688
|
+
}
|
|
689
|
+
if (!Number.isInteger(header.minZoom) || !Number.isInteger(header.maxZoom) || header.minZoom < 0 || header.maxZoom < header.minZoom || header.maxZoom > 26 || !Number.isInteger(header.centerZoom) || header.centerZoom < header.minZoom || header.centerZoom > header.maxZoom) {
|
|
690
|
+
throw inspectionError("TF_TILESET_ARCHIVE_INVALID", "archive", "Invalid PMTiles zoom range.");
|
|
691
|
+
}
|
|
692
|
+
if (!isCoordinate(header.minLon, -180, 180) || !isCoordinate(header.maxLon, -180, 180) || !isCoordinate(header.centerLon, -180, 180) || !isCoordinate(header.minLat, -90, 90) || !isCoordinate(header.maxLat, -90, 90) || !isCoordinate(header.centerLat, -90, 90) || header.minLon > header.maxLon || header.minLat > header.maxLat) {
|
|
693
|
+
throw inspectionError("TF_TILESET_ARCHIVE_INVALID", "archive", "Invalid PMTiles bounds.");
|
|
694
|
+
}
|
|
695
|
+
const rootEnd = checkedRangeEnd(
|
|
696
|
+
header.rootDirectoryOffset,
|
|
697
|
+
header.rootDirectoryLength,
|
|
698
|
+
archiveLength
|
|
699
|
+
);
|
|
700
|
+
const metadataEnd = checkedRangeEnd(
|
|
701
|
+
header.jsonMetadataOffset,
|
|
702
|
+
header.jsonMetadataLength,
|
|
703
|
+
archiveLength
|
|
704
|
+
);
|
|
705
|
+
const leafEnd = checkedRangeEnd(
|
|
706
|
+
header.leafDirectoryOffset,
|
|
707
|
+
header.leafDirectoryLength ?? 0,
|
|
708
|
+
archiveLength
|
|
709
|
+
);
|
|
710
|
+
const tileEnd = checkedRangeEnd(header.tileDataOffset, header.tileDataLength ?? 0, archiveLength);
|
|
711
|
+
if (header.rootDirectoryOffset < 127 || rootEnd > Math.min(16384, archiveLength)) {
|
|
712
|
+
throw inspectionError("TF_TILESET_ARCHIVE_INVALID", "archive", "Invalid PMTiles layout.");
|
|
713
|
+
}
|
|
714
|
+
const ranges = [
|
|
715
|
+
{ end: 127, start: 0 },
|
|
716
|
+
{ end: rootEnd, start: header.rootDirectoryOffset },
|
|
717
|
+
{ end: metadataEnd, start: header.jsonMetadataOffset },
|
|
718
|
+
{ end: leafEnd, start: header.leafDirectoryOffset },
|
|
719
|
+
{ end: tileEnd, start: header.tileDataOffset }
|
|
720
|
+
].filter(({ end, start }) => end > start).sort((left, right) => left.start - right.start || left.end - right.end);
|
|
721
|
+
for (let index = 1; index < ranges.length; index += 1) {
|
|
722
|
+
if (ranges[index].start < ranges[index - 1].end) {
|
|
723
|
+
throw inspectionError("TF_TILESET_ARCHIVE_INVALID", "archive", "PMTiles sections overlap.");
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
function checkedRangeEnd(offset, length, archiveLength) {
|
|
728
|
+
if (!Number.isSafeInteger(offset) || !Number.isSafeInteger(length) || offset < 0 || length < 0 || offset > archiveLength - length) {
|
|
729
|
+
throw inspectionError(
|
|
730
|
+
"TF_TILESET_ARCHIVE_INVALID",
|
|
731
|
+
"archive",
|
|
732
|
+
"PMTiles section lies outside the archive."
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
return offset + length;
|
|
736
|
+
}
|
|
737
|
+
function isSafeCount(value) {
|
|
738
|
+
return Number.isSafeInteger(value) && value >= 0;
|
|
739
|
+
}
|
|
740
|
+
function isCoordinate(value, minimum, maximum) {
|
|
741
|
+
return Number.isFinite(value) && value >= minimum && value <= maximum;
|
|
742
|
+
}
|
|
743
|
+
function isSupportedCompression(value) {
|
|
744
|
+
return value === Compression.None || value === Compression.Gzip;
|
|
745
|
+
}
|
|
746
|
+
function normalizeSourceLayers(metadata) {
|
|
747
|
+
const record = asRecord(metadata);
|
|
748
|
+
let raw = record?.vector_layers;
|
|
749
|
+
if (typeof raw === "string") {
|
|
750
|
+
try {
|
|
751
|
+
raw = JSON.parse(raw);
|
|
752
|
+
} catch {
|
|
753
|
+
throw inspectionError(
|
|
754
|
+
"TF_TILESET_METADATA_INVALID",
|
|
755
|
+
"contract.sourceLayers",
|
|
756
|
+
"PMTiles vector_layers metadata is invalid JSON."
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
if (raw === void 0) return Object.freeze({ declared: false, layers: Object.freeze([]) });
|
|
761
|
+
if (!Array.isArray(raw) || raw.length > maxMetadataLayers) {
|
|
762
|
+
throw inspectionError(
|
|
763
|
+
"TF_TILESET_METADATA_INVALID",
|
|
764
|
+
"contract.sourceLayers",
|
|
765
|
+
`PMTiles vector_layers must contain at most ${maxMetadataLayers} layers.`
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
const seen = /* @__PURE__ */ new Set();
|
|
769
|
+
return Object.freeze({
|
|
770
|
+
declared: true,
|
|
771
|
+
layers: Object.freeze(
|
|
772
|
+
raw.map((candidate, index) => {
|
|
773
|
+
const layer = asRecord(candidate);
|
|
774
|
+
if (!layer) {
|
|
775
|
+
throw inspectionError(
|
|
776
|
+
"TF_TILESET_METADATA_INVALID",
|
|
777
|
+
`contract.sourceLayers.${index}`,
|
|
778
|
+
"PMTiles source-layer metadata must be an object."
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
const id = layer.id;
|
|
782
|
+
if (typeof id !== "string" || !fieldNamePattern.test(id)) {
|
|
783
|
+
throw inspectionError(
|
|
784
|
+
"TF_TILESET_METADATA_INVALID",
|
|
785
|
+
`contract.sourceLayers.${index}.id`,
|
|
786
|
+
"PMTiles source-layer ID is invalid."
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
if (seen.has(id)) {
|
|
790
|
+
throw inspectionError(
|
|
791
|
+
"TF_TILESET_METADATA_INVALID",
|
|
792
|
+
`contract.sourceLayers.${index}.id`,
|
|
793
|
+
`Duplicate PMTiles source-layer ID: ${id}.`
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
seen.add(id);
|
|
797
|
+
const fieldMetadata = normalizeMetadataFields(layer.fields, index);
|
|
798
|
+
const minzoom = optionalZoom(layer.minzoom, `contract.sourceLayers.${index}.minzoom`);
|
|
799
|
+
const maxzoom = optionalZoom(layer.maxzoom, `contract.sourceLayers.${index}.maxzoom`);
|
|
800
|
+
if (minzoom !== void 0 && maxzoom !== void 0 && minzoom > maxzoom) {
|
|
801
|
+
throw inspectionError(
|
|
802
|
+
"TF_TILESET_METADATA_INVALID",
|
|
803
|
+
`contract.sourceLayers.${index}.maxzoom`,
|
|
804
|
+
"PMTiles source-layer minzoom exceeds maxzoom."
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
return Object.freeze({
|
|
808
|
+
fields: fieldMetadata.fields,
|
|
809
|
+
fieldsDeclared: fieldMetadata.declared,
|
|
810
|
+
id,
|
|
811
|
+
...maxzoom === void 0 ? {} : { maxzoom },
|
|
812
|
+
...minzoom === void 0 ? {} : { minzoom }
|
|
813
|
+
});
|
|
814
|
+
}).sort((left, right) => compareCodeUnits(left.id, right.id))
|
|
815
|
+
)
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
function normalizeMetadataFields(value, layerIndex) {
|
|
819
|
+
if (value === void 0) return Object.freeze({ declared: false, fields: Object.freeze([]) });
|
|
820
|
+
const fields = asRecord(value);
|
|
821
|
+
if (!fields || Object.keys(fields).length > maxMetadataFields) {
|
|
822
|
+
throw inspectionError(
|
|
823
|
+
"TF_TILESET_METADATA_INVALID",
|
|
824
|
+
`contract.sourceLayers.${layerIndex}.fields`,
|
|
825
|
+
`PMTiles source-layer fields must contain at most ${maxMetadataFields} entries.`
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
return Object.freeze({
|
|
829
|
+
declared: true,
|
|
830
|
+
fields: Object.freeze(
|
|
831
|
+
Object.entries(fields).sort(([left], [right]) => compareCodeUnits(left, right)).map(([name, type], fieldIndex) => {
|
|
832
|
+
if (!fieldNamePattern.test(name)) {
|
|
833
|
+
throw inspectionError(
|
|
834
|
+
"TF_TILESET_METADATA_INVALID",
|
|
835
|
+
`contract.sourceLayers.${layerIndex}.fields.${fieldIndex}`,
|
|
836
|
+
`Invalid PMTiles field name: ${name}.`
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
const normalized = normalizeFieldType(type);
|
|
840
|
+
return Object.freeze({ name, ...normalized ? { type: normalized } : {} });
|
|
841
|
+
})
|
|
842
|
+
)
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
async function inspectMvtSample(archive, header, includedValueFields, signal) {
|
|
846
|
+
const coordinates = sampleCoordinates(header).slice(0, inspectionLimits.maxTiles);
|
|
847
|
+
const includedValueFieldSet = new Set(includedValueFields);
|
|
848
|
+
const layers = /* @__PURE__ */ new Map();
|
|
849
|
+
const tiles = [];
|
|
850
|
+
let totalBytes = 0;
|
|
851
|
+
let featuresRead = 0;
|
|
852
|
+
let truncated = false;
|
|
853
|
+
for (const coordinate of coordinates) {
|
|
854
|
+
signal?.throwIfAborted();
|
|
855
|
+
const response = await archive.getZxy(coordinate.z, coordinate.x, coordinate.y, signal);
|
|
856
|
+
if (!response) continue;
|
|
857
|
+
if (response.data.byteLength > maxTileBytes) {
|
|
858
|
+
throw inspectionError(
|
|
859
|
+
"TF_TILESET_SAMPLE_LIMIT_EXCEEDED",
|
|
860
|
+
"observation.limits.maxBytes",
|
|
861
|
+
`A sampled vector tile exceeds ${maxTileBytes} bytes.`
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
totalBytes += response.data.byteLength;
|
|
865
|
+
if (totalBytes > inspectionLimits.maxBytes) {
|
|
866
|
+
truncated = true;
|
|
867
|
+
break;
|
|
868
|
+
}
|
|
869
|
+
tiles.push(coordinate);
|
|
870
|
+
const reader = new PbfReader(response.data);
|
|
871
|
+
const tile = new VectorTile(reader);
|
|
872
|
+
for (const layerId of Object.keys(tile.layers).sort(compareCodeUnits)) {
|
|
873
|
+
const vectorLayer = tile.layers[layerId];
|
|
874
|
+
const observed = layers.get(layerId) ?? {
|
|
875
|
+
featuresRead: 0,
|
|
876
|
+
fields: /* @__PURE__ */ new Map(),
|
|
877
|
+
geometryTypes: /* @__PURE__ */ new Set()
|
|
878
|
+
};
|
|
879
|
+
layers.set(layerId, observed);
|
|
880
|
+
for (let index = 0; index < vectorLayer.length; index += 1) {
|
|
881
|
+
if (featuresRead >= inspectionLimits.maxFeatures) {
|
|
882
|
+
truncated = true;
|
|
883
|
+
break;
|
|
884
|
+
}
|
|
885
|
+
const feature = vectorLayer.feature(index);
|
|
886
|
+
featuresRead += 1;
|
|
887
|
+
observed.featuresRead += 1;
|
|
888
|
+
observed.geometryTypes.add(geometryType(feature.type));
|
|
889
|
+
for (const [name, value] of Object.entries(feature.properties)) {
|
|
890
|
+
const type = observedType(value);
|
|
891
|
+
if (!type) continue;
|
|
892
|
+
const observedValue = value;
|
|
893
|
+
const field = observed.fields.get(name) ?? {
|
|
894
|
+
distinctValues: /* @__PURE__ */ new Map(),
|
|
895
|
+
distinctValuesTruncated: false,
|
|
896
|
+
featuresPresent: 0,
|
|
897
|
+
outputValuesTruncated: false,
|
|
898
|
+
types: /* @__PURE__ */ new Set()
|
|
899
|
+
};
|
|
900
|
+
field.featuresPresent += 1;
|
|
901
|
+
field.types.add(type);
|
|
902
|
+
if (typeof observedValue === "number") {
|
|
903
|
+
field.numericMin = Math.min(field.numericMin ?? observedValue, observedValue);
|
|
904
|
+
field.numericMax = Math.max(field.numericMax ?? observedValue, observedValue);
|
|
905
|
+
}
|
|
906
|
+
const key = observedValueKey(observedValue);
|
|
907
|
+
const distinct = field.distinctValues.get(key);
|
|
908
|
+
if (distinct) {
|
|
909
|
+
distinct.count += 1;
|
|
910
|
+
} else if (field.distinctValues.size < inspectionLimits.maxDistinctValuesPerField) {
|
|
911
|
+
const includeValue = includedValueFieldSet.has(name);
|
|
912
|
+
const outputSafe = typeof observedValue !== "string" || observedValue.length <= inspectionLimits.maxValueLength;
|
|
913
|
+
field.distinctValues.set(key, {
|
|
914
|
+
count: 1,
|
|
915
|
+
...includeValue && outputSafe ? { value: observedValue } : {}
|
|
916
|
+
});
|
|
917
|
+
if (includeValue && !outputSafe) field.outputValuesTruncated = true;
|
|
918
|
+
} else {
|
|
919
|
+
field.distinctValuesTruncated = true;
|
|
920
|
+
if (includedValueFieldSet.has(name)) field.outputValuesTruncated = true;
|
|
921
|
+
}
|
|
922
|
+
observed.fields.set(name, field);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
if (truncated) break;
|
|
926
|
+
}
|
|
927
|
+
if (truncated) break;
|
|
928
|
+
}
|
|
929
|
+
return Object.freeze({
|
|
930
|
+
authority: "bounded-mvt-sample-v1",
|
|
931
|
+
featuresRead,
|
|
932
|
+
includedValueFields,
|
|
933
|
+
limits: inspectionLimits,
|
|
934
|
+
sourceLayers: Object.freeze(
|
|
935
|
+
[...layers.entries()].sort(([left], [right]) => compareCodeUnits(left, right)).map(
|
|
936
|
+
([id, layer]) => Object.freeze({
|
|
937
|
+
featuresRead: layer.featuresRead,
|
|
938
|
+
fields: Object.freeze(
|
|
939
|
+
[...layer.fields.entries()].sort(([left], [right]) => compareCodeUnits(left, right)).map(([name, field]) => {
|
|
940
|
+
const values = [...field.distinctValues.entries()].flatMap(
|
|
941
|
+
([key, value]) => value.value === void 0 ? [] : [{ key, count: value.count, value: value.value }]
|
|
942
|
+
).sort(
|
|
943
|
+
(left, right) => right.count - left.count || compareCodeUnits(left.key, right.key)
|
|
944
|
+
);
|
|
945
|
+
const includeValues = includedValueFieldSet.has(name);
|
|
946
|
+
return Object.freeze({
|
|
947
|
+
distinctValuesObserved: field.distinctValues.size,
|
|
948
|
+
distinctValuesTruncated: field.distinctValuesTruncated,
|
|
949
|
+
featuresMissing: layer.featuresRead - field.featuresPresent,
|
|
950
|
+
featuresPresent: field.featuresPresent,
|
|
951
|
+
name,
|
|
952
|
+
...field.numericMin === void 0 || field.numericMax === void 0 ? {} : {
|
|
953
|
+
numericRange: Object.freeze({
|
|
954
|
+
max: field.numericMax,
|
|
955
|
+
min: field.numericMin
|
|
956
|
+
})
|
|
957
|
+
},
|
|
958
|
+
...includeValues ? {
|
|
959
|
+
observedValues: Object.freeze(
|
|
960
|
+
values.slice(0, inspectionLimits.maxValuesPerField).map(({ count, value }) => Object.freeze({ count, value }))
|
|
961
|
+
),
|
|
962
|
+
observedValuesTruncated: field.outputValuesTruncated || field.distinctValuesTruncated || values.length > inspectionLimits.maxValuesPerField
|
|
963
|
+
} : {},
|
|
964
|
+
types: Object.freeze([...field.types].sort(compareCodeUnits))
|
|
965
|
+
});
|
|
966
|
+
})
|
|
967
|
+
),
|
|
968
|
+
geometryTypes: Object.freeze([...layer.geometryTypes].sort(compareCodeUnits)),
|
|
969
|
+
id
|
|
970
|
+
})
|
|
971
|
+
)
|
|
972
|
+
),
|
|
973
|
+
tiles: Object.freeze(tiles),
|
|
974
|
+
tilesRead: tiles.length,
|
|
975
|
+
truncated
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
function sampleCoordinates(header) {
|
|
979
|
+
const zooms = [.../* @__PURE__ */ new Set([header.minZoom, header.centerZoom, header.maxZoom])].sort(
|
|
980
|
+
(left, right) => left - right
|
|
981
|
+
);
|
|
982
|
+
const points = [
|
|
983
|
+
[header.centerLon, header.centerLat],
|
|
984
|
+
[(header.minLon + header.maxLon) / 2, (header.minLat + header.maxLat) / 2],
|
|
985
|
+
[header.minLon, header.minLat],
|
|
986
|
+
[header.minLon, header.maxLat],
|
|
987
|
+
[header.maxLon, header.minLat],
|
|
988
|
+
[header.maxLon, header.maxLat]
|
|
989
|
+
];
|
|
990
|
+
const coordinates = /* @__PURE__ */ new Map();
|
|
991
|
+
for (const zoom of zooms) {
|
|
992
|
+
for (const point of points) {
|
|
993
|
+
const coordinate = lonLatToTile(point[0], point[1], zoom);
|
|
994
|
+
coordinates.set(`${coordinate.z}/${coordinate.x}/${coordinate.y}`, coordinate);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
return [...coordinates.values()].sort(
|
|
998
|
+
(left, right) => left.z - right.z || left.y - right.y || left.x - right.x
|
|
999
|
+
);
|
|
1000
|
+
}
|
|
1001
|
+
function normalizeIncludedValueFields(value) {
|
|
1002
|
+
const fields = [...new Set(value ?? [])].sort(compareCodeUnits);
|
|
1003
|
+
if (fields.length > inspectionLimits.maxIncludedValueFields) {
|
|
1004
|
+
throw inspectionError(
|
|
1005
|
+
"TF_TILESET_INSPECTION_OPTIONS_INVALID",
|
|
1006
|
+
"includeValues",
|
|
1007
|
+
`Expected at most ${inspectionLimits.maxIncludedValueFields} value fields.`
|
|
1008
|
+
);
|
|
1009
|
+
}
|
|
1010
|
+
for (const [index, field] of fields.entries()) {
|
|
1011
|
+
if (typeof field !== "string" || !fieldNamePattern.test(field)) {
|
|
1012
|
+
throw inspectionError(
|
|
1013
|
+
"TF_TILESET_INSPECTION_OPTIONS_INVALID",
|
|
1014
|
+
`includeValues.${index}`,
|
|
1015
|
+
"Value fields must use portable PMTiles field names."
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
return Object.freeze(fields);
|
|
1020
|
+
}
|
|
1021
|
+
function observedValueKey(value) {
|
|
1022
|
+
if (typeof value === "boolean") return `boolean:${value ? "1" : "0"}`;
|
|
1023
|
+
if (typeof value === "number") return `number:${Object.is(value, -0) ? "0" : String(value)}`;
|
|
1024
|
+
return `string:${value}`;
|
|
1025
|
+
}
|
|
1026
|
+
function lonLatToTile(longitude, latitude, z) {
|
|
1027
|
+
const count = 2 ** z;
|
|
1028
|
+
const x = Math.max(0, Math.min(count - 1, Math.floor((longitude + 180) / 360 * count)));
|
|
1029
|
+
const boundedLatitude = Math.max(-85.0511287798066, Math.min(85.0511287798066, latitude));
|
|
1030
|
+
const radians = boundedLatitude * Math.PI / 180;
|
|
1031
|
+
const y = Math.max(
|
|
1032
|
+
0,
|
|
1033
|
+
Math.min(
|
|
1034
|
+
count - 1,
|
|
1035
|
+
Math.floor((1 - Math.log(Math.tan(radians) + 1 / Math.cos(radians)) / Math.PI) / 2 * count)
|
|
1036
|
+
)
|
|
1037
|
+
);
|
|
1038
|
+
return Object.freeze({ x, y, z });
|
|
1039
|
+
}
|
|
1040
|
+
function metadataAttribution(metadata) {
|
|
1041
|
+
const attribution = asRecord(metadata)?.attribution;
|
|
1042
|
+
return typeof attribution === "string" && attribution.trim() ? { attribution: attribution.trim().slice(0, 4096) } : {};
|
|
1043
|
+
}
|
|
1044
|
+
function optionalZoom(value, path) {
|
|
1045
|
+
if (value === void 0) return void 0;
|
|
1046
|
+
if (!Number.isInteger(value) || value < 0 || value > 30) {
|
|
1047
|
+
throw inspectionError(
|
|
1048
|
+
"TF_TILESET_METADATA_INVALID",
|
|
1049
|
+
path,
|
|
1050
|
+
"Expected an integer zoom from 0 to 30."
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
return value;
|
|
1054
|
+
}
|
|
1055
|
+
function normalizeFieldType(value) {
|
|
1056
|
+
if (typeof value !== "string") return void 0;
|
|
1057
|
+
const normalized = value.toLowerCase();
|
|
1058
|
+
if (normalized === "boolean" || normalized === "bool") return "Boolean";
|
|
1059
|
+
if (["number", "float", "double", "integer", "int", "uint"].includes(normalized)) return "Number";
|
|
1060
|
+
if (normalized === "string" || normalized === "text") return "String";
|
|
1061
|
+
return void 0;
|
|
1062
|
+
}
|
|
1063
|
+
function observedType(value) {
|
|
1064
|
+
if (typeof value === "boolean") return "Boolean";
|
|
1065
|
+
if (typeof value === "number" && Number.isFinite(value)) return "Number";
|
|
1066
|
+
if (typeof value === "string") return "String";
|
|
1067
|
+
return void 0;
|
|
1068
|
+
}
|
|
1069
|
+
function geometryType(value) {
|
|
1070
|
+
if (value === 1) return "Point";
|
|
1071
|
+
if (value === 2) return "LineString";
|
|
1072
|
+
if (value === 3) return "Polygon";
|
|
1073
|
+
return "Unknown";
|
|
1074
|
+
}
|
|
1075
|
+
function tileTypeName(value) {
|
|
1076
|
+
if (value === TileType.Mvt) return "mvt";
|
|
1077
|
+
if (value === TileType.Mlt) return "mlt";
|
|
1078
|
+
if (value === TileType.Png) return "png";
|
|
1079
|
+
if (value === TileType.Jpeg) return "jpeg";
|
|
1080
|
+
if (value === TileType.Webp) return "webp";
|
|
1081
|
+
if (value === TileType.Avif) return "avif";
|
|
1082
|
+
throw inspectionError(
|
|
1083
|
+
"TF_TILESET_TILE_TYPE_UNSUPPORTED",
|
|
1084
|
+
"contract.tileType",
|
|
1085
|
+
"Unsupported PMTiles tile type."
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
function normalizeCoordinate(value) {
|
|
1089
|
+
return Math.round(value * 1e7) / 1e7;
|
|
1090
|
+
}
|
|
1091
|
+
function inspectionError(code, path, message) {
|
|
1092
|
+
return Object.assign(new Error(message), { code, path, phase: "tileset-inspection" });
|
|
1093
|
+
}
|
|
1094
|
+
function asInspectionError(error, code, path, message) {
|
|
1095
|
+
if (error instanceof DOMException && error.name === "AbortError") {
|
|
1096
|
+
return error;
|
|
1097
|
+
}
|
|
1098
|
+
const record = asRecord(error);
|
|
1099
|
+
return typeof record?.code === "string" ? error : inspectionError(code, path, message);
|
|
1100
|
+
}
|
|
1101
|
+
function asRecord(value) {
|
|
1102
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
export {
|
|
1106
|
+
inspectTileflowPmtilesForLocalUse,
|
|
1107
|
+
inspectTileflowPmtiles
|
|
1108
|
+
};
|