From b2d083cb5d0947b9d0d6cc63b866e66a26d55404 Mon Sep 17 00:00:00 2001 From: Jonathan Lurie Date: Mon, 14 Oct 2024 14:03:14 +0200 Subject: [PATCH] Rd-324-2 (#124) * 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 --- CHANGELOG.md | 1 + package-lock.json | 1 + package.json | 1 + src/Map.ts | 117 ++++++++++++++++++++++++++++++++++++++++--- src/mapstyle.ts | 124 +++++++++++++++++++++++++++++++++++++++++----- src/tools.ts | 4 +- 6 files changed, 227 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e945341..02e83b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - The `Map` class instances now have a `.setTerrainAnimationDuration(d: number)` method - The `Map` class instances now have events related to terrain animation `"terrainAnimationStart"` and `"terrainAnimationStop"` - expose the function `getWebGLSupportError()` to detect WebGL compatibility +- Adding detection of invalid style objects of URLs and falls back to a default style if necessary. - Updating to Maplibre v4.7.1 ## 2.3.0 diff --git a/package-lock.json b/package-lock.json index 8a674d4..c8bc462 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "2.3.0", "license": "BSD-3-Clause", "dependencies": { + "@maplibre/maplibre-gl-style-spec": "^20.3.1", "@maptiler/client": "^2.0.0", "events": "^3.3.0", "js-base64": "^3.7.4", diff --git a/package.json b/package.json index 20e21d1..e75c7ac 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "vitest": "^0.34.2" }, "dependencies": { + "@maplibre/maplibre-gl-style-spec": "^20.3.1", "@maptiler/client": "^2.0.0", "events": "^3.3.0", "js-base64": "^3.7.4", diff --git a/src/Map.ts b/src/Map.ts index 8966c0a..0ff0522 100644 --- a/src/Map.ts +++ b/src/Map.ts @@ -1,4 +1,4 @@ -import maplibregl from "maplibre-gl"; +import maplibregl, { AJAXError } from "maplibre-gl"; import { Base64 } from "js-base64"; import type { StyleSpecification, @@ -29,7 +29,7 @@ import { getBrowserLanguage, Language, type LanguageInfo } from "./language"; import { styleToStyle } from "./mapstyle"; import { MaptilerTerrainControl } from "./MaptilerTerrainControl"; import { MaptilerNavigationControl } from "./MaptilerNavigationControl"; -import { geolocation, getLanguageInfoFromFlag, toLanguageInfo } from "@maptiler/client"; +import { MapStyle, geolocation, getLanguageInfoFromFlag, toLanguageInfo } from "@maptiler/client"; import { MaptilerGeolocateControl } from "./MaptilerGeolocateControl"; import { ScaleControl } from "./MLAdapters/ScaleControl"; import { FullscreenControl } from "./MLAdapters/FullscreenControl"; @@ -187,6 +187,8 @@ export class Map extends maplibregl.Map { private languageAlwaysBeenStyle: boolean; private isReady = false; private terrainAnimationDuration = 1000; + private monitoredStyleUrls!: Set; + private styleInProcess = false; constructor(options: MapOptions) { displayNoWebGlWarning(options.container); @@ -195,13 +197,19 @@ export class Map extends maplibregl.Map { config.apiKey = options.apiKey; } - const style = styleToStyle(options.style); - const hashPreConstructor = location.hash; + const { style, requiresUrlMonitoring, isFallback } = styleToStyle(options.style); + if (isFallback) { + console.warn( + "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.", + ); + } if (!config.apiKey) { console.warn("MapTiler Cloud API key is not set. Visit https://maptiler.com and try Cloud for free!"); } + const hashPreConstructor = location.hash; + // default attribution control options let attributionControlOptions = { compact: false, @@ -215,13 +223,71 @@ export class Map extends maplibregl.Map { }; } - // calling the map constructor with full length style - super({ + const superOptions = { ...options, style, maplibreLogo: false, transformRequest: combineTransformRequest(options.transformRequest), attributionControl: options.forceNoAttributionControl === true ? false : attributionControlOptions, + } as maplibregl.MapOptions; + + // Removing the style option from the super constructor so that we can initialize this.styleInProcess before + // calling .setStyle(). Otherwise, if a style is provided to the super constructor, the setStyle method is called as + // a child call of super, meaning instance attributes cannot be initialized yet. + // The styleInProcess instance attribute is necessary to track if a style has not fall into a CORS error, for which + // Maplibre DOES NOT throw an AJAXError (hence does not track the URL of the failed http request) + // biome-ignore lint/performance/noDelete: + delete superOptions.style; + super(superOptions); + this.setStyle(style); + + if (requiresUrlMonitoring) { + this.monitorStyleUrl(style as string); + } + + const applyFallbackStyle = () => { + let warning = "The distant style could not be loaded."; + // Loading a new style failed. If a style is not already in place, + // the default one is loaded instead + warning in console + if (!this.getStyle()) { + this.setStyle(MapStyle.STREETS); + warning += `Loading default MapTiler Cloud style "${MapStyle.STREETS.getDefaultVariant().getId()}" as a fallback.`; + } else { + warning += "Leaving the style as is."; + } + console.warn(warning); + }; + + this.on("style.load", () => { + this.styleInProcess = false; + }); + + // Safeguard for distant styles at non-http 2xx status URLs + this.on("error", (event) => { + if (event.error instanceof AJAXError) { + const err = event.error as AJAXError; + const url = err.url; + const cleanUrl = new URL(url); + cleanUrl.search = ""; + const clearnUrlStr = cleanUrl.href; + + // If the URL is present in the list of monitored style URL, + // that means this AJAXError was about a style, and we want to fallback to + // the default style + if (this.monitoredStyleUrls.has(clearnUrlStr)) { + this.monitoredStyleUrls.delete(clearnUrlStr); + applyFallbackStyle(); + } + return; + } + + // CORS error when fetching distant URL are not detected as AJAXError by Maplibre, just as generic error with no url property + // so we have to find a way to detect them when it comes to failing to load a style. + if (this.styleInProcess) { + // If this.styleInProcess is true, it very likely means the style URL has not resolved due to a CORS issue. + // In such case, we load the default style + return applyFallbackStyle(); + } }); if (config.caching && !CACHE_API_AVAILABLE) { @@ -632,6 +698,20 @@ export class Map extends maplibregl.Map { }); } + private monitorStyleUrl(url: string) { + // In case this was called before the super constructor could be called. + if (typeof this.monitoredStyleUrls === "undefined") { + this.monitoredStyleUrls = new Set(); + } + + // Note: Because of the usage of urlToAbsoluteUrl() the URL of a style is always supposed to be absolute + + // Removing all the URL params to make it easier to later identify in the set + const cleanUrl = new URL(url); + cleanUrl.search = ""; + this.monitoredStyleUrls.add(cleanUrl.href); + } + /** * Update the style of the map. * Can be: @@ -650,7 +730,30 @@ export class Map extends maplibregl.Map { this.forceLanguageUpdate = false; }); - return super.setStyle(styleToStyle(style), options); + const styleInfo = styleToStyle(style); + + if (styleInfo.requiresUrlMonitoring) { + this.monitorStyleUrl(styleInfo.style as string); + } + + // If the style is invalid and what is returned is a fallback + the map already has a style, + // the style remains unchanged. + if (styleInfo.isFallback) { + if (this.getStyle()) { + console.warn( + "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.", + ); + return this; + } + + console.warn( + "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.", + ); + } + + this.styleInProcess = true; + super.setStyle(styleInfo.style, options); + return this; } /** diff --git a/src/mapstyle.ts b/src/mapstyle.ts index 63305d1..64e684b 100644 --- a/src/mapstyle.ts +++ b/src/mapstyle.ts @@ -1,32 +1,130 @@ +import { validateStyleMin } from "@maplibre/maplibre-gl-style-spec"; import { MapStyle, ReferenceMapStyle, MapStyleVariant, mapStylePresetList, expandMapStyle } from "@maptiler/client"; export function styleToStyle( style: string | ReferenceMapStyle | MapStyleVariant | maplibregl.StyleSpecification | null | undefined, -): string | maplibregl.StyleSpecification { +): { style: string | maplibregl.StyleSpecification; requiresUrlMonitoring: boolean; isFallback: boolean } { if (!style) { - return MapStyle[mapStylePresetList[0].referenceStyleID as keyof typeof MapStyle] - .getDefaultVariant() - .getExpandedStyleURL(); + return { + style: MapStyle[mapStylePresetList[0].referenceStyleID as keyof typeof MapStyle] + .getDefaultVariant() + .getExpandedStyleURL(), + requiresUrlMonitoring: false, // default styles don't require URL monitoring + isFallback: true, + }; } // If the provided style is a shorthand (eg. "streets-v2") or a full style URL - if (typeof style === "string" || style instanceof String) { - if (!style.startsWith("http") && style.toLowerCase().includes(".json")) { - // If a style does not start by http but still contains the extension ".json" - // we assume it's a relative path to a style json file - return style as string; + if (typeof style === "string") { + // The string could be a JSON valid style spec + const styleValidationReport = convertToStyleSpecificationString(style); + + // The string is a valid JSON style that validates against the StyleSpecification spec: + // Let's use this style + if (styleValidationReport.isValidStyle) { + return { + style: styleValidationReport.styleObject as maplibregl.StyleSpecification, + requiresUrlMonitoring: false, + isFallback: false, + }; + } + + // The string is a valid JSON but not of an object that validates the StyleSpecification spec: + // Fallback to the default style + if (styleValidationReport.isValidJSON) { + return { + style: MapStyle[mapStylePresetList[0].referenceStyleID as keyof typeof MapStyle] + .getDefaultVariant() + .getExpandedStyleURL(), + requiresUrlMonitoring: false, // default styles don't require URL monitoring + isFallback: true, + }; + } + + // The style is an absolute URL + if (style.startsWith("http")) { + return { style: style, requiresUrlMonitoring: true, isFallback: false }; } - return expandMapStyle(style); + // The style is a relative URL + if (style.toLowerCase().includes(".json")) { + return { style: urlToAbsoluteUrl(style), requiresUrlMonitoring: true, isFallback: false }; + } + + // The style is a shorthand like "streets-v2" or a MapTiler Style ID (UUID) + return { style: expandMapStyle(style), requiresUrlMonitoring: true, isFallback: false }; } if (style instanceof MapStyleVariant) { - return style.getExpandedStyleURL(); + // Built-in style variants don't require URL monitoring + return { style: style.getExpandedStyleURL(), requiresUrlMonitoring: false, isFallback: false }; } if (style instanceof ReferenceMapStyle) { - return (style.getDefaultVariant() as MapStyleVariant).getExpandedStyleURL(); + // Built-in reference map styles don't require URL monitoring + return { + style: (style.getDefaultVariant() as MapStyleVariant).getExpandedStyleURL(), + requiresUrlMonitoring: false, + isFallback: false, + }; + } + + // If the style validates as a StyleSpecification object, we use it + if (validateStyleMin(style).length === 0) { + return { + style: style as maplibregl.StyleSpecification, + requiresUrlMonitoring: false, + isFallback: false, + }; } - return style as maplibregl.StyleSpecification; + // If none of the previous attempts to detect a valid style failed => fallback to default style + const fallbackStyle = MapStyle[mapStylePresetList[0].referenceStyleID as keyof typeof MapStyle].getDefaultVariant(); + return { + style: fallbackStyle.getExpandedStyleURL(), + requiresUrlMonitoring: false, // default styles don't require URL monitoring + isFallback: true, + }; +} + +/** + * makes sure a URL is absolute + */ +export function urlToAbsoluteUrl(url: string): string { + // Trying to make a URL instance only works with absolute URL or when a base is provided + try { + const u = new URL(url); + return u.href; + } catch (e) { + // nothing to raise + } + + // Absolute URL did not work, we are building it using the current domain + const u = new URL(url, location.origin); + return u.href; +} + +type StyleValidationReport = { + isValidJSON: boolean; + isValidStyle: boolean; + styleObject: maplibregl.StyleSpecification | null; +}; + +export function convertToStyleSpecificationString(str: string): StyleValidationReport { + try { + const styleObj = JSON.parse(str); + const styleErrs = validateStyleMin(styleObj); + + return { + isValidJSON: true, + isValidStyle: styleErrs.length === 0, + styleObject: styleErrs.length === 0 ? (styleObj as maplibregl.StyleSpecification) : null, + }; + } catch (e) { + return { + isValidJSON: false, + isValidStyle: false, + styleObject: null, + }; + } } diff --git a/src/tools.ts b/src/tools.ts index fac3ee4..dc25885 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -79,8 +79,10 @@ export function maptilerCloudTransformRequest(url: string, resourceType?: Resour } } + const localCacheTransformedReq = localCacheTransformRequest(reqUrl, resourceType); + return { - url: localCacheTransformRequest(reqUrl, resourceType), + url: localCacheTransformedReq, }; }