maplibre-tilepackage-protocol 1.0.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/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Måns Beckman
4
+ Copyright (c) 2021 Protomaps LLC
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # maplibre-tilepackage-protocol
2
+
3
+ As an ES6 module: `npm add maplibre-tilepackage-protocol`
4
+
5
+ ```js
6
+ import * as tilePackage from "maplibre-tilepackage-protocol";
7
+ ```
8
+
9
+ ## MapLibre GL JS
10
+
11
+ Example of a TPKX/VTPK archive displayed in MapLibre GL JS:
12
+
13
+ ```js
14
+ import * as tilePackage from "maplibre-tilepackage-protocol";
15
+ let protocol = new tilePackage.Protocol();
16
+ maplibregl.addProtocol("tilepackage",protocol.package);
17
+ var style = {
18
+ "version": 8,
19
+ "sources": {
20
+ "example_source": {
21
+ "type": "raster",
22
+ "url": "tilepackage://https://example.com/example.tpkx",
23
+ ...
24
+ ```
25
+
26
+ ## TPKX
27
+
28
+ A TPKX file containes a full tile pyramid and you just treat it as any other raster source.
29
+
30
+ ## VTPK
31
+
32
+ Primarily flat VTPK files are supported, see below for indexed VTPK files.
33
+
34
+ The file also contains everything you need for your map, such as a style with layers, glyphs, and spirits. You can get everything you need using the tilepackage.
35
+
36
+ ```js
37
+ async initMap(package){
38
+ const header = await package.getHeader();
39
+ if (header.spatialReference == 3857) {
40
+ const style = await package.getStyle();
41
+ new maplibregl.Map({
42
+ container: "map-element",
43
+ localIdeographFontFamily: false,
44
+ style: style,
45
+ });
46
+ }
47
+ }
48
+
49
+ const source = String || tilePackage.FileSource;
50
+ const package = new tilePackage.TilePackage(
51
+ source,
52
+ );
53
+ initMap(package)
54
+ ```
55
+
56
+ A VTPK file may contain an indexed tile pyramid where some tiles are missing, because of this you should always initialze a vtpk file with:
57
+
58
+ ```js
59
+ let protocol = new tilePackage.Protocol({
60
+ errorOnMissingTile: true,
61
+ });
62
+ ```
63
+
64
+ This will make tiles from lower zoom levels remain in view when you zoom into the map. Unfortunatly it is not possible to get the tiles to over scale in the same way as in ArcGIS Pro without modifying the source cache code in Maplibre. https://github.com/maplibre/maplibre-gl-js/issues/5618
65
+
66
+ # Acknowledgement
67
+
68
+ The code for this protocol was based on the PMTiles protocol by Protomaps LLC.
69
+
70
+ # Sample data
71
+
72
+ A sample dataset covering Austria can be downloaded from the following link. Note that this file is an indexed VTPK and won't display all tiles correctly without modifying Maplibre.
73
+
74
+ https://www.data.gv.at/katalog/de/dataset/basemap-at-verwaltungsgrundkarte-vektor-offline-osterreich#resources
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "maplibre-tilepackage-protocol",
3
+ "version": "1.0.0",
4
+ "description": "Maplibre-gl-js protocol for reading VTPK and TPKX files",
5
+ "homepage": "https://github.com/spatialillusions/maplibre-tilepackage-protocol#readme",
6
+ "bugs": {
7
+ "url": "https://github.com/spatialillusions/maplibre-tilepackage-protocol/issues"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/spatialillusions/maplibre-tilepackage-protocol.git"
12
+ },
13
+ "license": "MIT",
14
+ "author": "Måns Beckman",
15
+ "type": "module",
16
+ "main": "src/index.js",
17
+ "scripts": {
18
+ "test": "echo \"Error: no test specified\" && exit 1"
19
+ }
20
+ }
@@ -0,0 +1,28 @@
1
+ /* global globalThis */
2
+ import * as fflate from "fflate";
3
+
4
+ /**
5
+ * Provide a decompression implementation that acts on `buf` and returns decompressed data.
6
+ *
7
+ * Should use the native DecompressionStream on browsers, zlib on node.
8
+ * Should throw if the compression algorithm is not supported.
9
+ */
10
+ export default async function defaultDecompress(buf, compression) {
11
+ if (compression === "none" || compression === undefined) {
12
+ return buf;
13
+ }
14
+ if (compression === "gzip") {
15
+ if (typeof globalThis.DecompressionStream === "undefined") {
16
+ return fflate.decompressSync(new Uint8Array(buf));
17
+ }
18
+ const stream = new Response(buf).body;
19
+ if (!stream) {
20
+ throw new Error("Failed to read response stream");
21
+ }
22
+ const result = stream.pipeThrough(
23
+ new globalThis.DecompressionStream("gzip"),
24
+ );
25
+ return new Response(result).arrayBuffer();
26
+ }
27
+ throw new Error("Compression method not supported");
28
+ }
@@ -0,0 +1,249 @@
1
+ import getJsonFromFile from "./get-json-from-file.js";
2
+
3
+ function xmlToJson(xmlString) {
4
+ const parser = new DOMParser();
5
+ const xmlDoc = parser.parseFromString(xmlString, "application/xml");
6
+ const obj = {};
7
+
8
+ function traverse(node, obj) {
9
+ if (node.nodeType === 1) {
10
+ // element
11
+ const nodeName = node.nodeName;
12
+ if (!obj[nodeName] && node.childNodes.length > 0) {
13
+ obj[nodeName] = {};
14
+ }
15
+ if (node.childNodes.length == 1 && node.childNodes[0].nodeType == 3) {
16
+ obj[nodeName] = node.childNodes[0].nodeValue.trim();
17
+ return;
18
+ }
19
+ for (let i = 0; i < node.childNodes.length; i++) {
20
+ traverse(node.childNodes[i], obj[nodeName]);
21
+ }
22
+ }
23
+ }
24
+
25
+ traverse(xmlDoc.documentElement, obj);
26
+ return obj;
27
+ }
28
+
29
+ /**
30
+ * Parse json into a Header object.
31
+ */
32
+ export function jsonToHeader(json, files, etag) {
33
+ let spatialReference;
34
+ try {
35
+ spatialReference = json.tileInfo.spatialReference.latestWkid;
36
+ // eslint-disable-next-line no-unused-vars
37
+ } catch (e) {
38
+ spatialReference = undefined;
39
+ }
40
+ return {
41
+ type: json.type,
42
+ name: json.name,
43
+ description: json.serviceDescription,
44
+ attribution: json.copyrightText,
45
+ version: json.version,
46
+ metadataOffset: json.metadataOffset || 0,
47
+ metadataLength: json.metadataLength || 0,
48
+ packageType: json.tileInfo.format ? "vtpk" : "tpkx",
49
+ spatialReference: spatialReference,
50
+ tileCompression: json.resourceInfo
51
+ ? json.resourceInfo.tileCompression || "none"
52
+ : "none",
53
+ tileType: json.tileInfo.format || json.tileImageInfo.format,
54
+ tileSize: json.tileInfo.rows,
55
+ minZoom: json.minZoom || json.minLOD,
56
+ maxZoom: json.maxZoom || json.maxLOD,
57
+ minLon: Number(json.extent.xmin),
58
+ minLat: Number(json.extent.ymin),
59
+ maxLon: Number(json.extent.xmax),
60
+ maxLat: Number(json.extent.ymax),
61
+ files: files,
62
+ etag: etag,
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Retrieve the header and root directory of a TilePackage archive.
68
+ *
69
+ * @param {Source} source - The source of the TilePackage archive.
70
+ */
71
+ export default async function getHeaderAndFileList(source) {
72
+ const key = source.getKey();
73
+ const fileSize = await source.getSize();
74
+ const resp = await source.getBytes(fileSize - 98, 98);
75
+ let v = new DataView(resp.data, 0, 98);
76
+ let entriesCentralDirectory, sizeCentralDirectory, offsetCentralDirectory;
77
+ //let bigZip64 = false;
78
+ if (v.getUint32(0, true) === 0x06064b50) {
79
+ // This is a ZIP64 tilepackage
80
+ //bigZip64 = true;
81
+ entriesCentralDirectory = Number(v.getBigUint64(32, true));
82
+ sizeCentralDirectory = Number(v.getBigUint64(40, true));
83
+ offsetCentralDirectory = Number(v.getBigUint64(48, true));
84
+ } else {
85
+ v = new DataView(resp.data, 98 - 22, 22);
86
+ if (v.getUint32(0, true) === 0x06054b50) {
87
+ // This is a ordinary zip archive
88
+ entriesCentralDirectory = Number(v.getUint16(10, true));
89
+ sizeCentralDirectory = Number(v.getUint32(12, true));
90
+ offsetCentralDirectory = Number(v.getUint32(16, true));
91
+ } else {
92
+ throw new Error("Wrong magic number for Zip archive");
93
+ }
94
+ }
95
+
96
+ const centralDirectory = await source.getBytes(
97
+ offsetCentralDirectory,
98
+ sizeCentralDirectory,
99
+ );
100
+
101
+ v = new DataView(centralDirectory.data, 0, sizeCentralDirectory);
102
+ if (v.getUint32(0, true) !== 0x02014b50) {
103
+ throw new Error("Wrong magic number for Central Directory archive");
104
+ }
105
+
106
+ let entryStart = 0;
107
+ const tilePackageFiles = {};
108
+ for (let i = 0; i < entriesCentralDirectory; i++) {
109
+ if (entryStart >= sizeCentralDirectory) break;
110
+ /*
111
+ central file header signature 4 bytes (0x02014b50)
112
+ version made by 2 bytes
113
+ version needed to extract 2 bytes
114
+ general purpose bit flag 2 bytes
115
+ compression method 2 bytes
116
+ last mod file time 2 bytes
117
+ last mod file date 2 bytes
118
+ crc-32 4 bytes
119
+ compressed size 4 bytes
120
+ uncompressed size 4 bytes
121
+ file name length 2 bytes
122
+ extra field length 2 bytes
123
+ file comment length 2 bytes
124
+ disk number start 2 bytes
125
+ internal file attributes 2 bytes
126
+ external file attributes 4 bytes
127
+ relative offset of local header 4 bytes
128
+
129
+ file name (variable size)
130
+ extra field (variable size)
131
+ file comment (variable size)
132
+ */
133
+ let sizeFile = v.getUint32(entryStart + 20, true);
134
+
135
+ const sizeFileName = v.getUint16(entryStart + 28, true);
136
+
137
+ const sizeExtraField = v.getUint16(entryStart + 30, true);
138
+
139
+ const sizeComment = v.getUint16(entryStart + 32, true);
140
+
141
+ let relativeOffset = v.getUint32(entryStart + 42, true);
142
+
143
+ const vFilename = new DataView(
144
+ centralDirectory.data,
145
+ entryStart + 46,
146
+ sizeFileName,
147
+ );
148
+ const decoder = new TextDecoder("utf-8");
149
+ const filename = decoder.decode(vFilename);
150
+
151
+ if (
152
+ (sizeFile == 0xffffffff || relativeOffset == 0xffffffff) &&
153
+ sizeExtraField > 0
154
+ ) {
155
+ const vExtended = new DataView(
156
+ centralDirectory.data,
157
+ entryStart + 46 + sizeFileName,
158
+ 32,
159
+ );
160
+ if (vExtended.getUint16(0, true) == 0x0001) {
161
+ /*
162
+ Value Size Description
163
+ ----- ---- -----------
164
+ (ZIP64) 0x0001 2 bytes Tag for this "extra" block type
165
+ Size 2 bytes Size of this "extra" block
166
+ Original
167
+ Size 8 bytes Original uncompressed file size
168
+ Compressed
169
+ Size 8 bytes Size of compressed data
170
+ Relative Header
171
+ Offset 8 bytes Offset of local header record
172
+ Disk Start
173
+ Number 4 bytes Number of the disk on which
174
+ this file starts
175
+ */
176
+
177
+ let j = 0;
178
+ if (sizeFile == 0xffffffff) {
179
+ sizeFile = Number(vExtended.getBigUint64(4, true));
180
+ j += 8;
181
+ }
182
+ if (relativeOffset == 0xffffffff) {
183
+ relativeOffset = Number(vExtended.getBigUint64(4 + j, true));
184
+ }
185
+ }
186
+ }
187
+
188
+ tilePackageFiles[filename] = {
189
+ filename: filename,
190
+ size: sizeFile,
191
+ relativeOffset: relativeOffset,
192
+ absoluteOffset: relativeOffset + 30 + filename.length,
193
+ };
194
+ entryStart += 46 + sizeFileName + sizeExtraField + sizeComment;
195
+ }
196
+
197
+ let root = {};
198
+
199
+ if (key.indexOf(".tpkx") !== -1) {
200
+ // TPKX
201
+ root = await getJsonFromFile("root.json", tilePackageFiles, source);
202
+ root.type = "tpkx";
203
+ const iteminfo = await getJsonFromFile(
204
+ "iteminfo.json",
205
+ tilePackageFiles,
206
+ source,
207
+ );
208
+ Object.keys(iteminfo).forEach((key) => {
209
+ if (key !== "type") {
210
+ root[key] = iteminfo[key];
211
+ }
212
+ });
213
+ } else {
214
+ // VTPK
215
+ root = await getJsonFromFile("p12/root.json", tilePackageFiles, source);
216
+ root.type = "vtpk";
217
+
218
+ const iteminfoFile = "esriinfo/iteminfo.xml";
219
+ if (tilePackageFiles[iteminfoFile]) {
220
+ const iteminfoOffset = tilePackageFiles[iteminfoFile].absoluteOffset;
221
+
222
+ const iteminfoData = await source.getBytes(
223
+ iteminfoOffset,
224
+ tilePackageFiles[iteminfoFile].size,
225
+ );
226
+ const decoder = new TextDecoder("utf-8");
227
+ const iteminfoString = decoder.decode(iteminfoData.data);
228
+ const iteminfo = xmlToJson(iteminfoString);
229
+ Object.keys(iteminfo.ESRI_ItemInformation).forEach((key) => {
230
+ if (key !== "type") {
231
+ root[key] = iteminfo.ESRI_ItemInformation[key];
232
+ }
233
+ });
234
+ }
235
+
236
+ if (tilePackageFiles["p12/metadata.json"]) {
237
+ const metadataOffset =
238
+ tilePackageFiles["p12/metadata.json"].absoluteOffset;
239
+ root.metadataOffset = metadataOffset;
240
+ root.metadataLength = tilePackageFiles["p12/metadata.json"].size;
241
+ }
242
+ }
243
+ //TODO read iteminfo.xml....
244
+
245
+ const header = jsonToHeader(root, tilePackageFiles, resp.etag);
246
+
247
+ //return [header, [dirKey, rootDir.length, rootDir]];
248
+ return [header, ""];
249
+ }
@@ -0,0 +1,12 @@
1
+ export default async function getJsonFromFile(file, tilePackageFiles, source) {
2
+ const decoder = new TextDecoder("utf-8");
3
+ if (tilePackageFiles[file]) {
4
+ const fileOffset = tilePackageFiles[file].absoluteOffset;
5
+ const fileJSON = await source.getBytes(
6
+ fileOffset,
7
+ tilePackageFiles[file].size,
8
+ );
9
+ return JSON.parse(decoder.decode(fileJSON.data));
10
+ }
11
+ return {};
12
+ }
package/src/index.js ADDED
@@ -0,0 +1,258 @@
1
+ export * from "./maplibre-gl-js-protocol.js";
2
+ export * from "./source.js";
3
+ import { FetchSource } from "./source.js";
4
+ import defaultDecompress from "./default-decompress.js";
5
+ import getJsonFromFile from "./get-json-from-file.js";
6
+ import SharedPromiseCache from "./shared-promise-cache.js";
7
+
8
+ function calculateFilename(z, x, y, header) {
9
+ const zoom = z.toString().padStart(2, "0");
10
+ const baseRow = Math.floor(y / 128) * 128;
11
+ const baseCol = Math.floor(x / 128) * 128;
12
+ const rowHex = baseRow.toString(16).padStart(4, "0");
13
+ const colHex = baseCol.toString(16).padStart(4, "0");
14
+ let basePath;
15
+ if (header.type === "tpkx") {
16
+ basePath = `tile`;
17
+ } else {
18
+ basePath = `p12/tile`;
19
+ }
20
+ return `${basePath}/L${zoom}/R${rowHex}C${colHex}.bundle`;
21
+ }
22
+
23
+ function getTileInfoFromTileIndex(tileIndex, z, x, y) {
24
+ const row = y % 128;
25
+ const col = x % 128;
26
+ return tileIndex[row][col];
27
+ }
28
+
29
+ /**
30
+ * Error thrown when a response for TilePackage over HTTP does not match previous, cached parts of the archive.
31
+ * The default TilePackage implementation will catch this error once internally and retry a request.
32
+ */
33
+ class EtagMismatch extends Error {
34
+ constructor(message) {
35
+ super(message);
36
+ this.name = "EtagMismatch";
37
+ }
38
+ }
39
+
40
+ export class TilePackage {
41
+ constructor(source, cache, decompress) {
42
+ if (typeof source === "string") {
43
+ this.source = new FetchSource(source);
44
+ } else {
45
+ this.source = source;
46
+ }
47
+
48
+ if (decompress) {
49
+ this.decompress = decompress;
50
+ } else {
51
+ this.decompress = defaultDecompress;
52
+ }
53
+
54
+ if (cache) {
55
+ this.cache = cache;
56
+ } else {
57
+ this.cache = new SharedPromiseCache();
58
+ }
59
+ }
60
+
61
+ async getHeader() {
62
+ return await this.cache.getHeader(this.source);
63
+ }
64
+
65
+ async getZxyAttempt(z, x, y, signal) {
66
+ //y = (1 << z) - 1 - y; //TMS vs XYZ
67
+ const header = await this.cache.getHeader(this.source);
68
+ if (z < header.minZoom || z > header.maxZoom) {
69
+ return undefined;
70
+ }
71
+ const file = calculateFilename(z, x, y, header);
72
+ if (!header.files[file]) {
73
+ return undefined;
74
+ }
75
+ const tileIndex = await this.cache.getTileIndex(
76
+ this.source,
77
+ file,
78
+ header,
79
+ signal,
80
+ );
81
+ const tileInfo = getTileInfoFromTileIndex(tileIndex, z, x, y);
82
+ if (tileInfo && tileInfo.tileSize > 0) {
83
+ const tileOffset =
84
+ header.files[file].absoluteOffset + tileInfo.tileOffset;
85
+ const resp = await this.source.getBytes(
86
+ tileOffset,
87
+ tileInfo.tileSize,
88
+ signal,
89
+ header.etag,
90
+ );
91
+ const data = await this.decompress(resp.data, header.tileCompression);
92
+ return {
93
+ data: data,
94
+ cacheControl: resp.cacheControl,
95
+ expires: resp.expires,
96
+ };
97
+ } else {
98
+ //return await this.getZxyAttempt(z - 1, x / 2, y / 2, signal);
99
+ // Return undefined if the tile is not found
100
+ return undefined;
101
+ }
102
+ }
103
+
104
+ async getZxy(z, x, y, signal) {
105
+ try {
106
+ return await this.getZxyAttempt(z, x, y, signal);
107
+ } catch (e) {
108
+ if (e instanceof EtagMismatch) {
109
+ this.cache.invalidate(this.source);
110
+ return await this.getZxyAttempt(z, x, y, signal);
111
+ }
112
+ throw e;
113
+ }
114
+ }
115
+
116
+ async getMetadataAttempt() {
117
+ const header = await this.cache.getHeader(this.source);
118
+
119
+ let metadata = {};
120
+ if (header.packageType === "vtpk") {
121
+ const resp = await this.source.getBytes(
122
+ header.jsonMetadataOffset,
123
+ header.jsonMetadataLength,
124
+ undefined,
125
+ header.etag,
126
+ );
127
+
128
+ const decoder = new TextDecoder("utf-8");
129
+ metadata = JSON.parse(decoder.decode(resp.data));
130
+ }
131
+ metadata.name = header.name;
132
+
133
+ return metadata;
134
+ }
135
+
136
+ async getMetadata() {
137
+ try {
138
+ return await this.getMetadataAttempt();
139
+ } catch (e) {
140
+ if (e instanceof EtagMismatch) {
141
+ this.cache.invalidate(this.source);
142
+ return await this.getMetadataAttempt();
143
+ }
144
+ throw e;
145
+ }
146
+ }
147
+
148
+ async getResourceAttempt(file, signal) {
149
+ const header = await this.cache.getHeader(this.source);
150
+ if (!header.files[file]) {
151
+ return undefined;
152
+ }
153
+
154
+ const resource = await this.cache.getResource(
155
+ this.source,
156
+ file,
157
+ header,
158
+ signal,
159
+ );
160
+ return {
161
+ data: resource.data,
162
+ cacheControl: resource.cacheControl,
163
+ expires: resource.expires,
164
+ };
165
+ }
166
+
167
+ async getResource(file, signal) {
168
+ try {
169
+ return await this.getResourceAttempt(file, signal);
170
+ } catch (e) {
171
+ if (e instanceof EtagMismatch) {
172
+ this.cache.invalidate(this.source);
173
+ return await this.getResourceAttempt(file, signal);
174
+ }
175
+ throw e;
176
+ }
177
+ }
178
+
179
+ async getStyleAttempt() {
180
+ const header = await this.cache.getHeader(this.source);
181
+ const sourceKey = this.source.getKey();
182
+ if (header.packageType === "vtpk") {
183
+ const style = await getJsonFromFile(
184
+ "p12/resources/styles/root.json",
185
+ header.files,
186
+ this.source,
187
+ );
188
+ style.sources.esri.url = `"tilepackage://${sourceKey}`;
189
+ style.sources.esri.maxzoom = header.maxZoom || 22;
190
+ style.glyphs = `tilepackage://${sourceKey}/{fontstack}/{range}`;
191
+ style.sprite = `tilepackage://${sourceKey}/sprite`;
192
+
193
+ return style;
194
+ } else {
195
+ return {
196
+ version: 8,
197
+ sources: {
198
+ esri: {
199
+ type: "raster",
200
+ tileSize: header.tileSize || 256,
201
+ url: `"tilepackage://${sourceKey}`,
202
+ maxzoom: header.maxZoom || 22,
203
+ },
204
+ },
205
+ layers: [
206
+ {
207
+ id: "tilepackageraster",
208
+ type: "raster",
209
+ source: "esri",
210
+ },
211
+ ],
212
+ };
213
+ }
214
+ }
215
+
216
+ async getStyle() {
217
+ try {
218
+ return await this.getStyleAttempt();
219
+ } catch (e) {
220
+ if (e instanceof EtagMismatch) {
221
+ this.cache.invalidate(this.source);
222
+ return await this.getStyleAttempt();
223
+ }
224
+ throw e;
225
+ }
226
+ }
227
+
228
+ async getTileJson(baseTilesUrl) {
229
+ const header = await this.getHeader();
230
+ const metadata = await this.getMetadata();
231
+ const ext = header.tileType;
232
+
233
+ const tileJson = {
234
+ tilejson: "3.0.0",
235
+ scheme: "xyz",
236
+ tiles: [`${baseTilesUrl}/{z}/{x}/{y}${ext}`],
237
+ name: metadata.name,
238
+ version: header.version,
239
+ bounds: [header.minLon, header.minLat, header.maxLon, header.maxLat],
240
+ };
241
+ if (metadata.vector_layers) {
242
+ tileJson.vector_layers = metadata.vector_layers;
243
+ }
244
+ if (metadata.attribution) {
245
+ tileJson.attribution = metadata.attribution;
246
+ }
247
+ if (metadata.description) {
248
+ tileJson.description = metadata.description;
249
+ }
250
+ if (header.minZoom) {
251
+ tileJson.minzoom = header.minZoom;
252
+ }
253
+ if (header.maxZoom) {
254
+ tileJson.maxzoom = header.maxZoom;
255
+ }
256
+ return tileJson;
257
+ }
258
+ }
@@ -0,0 +1,240 @@
1
+ import { TilePackage } from "./index.js";
2
+ import protocolGlyphs from "../protocol-glyphs.js";
3
+
4
+ function stringEndsIn(string, endings) {
5
+ for (let i = 0; i < endings.length; i++) {
6
+ if (string.slice(-endings[i].length) == endings[i]) return endings[i];
7
+ }
8
+ return false;
9
+ }
10
+
11
+ const converter = (getData) => (requestParameters, arg2) => {
12
+ if (arg2 instanceof AbortController) {
13
+ return getData(requestParameters, arg2);
14
+ }
15
+ const abortController = new AbortController();
16
+ getData(requestParameters, abortController)
17
+ .then(
18
+ (result) => {
19
+ return arg2(
20
+ undefined,
21
+ result.data,
22
+ result.cacheControl || "",
23
+ result.expires || "",
24
+ );
25
+ },
26
+ (err) => {
27
+ return arg2(err);
28
+ },
29
+ )
30
+ .catch((e) => {
31
+ return arg2(e);
32
+ });
33
+ return { cancel: () => abortController.abort() };
34
+ };
35
+
36
+ /**
37
+ * MapLibre GL JS protocol. Must be added once globally.
38
+ */
39
+ export class Protocol {
40
+ /**
41
+ * Initialize the MapLibre TilePackage protocol.
42
+ *
43
+ * * metadata: also load the metadata section of the TilePackage. required for some "inspect" functionality
44
+ * and to automatically populate the map attribution. Requires an extra HTTP request.
45
+ * * errorOnMissingTile: When a vector MVT tile is missing from the archive, raise an error instead of
46
+ * returning the empty array. Not recommended. This is only to reproduce the behavior of ZXY tile APIs
47
+ * which some applications depend on when overzooming.
48
+ */
49
+ constructor(options) {
50
+ this.tiles = new Map();
51
+ this.metadata = options ? options.metadata || false : false;
52
+ this.errorOnMissingTile = options
53
+ ? options.errorOnMissingTile || false
54
+ : false;
55
+ this.getData = async (params, abortController) => {
56
+ if (params.type === "json") {
57
+ let tilePackageUrl = params.url.substr(14); // TODO fix this to be more robust
58
+ // at the moment we depend on that the tilePackage URL starts with tilepackage://
59
+
60
+ let spriteCheck = stringEndsIn(params.url, [
61
+ "/sprite",
62
+ "/sprite.json",
63
+ "/sprite@2x.json",
64
+ ]);
65
+ if (spriteCheck) {
66
+ // For some reason spirit and glyphs urls might loose the : in http://
67
+ tilePackageUrl = tilePackageUrl.replace(/(https?)\/\/?/g, "$1://");
68
+ tilePackageUrl = tilePackageUrl.slice(0, -spriteCheck.length);
69
+ if (spriteCheck == "/sprite") {
70
+ spriteCheck = "/sprite.json";
71
+ }
72
+ }
73
+
74
+ let instance = this.tiles.get(tilePackageUrl);
75
+ if (!instance) {
76
+ instance = new TilePackage(tilePackageUrl);
77
+ this.tiles.set(tilePackageUrl, instance);
78
+ }
79
+
80
+ if (spriteCheck) {
81
+ // This is a request for a sprite json
82
+ const file = `p12/resources/sprites${spriteCheck}`;
83
+ const resp = await instance.getResource(file, abortController.signal);
84
+ if (resp) {
85
+ const decoder = new TextDecoder("utf-8");
86
+ const json = JSON.parse(decoder.decode(resp.data));
87
+ return {
88
+ data: json,
89
+ };
90
+ }
91
+ }
92
+ // This is a request for a tile json
93
+ if (this.metadata) {
94
+ return {
95
+ data: await instance.getTileJson(params.url),
96
+ };
97
+ }
98
+ const h = await instance.getHeader();
99
+ if (h.minLon >= h.maxLon || h.minLat >= h.maxLat) {
100
+ console.error(
101
+ `Bounds of TilePackage archive ${h.minLon},${h.minLat},${h.maxLon},${h.maxLat} are not valid.`,
102
+ );
103
+ }
104
+
105
+ return {
106
+ data: {
107
+ tiles: [`${params.url}/{z}/{x}/{y}`],
108
+ minzoom: h.minZoom,
109
+ maxzoom: h.maxZoom,
110
+ bounds: [h.minLon, h.minLat, h.maxLon, h.maxLat],
111
+ },
112
+ };
113
+ //}
114
+ }
115
+ // TODO handle other paths ???
116
+ let re = new RegExp(/tilepackage:\/\/(.+)\/(\d+)\/(\d+)\/(\d+)/);
117
+ let result = params.url.match(re);
118
+ if (!result) {
119
+ // This might be a request for glyphs or spirit
120
+ let tilePackageUrl = params.url.substr(14);
121
+ // For some reason spirit and glyphs urls might loose the : in http://
122
+ tilePackageUrl = tilePackageUrl.replace(/(https?)\/\/?/g, "$1://");
123
+ let spriteCheck = stringEndsIn(params.url, [
124
+ "/sprite",
125
+ "/sprite.png",
126
+ "/sprite@2x.png",
127
+ ]);
128
+ if (spriteCheck) {
129
+ // It looks like we are looking for a spirit png
130
+ tilePackageUrl = tilePackageUrl.slice(0, -spriteCheck.length);
131
+ if (spriteCheck == "/sprite") {
132
+ spriteCheck = "/sprite.png";
133
+ }
134
+ let instance = this.tiles.get(tilePackageUrl);
135
+ if (!instance) {
136
+ instance = new TilePackage(tilePackageUrl);
137
+ this.tiles.set(tilePackageUrl, instance);
138
+ }
139
+ const file = `p12/resources/sprites${spriteCheck}`;
140
+ const resp = await instance.getResource(file, abortController.signal);
141
+ if (resp) {
142
+ return {
143
+ data: new Uint8Array(resp.data),
144
+ cacheControl: resp.cacheControl,
145
+ expires: resp.expires,
146
+ };
147
+ }
148
+ } else {
149
+ // This is more likely a glyph url
150
+ re = new RegExp(/^tilepackage:\/\/(.+?)\/([^/]+)\/([^/]+)$/);
151
+ result = params.url.match(re);
152
+ if (result && isNaN(result[2])) {
153
+ const tilePackageUrl = result[1];
154
+ let instance = this.tiles.get(tilePackageUrl);
155
+ if (!instance) {
156
+ instance = new TilePackage(tilePackageUrl);
157
+ this.tiles.set(tilePackageUrl, instance);
158
+ }
159
+ const file = `p12/resources/fonts/${result[2]}/${result[3]}.pbf`;
160
+ const resp = await instance.getResource(
161
+ file,
162
+ abortController.signal,
163
+ );
164
+ if (resp) {
165
+ return {
166
+ data: new Uint8Array(resp.data),
167
+ cacheControl: resp.cacheControl,
168
+ expires: resp.expires,
169
+ };
170
+ }
171
+
172
+ // Fallback to local glyphs since we couldn't load the coorect from the vtpk
173
+ const fallback = await protocolGlyphs({
174
+ url: `glyphs://${result[2]}/${result[3]}`,
175
+ });
176
+ if (fallback) {
177
+ return fallback;
178
+ }
179
+
180
+ return { data: null };
181
+ }
182
+ }
183
+ }
184
+
185
+ if (!result) {
186
+ throw new Error("Invalid TilePackage protocol URL");
187
+ }
188
+ const tilePackageUrl = result[1];
189
+
190
+ let instance = this.tiles.get(tilePackageUrl);
191
+ if (!instance) {
192
+ instance = new TilePackage(tilePackageUrl);
193
+ this.tiles.set(tilePackageUrl, instance);
194
+ }
195
+ const z = result[2];
196
+ const x = result[3];
197
+ const y = result[4];
198
+ const header = await instance.getHeader();
199
+ const resp = await instance.getZxy(+z, +x, +y, abortController.signal);
200
+ if (resp) {
201
+ return {
202
+ data: new Uint8Array(resp.data),
203
+ cacheControl: resp.cacheControl,
204
+ expires: resp.expires,
205
+ };
206
+ }
207
+ if (header.tileType === "pbf") {
208
+ if (this.errorOnMissingTile) {
209
+ const e = new Error(
210
+ "Tile not found in Tile Package, this is normal for VTPK files with variable tile depth.",
211
+ ); // e.name is 'Error'
212
+ e.name = "TileError";
213
+ throw e;
214
+ }
215
+ return { data: new Uint8Array() };
216
+ } else {
217
+ return { data: null };
218
+ }
219
+ };
220
+
221
+ this.package = converter(this.getData);
222
+ }
223
+
224
+ /**
225
+ * Add a {@link TilePackage} instance to the global protocol instance.
226
+ *
227
+ * For remote fetch sources, references in MapLibre styles like tilePackage://http://...
228
+ * will resolve to the same instance if the URLs match.
229
+ */
230
+ add(p) {
231
+ this.tiles.set(p.source.getKey(), p);
232
+ }
233
+
234
+ /**
235
+ * Fetch a {@link TilePackage} instance by URL, for remote TilePackage instances.
236
+ */
237
+ get(url) {
238
+ return this.tiles.get(url);
239
+ }
240
+ }
@@ -0,0 +1,195 @@
1
+ import getHeaderAndFileList from "./get-header-and-filelist.js";
2
+
3
+ function deserializeIndex(dataView) {
4
+ const index = [];
5
+
6
+ //const M = BigInt(Math.pow(2, 40));
7
+ for (let row = 0; row < 128; row++) {
8
+ index[row] = [];
9
+ for (let col = 0; col < 128; col++) {
10
+ const tileIndexOffset = 8 * (128 * (row % 128) + (col % 128));
11
+ // eslint-disable-next-line no-undef
12
+ let tileOffset = BigInt(0);
13
+ for (let i = 4; i >= 0; i--) {
14
+ // Start from the last byte for little-endian
15
+ tileOffset =
16
+ // eslint-disable-next-line no-undef
17
+ (tileOffset << BigInt(8)) |
18
+ // eslint-disable-next-line no-undef
19
+ BigInt(dataView.getUint8(tileIndexOffset + i));
20
+ }
21
+ // eslint-disable-next-line no-undef
22
+ let tileSize = BigInt(0);
23
+ for (let i = 2; i >= 0; i--) {
24
+ // Start from the last byte for little-endian
25
+ tileSize =
26
+ // eslint-disable-next-line no-undef
27
+ (tileSize << BigInt(8)) |
28
+ // eslint-disable-next-line no-undef
29
+ BigInt(dataView.getUint8(tileIndexOffset + 5 + i));
30
+ }
31
+ if (tileSize > 0) {
32
+ index[row][col] = {
33
+ row: row,
34
+ col: col,
35
+ tileOffset: Number(tileOffset),
36
+ tileSize: Number(tileSize),
37
+ };
38
+ }
39
+ }
40
+ }
41
+
42
+ return index;
43
+ }
44
+
45
+ async function getResource(source, file, header, signal) {
46
+ const resp = await source.getBytes(
47
+ header.files[file].absoluteOffset,
48
+ header.files[file].size,
49
+ signal,
50
+ header.etag,
51
+ );
52
+ return resp;
53
+ }
54
+
55
+ async function getTileIndex(source, file, header, signal) {
56
+ const offset = header.files[file].absoluteOffset + 64; // File header 64bytes
57
+ const resp = await source.getBytes(
58
+ offset,
59
+ 128 * 128 * 8,
60
+ signal,
61
+ header.etag,
62
+ );
63
+ const dataView = new DataView(resp.data, 0, 128 * 128 * 8);
64
+ const directory = deserializeIndex(dataView);
65
+ if (directory.length === 0) {
66
+ throw new Error("Empty directory is invalid");
67
+ }
68
+ return directory;
69
+ }
70
+
71
+ /**
72
+ * A cache for parts of a TilePackage archive where promises can be shared between requests.
73
+ *
74
+ * Only caches headers, resource files, and directories, not individual tile contents.
75
+ */
76
+ export default class SharedPromiseCache {
77
+ constructor(maxCacheEntries = 100) {
78
+ this.cache = new Map();
79
+ this.invalidations = new Map();
80
+ this.maxCacheEntries = maxCacheEntries;
81
+ this.counter = 1;
82
+ //this.decompress = decompress;
83
+ }
84
+
85
+ async getHeader(source) {
86
+ const cacheKey = source.getKey();
87
+ const cacheValue = this.cache.get(cacheKey);
88
+ if (cacheValue) {
89
+ cacheValue.lastUsed = this.counter++;
90
+ const data = await cacheValue.data;
91
+ return data;
92
+ }
93
+
94
+ const p = new Promise((resolve, reject) => {
95
+ getHeaderAndFileList(source)
96
+ .then((res) => {
97
+ /*
98
+ if (res[1]) {
99
+ this.cache.set(res[1][0], {
100
+ lastUsed: this.counter++,
101
+ data: Promise.resolve(res[1][2]),
102
+ });
103
+ }*/
104
+ resolve(res[0]);
105
+ this.prune();
106
+ })
107
+ .catch((e) => {
108
+ reject(e);
109
+ });
110
+ });
111
+ this.cache.set(cacheKey, { lastUsed: this.counter++, data: p });
112
+ return p;
113
+ }
114
+
115
+ async getResource(source, file, header, signal) {
116
+ const cacheKey = `${source.getKey()}|${header.etag || ""}|${file}|Resource`;
117
+ const cacheValue = this.cache.get(cacheKey);
118
+ if (cacheValue) {
119
+ cacheValue.lastUsed = this.counter++;
120
+ const data = await cacheValue.data;
121
+ return data;
122
+ }
123
+ const p = new Promise((resolve, reject) => {
124
+ getResource(source, file, header, signal)
125
+ .then((resource) => {
126
+ resolve(resource);
127
+ this.prune();
128
+ })
129
+ .catch((e) => {
130
+ reject(e);
131
+ });
132
+ });
133
+ this.cache.set(cacheKey, { lastUsed: this.counter++, data: p });
134
+ return p;
135
+ }
136
+
137
+ async getTileIndex(source, file, header, signal) {
138
+ const cacheKey = `${source.getKey()}|${
139
+ header.etag || ""
140
+ }|${file}|TileIndex`;
141
+ const cacheValue = this.cache.get(cacheKey);
142
+ if (cacheValue) {
143
+ cacheValue.lastUsed = this.counter++;
144
+ const data = await cacheValue.data;
145
+ return data;
146
+ }
147
+ const p = new Promise((resolve, reject) => {
148
+ getTileIndex(source, file, header, signal)
149
+ .then((directory) => {
150
+ resolve(directory);
151
+ this.prune();
152
+ })
153
+ .catch((e) => {
154
+ reject(e);
155
+ });
156
+ });
157
+ this.cache.set(cacheKey, { lastUsed: this.counter++, data: p });
158
+ return p;
159
+ }
160
+
161
+ prune() {
162
+ if (this.cache.size >= this.maxCacheEntries) {
163
+ let minUsed = Infinity;
164
+ let minKey = undefined;
165
+ this.cache.forEach((cacheValue, key) => {
166
+ if (cacheValue.lastUsed < minUsed) {
167
+ minUsed = cacheValue.lastUsed;
168
+ minKey = key;
169
+ }
170
+ });
171
+ if (minKey) {
172
+ this.cache.delete(minKey);
173
+ }
174
+ }
175
+ }
176
+
177
+ async invalidate(source) {
178
+ const key = source.getKey();
179
+ if (this.invalidations.get(key)) {
180
+ return await this.invalidations.get(key);
181
+ }
182
+ this.cache.delete(source.getKey());
183
+ const p = new Promise((resolve, reject) => {
184
+ this.getHeader(source)
185
+ .then(() => {
186
+ resolve();
187
+ this.invalidations.delete(key);
188
+ })
189
+ .catch((e) => {
190
+ reject(e);
191
+ });
192
+ });
193
+ this.invalidations.set(key, p);
194
+ }
195
+ }
package/src/source.js ADDED
@@ -0,0 +1,237 @@
1
+ /* global globalThis */
2
+
3
+ /**
4
+ * Interface for retrieving an archive from remote or local storage.
5
+ */
6
+ export class Source {
7
+ async getBytes() {
8
+ throw new Error("Not implemented");
9
+ }
10
+
11
+ getKey() {
12
+ throw new Error("Not implemented");
13
+ }
14
+ }
15
+
16
+ /**
17
+ * Use the Browser's File API, which is different from the NodeJS file API.
18
+ * see https://developer.mozilla.org/en-US/docs/Web/API/File_API
19
+ */
20
+ export class FileSource extends Source {
21
+ constructor(file) {
22
+ super();
23
+ this.file = file;
24
+ }
25
+
26
+ getKey() {
27
+ return this.file.name;
28
+ }
29
+
30
+ async getSize() {
31
+ return this.file.size;
32
+ }
33
+
34
+ // eslint-disable-next-line no-unused-vars
35
+ async getBytes(offset, length, passedSignal, etag) {
36
+ const blob = this.file.slice(offset, offset + length);
37
+ const a = await blob.arrayBuffer();
38
+ return { data: a };
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Uses the browser Fetch API to make tile requests via HTTP.
44
+ *
45
+ * This method does not send conditional request headers If-Match because of CORS.
46
+ * Instead, it detects ETag mismatches via the response ETag or the 416 response code.
47
+ *
48
+ * This also works around browser and storage-specific edge cases.
49
+ */
50
+ export class FetchSource extends Source {
51
+ constructor(url, customHeaders = new Headers()) {
52
+ super();
53
+ this.url = url;
54
+ this.customHeaders = customHeaders;
55
+ this.mustReload = false;
56
+ let userAgent = "";
57
+ if ("navigator" in globalThis) {
58
+ userAgent = globalThis.navigator.userAgent || "";
59
+ }
60
+ const isWindows = userAgent.indexOf("Windows") > -1;
61
+ const isChromiumBased = /Chrome|Chromium|Edg|OPR|Brave/.test(userAgent);
62
+ this.chromeWindowsNoCache = false;
63
+ if (isWindows && isChromiumBased) {
64
+ this.chromeWindowsNoCache = true;
65
+ }
66
+ }
67
+
68
+ getKey() {
69
+ return this.url;
70
+ }
71
+
72
+ // eslint-disable-next-line no-unused-vars
73
+ async getSize(passedSignal, etag) {
74
+ if (this.size) return this.size;
75
+
76
+ let controller;
77
+ let signal;
78
+ if (passedSignal) {
79
+ signal = passedSignal;
80
+ } else {
81
+ controller = new AbortController();
82
+ signal = controller.signal;
83
+ }
84
+
85
+ const requestHeaders = new Headers(this.customHeaders);
86
+ requestHeaders.set("range", `bytes=${0}-${4}`);
87
+
88
+ let cache;
89
+ if (this.mustReload) {
90
+ cache = "reload";
91
+ } else if (this.chromeWindowsNoCache) {
92
+ cache = "no-store";
93
+ }
94
+
95
+ const resp = await fetch(this.url, {
96
+ signal: signal,
97
+ cache: cache,
98
+ headers: requestHeaders,
99
+ });
100
+
101
+ //Making sure that the server supports content-range and getting the file size
102
+ const contentRange = resp.headers.get("Content-Range");
103
+ if (resp.status === 206 && contentRange) {
104
+ const fileSize = contentRange.split("/")[1];
105
+ if (fileSize) {
106
+ this.size = fileSize;
107
+ return fileSize;
108
+ }
109
+ }
110
+ if (resp.status === 206 && !contentRange) {
111
+ // We didn't get a content-range response, getting head instead
112
+ // Getting head is much slower but works. We need to get the size
113
+ // of the file in some way since zip files has the catalog
114
+ // directory at the end.
115
+ const response = await fetch(this.url, {
116
+ method: "HEAD",
117
+ });
118
+ if (response.ok) {
119
+ const fileSize = response.headers.get("Content-Length");
120
+ this.size = fileSize;
121
+ return fileSize;
122
+ }
123
+ }
124
+
125
+ if (resp.status === 200 && !contentRange) {
126
+ if (controller) controller.abort();
127
+ throw new Error(
128
+ "Server returned no content-length header or content-length exceeding request. Check that your storage backend supports HTTP Byte Serving.",
129
+ );
130
+ }
131
+ //const requestHeaders = new Headers(this.customHeaders);
132
+ //requestHeaders.set("method", "HEAD");
133
+
134
+ /*
135
+ let controller;
136
+ let signal;
137
+ if (passedSignal) {
138
+ signal = passedSignal;
139
+ } else {
140
+ controller = new AbortController();
141
+ signal = controller.signal;
142
+ }
143
+ */
144
+ /*
145
+ try {
146
+ const response = await fetch(this.url, { method: "HEAD" });
147
+ if (!response.ok) {
148
+ throw new Error(`HTTP error! status: ${response.status}`);
149
+ }
150
+ const contentLength = response.headers.get("content-length");
151
+ if (contentLength) {
152
+ console.log(contentLength);
153
+ this.size = contentLength;
154
+ return contentLength;
155
+ } else {
156
+ console.error("Content-Length header is not available.");
157
+ }
158
+ } catch (error) {
159
+ console.error("Error fetching headers:", error);
160
+ }*/
161
+ }
162
+
163
+ setHeaders(customHeaders) {
164
+ this.customHeaders = customHeaders;
165
+ }
166
+
167
+ async getBytes(offset, length, passedSignal, etag) {
168
+ let controller;
169
+ let signal;
170
+ if (passedSignal) {
171
+ signal = passedSignal;
172
+ } else {
173
+ controller = new AbortController();
174
+ signal = controller.signal;
175
+ }
176
+
177
+ const requestHeaders = new Headers(this.customHeaders);
178
+ requestHeaders.set("range", `bytes=${offset}-${offset + length - 1}`);
179
+
180
+ let cache;
181
+ if (this.mustReload) {
182
+ cache = "reload";
183
+ } else if (this.chromeWindowsNoCache) {
184
+ cache = "no-store";
185
+ }
186
+
187
+ let resp = await fetch(this.url, {
188
+ signal: signal,
189
+ cache: cache,
190
+ headers: requestHeaders,
191
+ });
192
+
193
+ if (offset === 0 && resp.status === 416) {
194
+ const contentRange = resp.headers.get("Content-Range");
195
+ if (!contentRange || !contentRange.startsWith("bytes */")) {
196
+ throw new Error("Missing content-length on 416 response");
197
+ }
198
+ const actualLength = +contentRange.substr(8);
199
+ resp = await fetch(this.url, {
200
+ signal: signal,
201
+ cache: "reload",
202
+ headers: { range: `bytes=0-${actualLength - 1}` },
203
+ });
204
+ }
205
+
206
+ let newEtag = resp.headers.get("Etag");
207
+ if (newEtag && newEtag.startsWith("W/")) {
208
+ newEtag = null;
209
+ }
210
+
211
+ if (resp.status === 416 || (etag && newEtag && newEtag !== etag)) {
212
+ this.mustReload = true;
213
+ throw new Error(
214
+ `Server returned non-matching ETag ${etag} after one retry. Check browser extensions and servers for issues that may affect correct ETag headers.`,
215
+ );
216
+ }
217
+
218
+ if (resp.status >= 300) {
219
+ throw new Error(`Bad response code: ${resp.status}`);
220
+ }
221
+
222
+ const contentLength = resp.headers.get("Content-Length");
223
+ if (resp.status === 200 && (!contentLength || +contentLength > length)) {
224
+ if (controller) controller.abort();
225
+ throw new Error(
226
+ "Server returned no content-length header or content-length exceeding request. Check that your storage backend supports HTTP Byte Serving.",
227
+ );
228
+ }
229
+ const a = await resp.arrayBuffer();
230
+ return {
231
+ data: a,
232
+ etag: newEtag || undefined,
233
+ cacheControl: resp.headers.get("Cache-Control") || undefined,
234
+ expires: resp.headers.get("Expires") || undefined,
235
+ };
236
+ }
237
+ }