Skip to content

Commit b2d083c

Browse files
* wip * fallback for missing distant style file * Add logic for tracking failed distant style loads * detecting faulty style URL * Adding logic to detect CORS errors
1 parent 8c8943f commit b2d083c

File tree

6 files changed

+227
-21
lines changed

6 files changed

+227
-21
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- The `Map` class instances now have a `.setTerrainAnimationDuration(d: number)` method
88
- The `Map` class instances now have events related to terrain animation `"terrainAnimationStart"` and `"terrainAnimationStop"`
99
- expose the function `getWebGLSupportError()` to detect WebGL compatibility
10+
- Adding detection of invalid style objects of URLs and falls back to a default style if necessary.
1011
- Updating to Maplibre v4.7.1
1112

1213
## 2.3.0

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
"vitest": "^0.34.2"
6262
},
6363
"dependencies": {
64+
"@maplibre/maplibre-gl-style-spec": "^20.3.1",
6465
"@maptiler/client": "^2.0.0",
6566
"events": "^3.3.0",
6667
"js-base64": "^3.7.4",

src/Map.ts

Lines changed: 110 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import maplibregl from "maplibre-gl";
1+
import maplibregl, { AJAXError } from "maplibre-gl";
22
import { Base64 } from "js-base64";
33
import type {
44
StyleSpecification,
@@ -29,7 +29,7 @@ import { getBrowserLanguage, Language, type LanguageInfo } from "./language";
2929
import { styleToStyle } from "./mapstyle";
3030
import { MaptilerTerrainControl } from "./MaptilerTerrainControl";
3131
import { MaptilerNavigationControl } from "./MaptilerNavigationControl";
32-
import { geolocation, getLanguageInfoFromFlag, toLanguageInfo } from "@maptiler/client";
32+
import { MapStyle, geolocation, getLanguageInfoFromFlag, toLanguageInfo } from "@maptiler/client";
3333
import { MaptilerGeolocateControl } from "./MaptilerGeolocateControl";
3434
import { ScaleControl } from "./MLAdapters/ScaleControl";
3535
import { FullscreenControl } from "./MLAdapters/FullscreenControl";
@@ -187,6 +187,8 @@ export class Map extends maplibregl.Map {
187187
private languageAlwaysBeenStyle: boolean;
188188
private isReady = false;
189189
private terrainAnimationDuration = 1000;
190+
private monitoredStyleUrls!: Set<string>;
191+
private styleInProcess = false;
190192

191193
constructor(options: MapOptions) {
192194
displayNoWebGlWarning(options.container);
@@ -195,13 +197,19 @@ export class Map extends maplibregl.Map {
195197
config.apiKey = options.apiKey;
196198
}
197199

198-
const style = styleToStyle(options.style);
199-
const hashPreConstructor = location.hash;
200+
const { style, requiresUrlMonitoring, isFallback } = styleToStyle(options.style);
201+
if (isFallback) {
202+
console.warn(
203+
"Invalid style. A style must be a valid URL to a style.json, a JSON string representing a valid StyleSpecification or a valid StyleSpecification object. Fallback to default MapTiler style.",
204+
);
205+
}
200206

201207
if (!config.apiKey) {
202208
console.warn("MapTiler Cloud API key is not set. Visit https://maptiler.com and try Cloud for free!");
203209
}
204210

211+
const hashPreConstructor = location.hash;
212+
205213
// default attribution control options
206214
let attributionControlOptions = {
207215
compact: false,
@@ -215,13 +223,71 @@ export class Map extends maplibregl.Map {
215223
};
216224
}
217225

218-
// calling the map constructor with full length style
219-
super({
226+
const superOptions = {
220227
...options,
221228
style,
222229
maplibreLogo: false,
223230
transformRequest: combineTransformRequest(options.transformRequest),
224231
attributionControl: options.forceNoAttributionControl === true ? false : attributionControlOptions,
232+
} as maplibregl.MapOptions;
233+
234+
// Removing the style option from the super constructor so that we can initialize this.styleInProcess before
235+
// calling .setStyle(). Otherwise, if a style is provided to the super constructor, the setStyle method is called as
236+
// a child call of super, meaning instance attributes cannot be initialized yet.
237+
// The styleInProcess instance attribute is necessary to track if a style has not fall into a CORS error, for which
238+
// Maplibre DOES NOT throw an AJAXError (hence does not track the URL of the failed http request)
239+
// biome-ignore lint/performance/noDelete: <explanation>
240+
delete superOptions.style;
241+
super(superOptions);
242+
this.setStyle(style);
243+
244+
if (requiresUrlMonitoring) {
245+
this.monitorStyleUrl(style as string);
246+
}
247+
248+
const applyFallbackStyle = () => {
249+
let warning = "The distant style could not be loaded.";
250+
// Loading a new style failed. If a style is not already in place,
251+
// the default one is loaded instead + warning in console
252+
if (!this.getStyle()) {
253+
this.setStyle(MapStyle.STREETS);
254+
warning += `Loading default MapTiler Cloud style "${MapStyle.STREETS.getDefaultVariant().getId()}" as a fallback.`;
255+
} else {
256+
warning += "Leaving the style as is.";
257+
}
258+
console.warn(warning);
259+
};
260+
261+
this.on("style.load", () => {
262+
this.styleInProcess = false;
263+
});
264+
265+
// Safeguard for distant styles at non-http 2xx status URLs
266+
this.on("error", (event) => {
267+
if (event.error instanceof AJAXError) {
268+
const err = event.error as AJAXError;
269+
const url = err.url;
270+
const cleanUrl = new URL(url);
271+
cleanUrl.search = "";
272+
const clearnUrlStr = cleanUrl.href;
273+
274+
// If the URL is present in the list of monitored style URL,
275+
// that means this AJAXError was about a style, and we want to fallback to
276+
// the default style
277+
if (this.monitoredStyleUrls.has(clearnUrlStr)) {
278+
this.monitoredStyleUrls.delete(clearnUrlStr);
279+
applyFallbackStyle();
280+
}
281+
return;
282+
}
283+
284+
// CORS error when fetching distant URL are not detected as AJAXError by Maplibre, just as generic error with no url property
285+
// so we have to find a way to detect them when it comes to failing to load a style.
286+
if (this.styleInProcess) {
287+
// If this.styleInProcess is true, it very likely means the style URL has not resolved due to a CORS issue.
288+
// In such case, we load the default style
289+
return applyFallbackStyle();
290+
}
225291
});
226292

227293
if (config.caching && !CACHE_API_AVAILABLE) {
@@ -632,6 +698,20 @@ export class Map extends maplibregl.Map {
632698
});
633699
}
634700

701+
private monitorStyleUrl(url: string) {
702+
// In case this was called before the super constructor could be called.
703+
if (typeof this.monitoredStyleUrls === "undefined") {
704+
this.monitoredStyleUrls = new Set<string>();
705+
}
706+
707+
// Note: Because of the usage of urlToAbsoluteUrl() the URL of a style is always supposed to be absolute
708+
709+
// Removing all the URL params to make it easier to later identify in the set
710+
const cleanUrl = new URL(url);
711+
cleanUrl.search = "";
712+
this.monitoredStyleUrls.add(cleanUrl.href);
713+
}
714+
635715
/**
636716
* Update the style of the map.
637717
* Can be:
@@ -650,7 +730,30 @@ export class Map extends maplibregl.Map {
650730
this.forceLanguageUpdate = false;
651731
});
652732

653-
return super.setStyle(styleToStyle(style), options);
733+
const styleInfo = styleToStyle(style);
734+
735+
if (styleInfo.requiresUrlMonitoring) {
736+
this.monitorStyleUrl(styleInfo.style as string);
737+
}
738+
739+
// If the style is invalid and what is returned is a fallback + the map already has a style,
740+
// the style remains unchanged.
741+
if (styleInfo.isFallback) {
742+
if (this.getStyle()) {
743+
console.warn(
744+
"Invalid style. A style must be a valid URL to a style.json, a JSON string representing a valid StyleSpecification or a valid StyleSpecification object. Keeping the curent style instead.",
745+
);
746+
return this;
747+
}
748+
749+
console.warn(
750+
"Invalid style. A style must be a valid URL to a style.json, a JSON string representing a valid StyleSpecification or a valid StyleSpecification object. Fallback to default MapTiler style.",
751+
);
752+
}
753+
754+
this.styleInProcess = true;
755+
super.setStyle(styleInfo.style, options);
756+
return this;
654757
}
655758

656759
/**

src/mapstyle.ts

Lines changed: 111 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,130 @@
1+
import { validateStyleMin } from "@maplibre/maplibre-gl-style-spec";
12
import { MapStyle, ReferenceMapStyle, MapStyleVariant, mapStylePresetList, expandMapStyle } from "@maptiler/client";
23

34
export function styleToStyle(
45
style: string | ReferenceMapStyle | MapStyleVariant | maplibregl.StyleSpecification | null | undefined,
5-
): string | maplibregl.StyleSpecification {
6+
): { style: string | maplibregl.StyleSpecification; requiresUrlMonitoring: boolean; isFallback: boolean } {
67
if (!style) {
7-
return MapStyle[mapStylePresetList[0].referenceStyleID as keyof typeof MapStyle]
8-
.getDefaultVariant()
9-
.getExpandedStyleURL();
8+
return {
9+
style: MapStyle[mapStylePresetList[0].referenceStyleID as keyof typeof MapStyle]
10+
.getDefaultVariant()
11+
.getExpandedStyleURL(),
12+
requiresUrlMonitoring: false, // default styles don't require URL monitoring
13+
isFallback: true,
14+
};
1015
}
1116

1217
// If the provided style is a shorthand (eg. "streets-v2") or a full style URL
13-
if (typeof style === "string" || style instanceof String) {
14-
if (!style.startsWith("http") && style.toLowerCase().includes(".json")) {
15-
// If a style does not start by http but still contains the extension ".json"
16-
// we assume it's a relative path to a style json file
17-
return style as string;
18+
if (typeof style === "string") {
19+
// The string could be a JSON valid style spec
20+
const styleValidationReport = convertToStyleSpecificationString(style);
21+
22+
// The string is a valid JSON style that validates against the StyleSpecification spec:
23+
// Let's use this style
24+
if (styleValidationReport.isValidStyle) {
25+
return {
26+
style: styleValidationReport.styleObject as maplibregl.StyleSpecification,
27+
requiresUrlMonitoring: false,
28+
isFallback: false,
29+
};
30+
}
31+
32+
// The string is a valid JSON but not of an object that validates the StyleSpecification spec:
33+
// Fallback to the default style
34+
if (styleValidationReport.isValidJSON) {
35+
return {
36+
style: MapStyle[mapStylePresetList[0].referenceStyleID as keyof typeof MapStyle]
37+
.getDefaultVariant()
38+
.getExpandedStyleURL(),
39+
requiresUrlMonitoring: false, // default styles don't require URL monitoring
40+
isFallback: true,
41+
};
42+
}
43+
44+
// The style is an absolute URL
45+
if (style.startsWith("http")) {
46+
return { style: style, requiresUrlMonitoring: true, isFallback: false };
1847
}
1948

20-
return expandMapStyle(style);
49+
// The style is a relative URL
50+
if (style.toLowerCase().includes(".json")) {
51+
return { style: urlToAbsoluteUrl(style), requiresUrlMonitoring: true, isFallback: false };
52+
}
53+
54+
// The style is a shorthand like "streets-v2" or a MapTiler Style ID (UUID)
55+
return { style: expandMapStyle(style), requiresUrlMonitoring: true, isFallback: false };
2156
}
2257

2358
if (style instanceof MapStyleVariant) {
24-
return style.getExpandedStyleURL();
59+
// Built-in style variants don't require URL monitoring
60+
return { style: style.getExpandedStyleURL(), requiresUrlMonitoring: false, isFallback: false };
2561
}
2662

2763
if (style instanceof ReferenceMapStyle) {
28-
return (style.getDefaultVariant() as MapStyleVariant).getExpandedStyleURL();
64+
// Built-in reference map styles don't require URL monitoring
65+
return {
66+
style: (style.getDefaultVariant() as MapStyleVariant).getExpandedStyleURL(),
67+
requiresUrlMonitoring: false,
68+
isFallback: false,
69+
};
70+
}
71+
72+
// If the style validates as a StyleSpecification object, we use it
73+
if (validateStyleMin(style).length === 0) {
74+
return {
75+
style: style as maplibregl.StyleSpecification,
76+
requiresUrlMonitoring: false,
77+
isFallback: false,
78+
};
2979
}
3080

31-
return style as maplibregl.StyleSpecification;
81+
// If none of the previous attempts to detect a valid style failed => fallback to default style
82+
const fallbackStyle = MapStyle[mapStylePresetList[0].referenceStyleID as keyof typeof MapStyle].getDefaultVariant();
83+
return {
84+
style: fallbackStyle.getExpandedStyleURL(),
85+
requiresUrlMonitoring: false, // default styles don't require URL monitoring
86+
isFallback: true,
87+
};
88+
}
89+
90+
/**
91+
* makes sure a URL is absolute
92+
*/
93+
export function urlToAbsoluteUrl(url: string): string {
94+
// Trying to make a URL instance only works with absolute URL or when a base is provided
95+
try {
96+
const u = new URL(url);
97+
return u.href;
98+
} catch (e) {
99+
// nothing to raise
100+
}
101+
102+
// Absolute URL did not work, we are building it using the current domain
103+
const u = new URL(url, location.origin);
104+
return u.href;
105+
}
106+
107+
type StyleValidationReport = {
108+
isValidJSON: boolean;
109+
isValidStyle: boolean;
110+
styleObject: maplibregl.StyleSpecification | null;
111+
};
112+
113+
export function convertToStyleSpecificationString(str: string): StyleValidationReport {
114+
try {
115+
const styleObj = JSON.parse(str);
116+
const styleErrs = validateStyleMin(styleObj);
117+
118+
return {
119+
isValidJSON: true,
120+
isValidStyle: styleErrs.length === 0,
121+
styleObject: styleErrs.length === 0 ? (styleObj as maplibregl.StyleSpecification) : null,
122+
};
123+
} catch (e) {
124+
return {
125+
isValidJSON: false,
126+
isValidStyle: false,
127+
styleObject: null,
128+
};
129+
}
32130
}

src/tools.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,10 @@ export function maptilerCloudTransformRequest(url: string, resourceType?: Resour
7979
}
8080
}
8181

82+
const localCacheTransformedReq = localCacheTransformRequest(reqUrl, resourceType);
83+
8284
return {
83-
url: localCacheTransformRequest(reqUrl, resourceType),
85+
url: localCacheTransformedReq,
8486
};
8587
}
8688

0 commit comments

Comments
 (0)