diff --git a/.gitignore b/.gitignore index eba1e25765..eb6086cf42 100644 --- a/.gitignore +++ b/.gitignore @@ -75,5 +75,3 @@ test/e2e/app/dist/ test/benchmarks/results/ -# Downloaded map glyph and sprite archives -.map-assets/ diff --git a/build-scripts/gulp/map-assets.js b/build-scripts/gulp/map-assets.js index e28055d405..af3441dfe5 100644 --- a/build-scripts/gulp/map-assets.js +++ b/build-scripts/gulp/map-assets.js @@ -1,137 +1,26 @@ -// Assembles the static assets of the vector base map - style, SDF glyphs and -// icon sprites - into /static/map/. vector.openstreetmap.org sets CORS headers -// on its tiles only, so these cannot be loaded from there by a browser. +// Generates the MapLibre styles for the vector base map. // -// Glyphs and sprites come from pinned VersaTiles releases, cached locally and -// verified against a digest. +// Only the styles. Glyphs, sprites and tiles are served by core's proxy, which +// is what lets them be requested with an application User-Agent and without a +// referrer. The styles stay here because they come from @versatiles/style and +// core has no node toolchain to regenerate them with. -import { createHash } from "node:crypto"; -import { readFile, writeFile } from "node:fs/promises"; +import { writeFile } from "node:fs/promises"; import path from "node:path"; import { colorful, eclipse } from "@versatiles/style"; import fs from "fs-extra"; import gulp from "gulp"; -import { extract } from "tar"; import paths from "../paths.cjs"; import { addLatinLabels } from "./map-labels.js"; -// The tile URL is deliberately never named here: the OSMF asks consumers to -// resolve it through the TileJSON so they can move the tiles without every -// client needing a release. https://operations.osmfoundation.org/policies/vector/ -const TILEJSON_URL = - "https://vector.openstreetmap.org/shortbread_v1/tilejson.json"; +const PROXY_PATH = "/api/map_tiles"; +const TILEJSON_URL = `${PROXY_PATH}/tilejson.json`; -const ASSET_PATH = "/static/map"; - -const ARCHIVES = { - fonts: { - url: "https://github.com/versatiles-org/versatiles-fonts/releases/download/v2.2.0/noto_sans.tar.gz", - sha256: "a2dac39f4096722bc420367ffd4a36687cce7229e8aa760bc12cf657072eea6b", - }, - sprites: { - url: "https://github.com/versatiles-org/versatiles-style/releases/download/v5.13.1/sprites.tar.gz", - sha256: "efffd0ee4cb9591bd52f16ff5b269d9618c7dd1db159cd6511943965560ddea5", - }, -}; - -// Rendered with a device font through `localIdeographFontFamily`, so these are -// never downloaded - and they are 90% of the Noto Sans SDF set. -const LOCAL_IDEOGRAPH_BLOCKS = [ - [0x2e80, 0x9fff], // CJK radicals through CJK Unified Ideographs, incl. kana - [0xac00, 0xd7ff], // Hangul syllables - [0xf900, 0xfaff], // CJK compatibility ideographs - [0xfe30, 0xfe4f], // CJK compatibility forms -]; - -// Our styles use bold only for motorway shields, so Latin, Greek and Cyrillic -// cover every ref and the rest of bold - half the glyph set - is dropped. -// `assertBoldStaysOnRefs` guards the assumption. -const BOLD_MAX_CODEPOINT = 0x04ff; -const BOLD_TEXT_FIELD = "{ref}"; - -const cacheDir = path.resolve(paths.root_dir, ".map-assets"); const outputDir = path.resolve(paths.build_dir, "map"); -const sha256 = (buffer) => createHash("sha256").update(buffer).digest("hex"); - -// Downloads an archive into the cache, or reuses it when the digest matches. -const cachedArchive = async (name, { url, sha256: expected }) => { - const file = path.join(cacheDir, `${name}.tar.gz`); - - if (await fs.pathExists(file)) { - if (sha256(await readFile(file)) === expected) { - return file; - } - console.warn("Cached map %s archive is stale, downloading again", name); - } - - console.log("Downloading map %s from %s", name, url); - const response = await fetch(url); - if (!response.ok) { - throw new Error( - `Failed to download map ${name}: ${response.status} ${response.statusText}` - ); - } - const body = Buffer.from(await response.arrayBuffer()); - - const digest = sha256(body); - if (digest !== expected) { - throw new Error( - `Digest mismatch for map ${name}: expected ${expected}, got ${digest}` - ); - } - - await fs.outputFile(file, body); - return file; -}; - -const glyphRange = (entryPath) => { - const match = /^(?[^/]+)\/(?\d+)-(?\d+)\.pbf$/.exec( - entryPath - ); - return match - ? { font: match.groups.font, start: Number(match.groups.start) } - : undefined; -}; - -const keepGlyph = (entryPath) => { - const range = glyphRange(entryPath); - if (!range) { - return false; - } - if (range.font.endsWith("_bold") && range.start > BOLD_MAX_CODEPOINT) { - return false; - } - return !LOCAL_IDEOGRAPH_BLOCKS.some( - ([from, to]) => range.start >= from && range.start <= to - ); -}; - -const keepSprite = (entryPath) => - /^basics\/sprites(@2x)?\.(json|png)$/.test(entryPath); - -// `neutrino`, for one, sets country and state labels in bold - names in any -// script, which would turn to tofu. Fail the build rather than ship that. -const assertBoldStaysOnRefs = (name, style) => { - const offenders = style.layers - .filter((layer) => - (layer.layout?.["text-font"] ?? []).some((font) => font.endsWith("_bold")) - ) - .filter((layer) => layer.layout["text-field"] !== BOLD_TEXT_FIELD) - .map((layer) => layer.id); - - if (offenders.length) { - throw new Error( - `Style "${name}" uses bold for ${offenders.join(", ")}, which can hold ` + - `names in any script. Raise BOLD_MAX_CODEPOINT to cover the full set ` + - `before shipping this style.` - ); - } -}; - // MapLibre extends the fetched TileJSON with the style's source options, so // anything left here wins and freezes at build time. Dropping them is what lets -// a remote switch move the attribution and zoom range too, not just the URLs. +// the proxy move the attribution and zoom range too, not just the URLs. const TILEJSON_FIELDS = [ "tiles", "attribution", @@ -164,46 +53,25 @@ const useTileJson = (name, style) => { const styleOptions = { // Keeps the generated URLs origin relative. baseUrl: "", - glyphs: `${ASSET_PATH}/fonts/{fontstack}/{range}.pbf`, - sprite: [{ id: "basics", url: `${ASSET_PATH}/sprites/basics/sprites` }], + glyphs: `${PROXY_PATH}/fonts/{fontstack}/{range}.pbf`, + sprite: [{ id: "basics", url: `${PROXY_PATH}/sprites/basics/sprites` }], }; const buildMapAssets = async () => { - const [fontArchive, spriteArchive] = await Promise.all([ - cachedArchive("fonts", ARCHIVES.fonts), - cachedArchive("sprites", ARCHIVES.sprites), - ]); - await fs.emptyDir(outputDir); - await Promise.all([ - fs.ensureDir(path.join(outputDir, "fonts")), - fs.ensureDir(path.join(outputDir, "sprites")), - ]); - await Promise.all([ - extract({ - file: fontArchive, - cwd: path.join(outputDir, "fonts"), - filter: keepGlyph, - }), - extract({ - file: spriteArchive, - cwd: path.join(outputDir, "sprites"), - filter: keepSprite, - }), + await Promise.all( // Both themes up front: dark is a real cartography, not an inverted raster. - ...[ + [ ["light", colorful], ["dark", eclipse], - ].map(([name, builder]) => { - const style = addLatinLabels(useTileJson(name, builder(styleOptions))); - assertBoldStaysOnRefs(name, style); - return writeFile( + ].map(([name, builder]) => + writeFile( path.join(outputDir, `${name}.json`), - JSON.stringify(style) - ); - }), - ]); + JSON.stringify(addLatinLabels(useTileJson(name, builder(styleOptions)))) + ) + ) + ); }; // Shared so it does not have to be wired into every pipeline separately. diff --git a/src/common/dom/setup-leaflet-map.ts b/src/common/dom/setup-leaflet-map.ts index ad84844b3b..860bab68f4 100644 --- a/src/common/dom/setup-leaflet-map.ts +++ b/src/common/dom/setup-leaflet-map.ts @@ -19,6 +19,7 @@ export const setupLeafletMap = async ( longitude: number; zoom?: number; darkMode?: boolean; + token?: string; } ): Promise => { if (!mapElement.parentNode) { @@ -60,7 +61,8 @@ export const setupLeafletMap = async ( const baseLayer = await createBaseLayer( Leaflet, map, - initialView?.darkMode ?? false + initialView?.darkMode ?? false, + initialView?.token ); return { map, leaflet: Leaflet, baseLayer }; diff --git a/src/common/map/base-layer.ts b/src/common/map/base-layer.ts index c2916d3b92..95735ce176 100644 --- a/src/common/map/base-layer.ts +++ b/src/common/map/base-layer.ts @@ -1,13 +1,17 @@ import type { maplibreGL } from "@maplibre/maplibre-gl-leaflet"; -import type { Map as LeafletMap } from "leaflet"; +import type { Map as LeafletMap, TileLayerOptions } from "leaflet"; import type { setRTLTextPlugin, StyleSpecification } from "maplibre-gl"; import type { LeafletModuleType } from "../dom/setup-leaflet-map"; +import { + MAP_TILES_PATH, + mapTilesUrl, + refreshMapTilesToken, + subscribeMapTilesToken, + withMapTilesToken, +} from "../../data/map_tiles"; -// Shortbread vector tiles from the OpenStreetMap Foundation. Only their tile -// endpoint sends CORS headers, so the style, glyphs and sprites are ours to -// serve - see build-scripts/gulp/map-assets.js. The credit comes from the -// TileJSON rather than from here, deliberately: it follows whoever serves the -// tiles. +// Generated by build-scripts/gulp/map-assets.js. The attribution comes from the +// TileJSON, deliberately: it follows whoever serves the tiles. const VECTOR_STYLES = { light: "/static/map/light.json", dark: "/static/map/dark.json", @@ -17,25 +21,31 @@ const VECTOR_STYLES = { // worker, hence a URL rather than an import. const RTL_TEXT_PLUGIN_URL = "/static/map/mapbox-gl-rtl-text.js"; -// Fallback for browsers without WebGL2, which MapLibre needs even for raster, -// so it stays a Leaflet tile layer. Still CARTO, and temporarily so: OSM's -// raster blocks a browser that sends no Referer, and the only referrer a browser -// can send is its origin, which identifies a Nabu Casa installation. -const RASTER_TILE_URL = "https://basemaps.cartocdn.com/rastertiles/voyager"; -const CARTO_ATTRIBUTION = - '© OpenStreetMap, ' + - '© CARTO'; +// MapLibre needs WebGL2 even for raster, so the fallback stays a Leaflet layer. +// OSM serves no @2x variant. +const RASTER_TILE_URL = `${MAP_TILES_PATH}/raster/{z}/{x}/{y}.png?token={token}`; +// The demo has no proxy to go through. Upstream serves raster to a browser that +// identifies itself with a referrer, which the demo page's `same-origin` meta +// policy strips again unless the tiles ask for it back. +const DEMO_RASTER_TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"; +const OSM_ATTRIBUTION = + '© OpenStreetMap contributors'; -// Browsers keep about 16 live WebGL contexts and drop the oldest, so a dashboard -// full of map cards loses its first ones for good - nothing frees a slot for -// MapLibre to reclaim. A transient loss does get restored, hence the grace. +// Browsers keep about 16 live WebGL contexts and drop the oldest, which a +// dashboard full of map cards hits. A transient loss is restored, hence a grace. const CONTEXT_RESTORE_GRACE = 2000; +const RECOVERY_THROTTLE = 30000; -// On the map, not the layer: only Leaflet tile layers report their own limits, -// and marker clustering throws without a maximum. The floor is 1 because at -// Leaflet zoom 0 the adapter drives MapLibre to -1, outside its range. +// On the map, not the layer: marker clustering throws without a maximum. The +// floor is 1 because at Leaflet zoom 0 the adapter drives MapLibre to -1. export const MAP_MIN_ZOOM = 1; export const MAP_MAX_ZOOM = 20; +// OSM's raster stops at 19 and the proxy refuses higher, so Leaflet scales the +// last level up rather than asking for tiles that are not there. +const RASTER_MAX_NATIVE_ZOOM = 19; + +// Leaflet substitutes any option into the URL template; its types do not. +type TokenTileLayerOptions = TileLayerOptions & { token?: string }; export interface MapBaseLayer { // A no-op for raster, which has no dark variant and is inverted in CSS. @@ -44,7 +54,7 @@ export interface MapBaseLayer { let webGL2Supported: boolean | undefined; -// Rules out iOS below 15, older Android tablets, and blocklisted drivers. +// Rules out iOS below 15, older Android tablets and blocklisted drivers. const supportsWebGL2 = (): boolean => { if (webGL2Supported === undefined) { try { @@ -59,17 +69,41 @@ const supportsWebGL2 = (): boolean => { return webGL2Supported; }; -// Asset URLs are stored origin relative so they follow the instance's host, but -// MapLibre rejects a relative sprite URL. The glyph URL is left alone: URL -// encoding would mangle its {fontstack} and {range} placeholders. +// MapLibre rejects a relative sprite URL. Not the glyph URL: encoding would +// mangle its {fontstack} and {range} placeholders. +// The demo has no core to proxy through. OSM sets CORS on its tiles but not on +// its glyphs and sprites, which is why those come from VersaTiles. +const DEMO_UPSTREAM = { + tilejson: "https://vector.openstreetmap.org/shortbread_v1/tilejson.json", + assets: "https://tiles.versatiles.org/assets", +}; + +const useDemoUpstream = (style: StyleSpecification): StyleSpecification => { + style.glyphs = `${DEMO_UPSTREAM.assets}/glyphs/{fontstack}/{range}.pbf`; + style.sprite = [ + { id: "basics", url: `${DEMO_UPSTREAM.assets}/sprites/basics/sprites` }, + ]; + Object.values(style.sources).forEach((source) => { + if ("url" in source) { + source.url = DEMO_UPSTREAM.tilejson; + } + }); + return style; +}; + const loadStyle = async (url: string): Promise => { const style: StyleSpecification = await (await fetch(url)).json(); + + if (__DEMO__) { + return useDemoUpstream(style); + } + if (typeof style.sprite === "string") { - style.sprite = new URL(style.sprite, location.href).href; + style.sprite = mapTilesUrl(style.sprite); } else if (Array.isArray(style.sprite)) { style.sprite = style.sprite.map((sprite) => ({ ...sprite, - url: new URL(sprite.url, location.href).href, + url: mapTilesUrl(sprite.url), })); } return style; @@ -93,21 +127,24 @@ const createVectorLayer = async ( createLayer: typeof maplibreGL, leaflet: LeafletModuleType, map: LeafletMap, - darkMode: boolean + darkMode: boolean, + token: string | undefined ): Promise => { let layer: ReturnType | undefined; try { layer = createLayer({ style: await loadStyle(VECTOR_STYLES[darkMode ? "dark" : "light"]), - // Draws CJK, kana and hangul with a device font, which is why we ship a - // tenth of the glyph set. No referrer is set: the endpoint serves without - // one, and the only one a browser can send identifies the installation. - localIdeographFontFamily: "sans-serif", + // Absolute, or the worker fetching tiles cannot resolve them. + transformRequest: (url) => ({ + url: withMapTilesToken(url), + // OSM asks a website for a referrer, and the demo has no instance + // hostname to leak. + referrerPolicy: __DEMO__ ? "origin" : undefined, + }), }); - // The plugin builds the MapLibre map in `onAdd`, so a refused context, a - // blocked worker or a rejected blob URL throws here. Keep it inside the - // guard or those lose the raster fallback. + // The plugin builds the MapLibre map in `onAdd`, so a refused context or a + // blocked worker throws here. Keep it guarded or those lose the fallback. layer.addTo(map); } catch { if (layer) { @@ -120,14 +157,14 @@ const createVectorLayer = async ( return undefined; } - // Tracked apart because a failed request must roll back to what is displayed, - // not to the opposite of what it asked for - with several in flight those are - // different, and guessing wrong makes the next toggle a permanent no-op. + // Tracked apart so a failed request rolls back to what is displayed, not to + // the opposite of what it asked - which with several in flight differs. let appliedDarkMode = darkMode; let requestedDarkMode = darkMode; // Styles are fetched, so only the newest request may touch the map. let latestRequest = 0; let vector = true; + let refused = false; const glMap = layer.getMaplibreMap(); let fallbackTimeout: number | undefined; @@ -148,13 +185,12 @@ const createVectorLayer = async ( } catch { // Nothing left to detach. } - createRasterLayer(leaflet, map); + createRasterLayer(leaflet, map, token); }; const scheduleSwap = () => { clearTimeout(fallbackTimeout); - // Backgrounding also drops the context, and there it comes back on return. - // Running the clock then would make switching apps enough to lose vector. + // Backgrounding drops it too, and there it comes back on return. if (!vector || document.hidden) { return; } @@ -177,54 +213,97 @@ const createVectorLayer = async ( document.removeEventListener("visibilitychange", handleVisibilityChange); }); + const applyStyle = (newDarkMode: boolean) => { + const request = ++latestRequest; + + loadStyle(VECTOR_STYLES[newDarkMode ? "dark" : "light"]) + .then((style) => { + if (request === latestRequest) { + appliedDarkMode = newDarkMode; + layer.getMaplibreMap()?.setStyle(style); + } + }) + .catch(() => { + if (request === latestRequest) { + requestedDarkMode = appliedDarkMode; + } + }); + }; + + // A refused request leaves the source dead: the TileJSON is fetched once and + // is never retried, so the style has to be applied again once there is a new + // token. Throttled, or a proxy refusing for another reason loops. + let lastRecovery = 0; + glMap.on("error", (event) => { + const status = (event.error as { status?: number } | undefined)?.status; + // 403 is a stale token, 404 the proxy not registered yet during a restart, + // and no status at all a network failure. All three recover the same way, + // and a token that comes back unchanged costs nothing. + if (status !== undefined && status !== 403 && status !== 404) { + return; + } + if (Date.now() - lastRecovery < RECOVERY_THROTTLE) { + return; + } + lastRecovery = Date.now(); + refused = true; + refreshMapTilesToken(); + }); + + // Only a new token clears the refusal. A theme change in between applies a + // style that is refused just as the last one was, so it proves nothing. + const unsubscribeToken = subscribeMapTilesToken(() => { + if (vector && refused) { + refused = false; + applyStyle(requestedDarkMode); + } + }); + map.on("unload", unsubscribeToken); + return { setDarkMode: (newDarkMode: boolean) => { if (!vector || newDarkMode === requestedDarkMode) { return; } requestedDarkMode = newDarkMode; - const request = ++latestRequest; - - loadStyle(VECTOR_STYLES[newDarkMode ? "dark" : "light"]) - .then((style) => { - if (request === latestRequest) { - appliedDarkMode = newDarkMode; - layer.getMaplibreMap()?.setStyle(style); - } - }) - .catch(() => { - if (request === latestRequest) { - requestedDarkMode = appliedDarkMode; - } - }); + applyStyle(newDarkMode); }, }; }; const createRasterLayer = ( leaflet: LeafletModuleType, - map: LeafletMap + map: LeafletMap, + token: string | undefined ): MapBaseLayer => { - leaflet - .tileLayer( - // These are the old retina tablets, and CARTO serves @2x - sharp tiles at - // the same request count, where `detectRetina` would fetch a zoom deeper - // at four times as many. - `${RASTER_TILE_URL}/{z}/{x}/{y}${leaflet.Browser.retina ? "@2x" : ""}.png`, - { - attribution: CARTO_ATTRIBUTION, - maxZoom: MAP_MAX_ZOOM, - } - ) + const layer = leaflet + .tileLayer(__DEMO__ ? DEMO_RASTER_TILE_URL : mapTilesUrl(RASTER_TILE_URL), { + attribution: OSM_ATTRIBUTION, + maxZoom: MAP_MAX_ZOOM, + maxNativeZoom: RASTER_MAX_NATIVE_ZOOM, + referrerPolicy: __DEMO__ ? "origin" : undefined, + // Leaflet throws on an undefined template variable, so no token means an + // empty one: the tiles 403 and the markers still draw. + token: token ?? "", + } as TokenTileLayerOptions) .addTo(map); + // Substituted per request, so a refreshed token needs no new layer. + const unsubscribe = subscribeMapTilesToken((newToken) => { + (layer.options as TokenTileLayerOptions).token = newToken; + // Tiles that 403'd are cached as failures; only a redraw asks again. + layer.redraw(); + }); + map.on("unload", unsubscribe); + return { setDarkMode: () => undefined }; }; export const createBaseLayer = async ( leaflet: LeafletModuleType, map: LeafletMap, - darkMode: boolean + darkMode: boolean, + token: string | undefined ): Promise => { if (supportsWebGL2()) { let vectorLayer: MapBaseLayer | undefined; @@ -238,7 +317,8 @@ export const createBaseLayer = async ( createLayer, leaflet, map, - darkMode + darkMode, + token ); } catch { // No chunk, no vector map - but still a map. @@ -247,5 +327,5 @@ export const createBaseLayer = async ( return vectorLayer; } } - return createRasterLayer(leaflet, map); + return createRasterLayer(leaflet, map, token); }; diff --git a/src/components/map/ha-map.ts b/src/components/map/ha-map.ts index 9bd7980ccf..b1c8c9f734 100644 --- a/src/components/map/ha-map.ts +++ b/src/components/map/ha-map.ts @@ -29,6 +29,7 @@ import type { MapBaseLayer } from "../../common/map/base-layer"; import { computeStateDomain } from "../../common/entity/compute_state_domain"; import { computeStateName } from "../../common/entity/compute_state_name"; import { getEntityLocation } from "../../common/entity/get_entity_location"; +import { ensureMapTilesToken } from "../../data/map_tiles"; import { DecoratedMarker } from "../../common/map/decorated_marker"; import { filterXSS } from "../../common/util/xss"; import { @@ -327,11 +328,19 @@ export class HaMap extends ReactiveElement { } this._loading = true; try { + // The tiles are proxied by core behind a token, so nothing loads without + // one. A host that provides no connection, or a backend without the + // proxy, leaves the map without tiles rather than failing to set up. + const token = this._connection + ? await ensureMapTilesToken(this._connection.connection) + : undefined; + const setup = await setupLeafletMap(map, { latitude: this._config?.latitude ?? 52.3731339, longitude: this._config?.longitude ?? 4.8903147, zoom: this.zoom, darkMode: this._darkMode, + token, }); // Setting up fetches a style, so the element can be gone by now. // `disconnectedCallback` had no map to tear down, and keeping this one diff --git a/src/data/map_tiles.ts b/src/data/map_tiles.ts new file mode 100644 index 0000000000..8a335febf6 --- /dev/null +++ b/src/data/map_tiles.ts @@ -0,0 +1,185 @@ +import type { Connection } from "home-assistant-js-websocket"; +import { waitForMs } from "../common/util/wait"; + +export const MAP_TILES_PATH = "/api/map_tiles"; + +// Core rotates every 30 minutes and keeps two tokens live, so one handed out +// now is good for at least 30 more. Refreshing sooner leaves room for a slow +// or missed round trip. +const TOKEN_REFRESH_MS = 20 * 60 * 1000; + +// Nothing loads without a token, so the first attempts are awaited - briefly, +// or a backend without the proxy would hold the map hostage. The rest retry in +// the background, for the window after a restart where the WebSocket is up but +// the handler is not registered yet. +const BLOCKING_DELAYS_MS = [0, 400, 1000]; +const BACKGROUND_DELAYS_MS = [2000, 5000, 10000, 15000]; + +let token: string | undefined; +let instanceUrl: string | undefined; +let acquiring: Promise | undefined; +let background: Promise | undefined; +let refreshInterval: ReturnType | undefined; +let watchedConnection: Connection | undefined; +let activeConnection: Connection | undefined; +let refreshing: Promise | undefined; +const listeners = new Set<(token: string) => void>(); + +const fetchToken = async (connection: Connection): Promise => { + const result = await connection.sendMessagePromise<{ token: string }>({ + type: "map_tiles/access_token", + }); + if (result.token !== token) { + token = result.token; + listeners.forEach((listener) => listener(token!)); + } +}; + +const attempt = async (connection: Connection, delays: number[]) => { + /* eslint-disable no-await-in-loop -- retries are intentionally sequential */ + for (const delay of delays) { + if (token) { + return; + } + if (delay) { + await waitForMs(delay); + } + try { + await fetchToken(connection); + return; + } catch { + // try next delay + } + } + /* eslint-enable no-await-in-loop */ +}; + +export const ensureMapTilesToken = async ( + connection: Connection +): Promise => { + // The demo has no proxy to ask, and its tiles come straight from upstream. + if (__DEMO__) { + return undefined; + } + + activeConnection = connection; + // Cast serves this page from its own host, so the proxy is not on this origin. + instanceUrl = connection.options.auth?.data.hassUrl; + + // Shared, or a dashboard full of maps asks once per map - and a backend + // without the proxy turns that into every retry, per map. + if (!token) { + acquiring ??= attempt(connection, BLOCKING_DELAYS_MS).finally(() => { + acquiring = undefined; + }); + await acquiring; + } + + if (!token) { + background ??= attempt(connection, BACKGROUND_DELAYS_MS) + .then(() => scheduleRefresh(connection)) + .finally(() => { + background = undefined; + }); + return undefined; + } + + scheduleRefresh(connection); + return token; +}; + +// Always the current connection: Cast builds a fresh one per connect while this +// module lives on, and refreshing over the closed one stops the rotation. +const handleReady = () => { + if (!activeConnection) { + return; + } + const connection = activeConnection; + fetchToken(connection) + // A token first acquired here would otherwise never get an interval. + .then(() => scheduleRefresh(connection)) + .catch(() => { + // Nothing to do; the next reconnect tries again. + }); +}; + +const scheduleRefresh = (connection: Connection) => { + if (token && !refreshInterval) { + refreshInterval = setInterval(() => { + fetchToken(activeConnection ?? connection).catch(() => { + // Keep the current token; the next interval retries. + }); + }, TOKEN_REFRESH_MS); + } + + if (watchedConnection !== connection) { + // The interval does not fire while the process is suspended, so the token + // can be stale before it comes round; reconnecting is the reliable signal. + watchedConnection?.removeEventListener("ready", handleReady); + watchedConnection = connection; + connection.addEventListener("ready", handleReady); + } +}; + +/** + * Forces a new token, for when the current one is refused. Deduplicated: a + * refused map produces one of these per tile. + */ +export const refreshMapTilesToken = (): Promise => { + if (!activeConnection) { + return Promise.resolve(); + } + refreshing ??= fetchToken(activeConnection) + .catch(() => { + // Leave the old token in place; a reconnect or the interval retries. + }) + .finally(() => { + refreshing = undefined; + }); + return refreshing; +}; + +/** Leaflet bakes its URL template at layer creation, so it needs telling. */ +export const subscribeMapTilesToken = ( + listener: (token: string) => void +): (() => void) => { + listeners.add(listener); + return () => listeners.delete(listener); +}; + +const instanceOrigin = () => + (instanceUrl ?? location.origin).replace(/\/+$/, ""); + +/** + * Prefixes a proxy path with the instance that serves it. Concatenated rather + * than resolved, so `{z}` and `{fontstack}` survive instead of being encoded. + */ +export const mapTilesUrl = (path: string): string => + path.startsWith("/") ? `${instanceOrigin()}${path}` : path; + +/** + * MapLibre hands tile URLs to a worker, which has no document to resolve a + * relative URL against, so the result has to be absolute. + */ +export const withMapTilesToken = (url: string): string => { + let parsed: URL; + try { + parsed = new URL(url, instanceOrigin()); + } catch { + return url; + } + + if (!parsed.pathname.startsWith(`${MAP_TILES_PATH}/`)) { + return parsed.href; + } + + // Rebuilt against the instance: MapLibre resolves the style's paths against + // this page first, and on Cast that is not the host serving the proxy. + const onInstance = new URL( + `${instanceOrigin()}${parsed.pathname}${parsed.search}` + ); + if (token) { + onInstance.searchParams.set("token", token); + } + return onInstance.href; +}; diff --git a/src/entrypoints/service-worker.ts b/src/entrypoints/service-worker.ts index 7b55954d86..4426a4a6da 100644 --- a/src/entrypoints/service-worker.ts +++ b/src/entrypoints/service-worker.ts @@ -117,6 +117,33 @@ const initRouting = () => { }) ); + // Strip the rotating token from the cache key, or every rotation refetches + // every tile. The TileJSON is deliberately not matched: it is the switching + // point for the tile source, so it stays on the network. + registerRoute( + ({ url }) => + /^\/api\/map_tiles\/(vector|raster|fonts|sprites)\//.test(url.pathname), + new CacheFirst({ + cacheName: "map-tiles", + plugins: [ + { + cacheKeyWillBeUsed: async ({ request }) => { + const url = new URL(request.url); + url.searchParams.delete("token"); + return url.href; + }, + }, + // A stale token gives a 403; caching that would pin the failure. + new CacheableResponsePlugin({ statuses: [0, 200] }), + new ExpirationPlugin({ + maxEntries: 500, + maxAgeSeconds: 60 * 60 * 24 * 7, + purgeOnQuotaError: true, + }), + ], + }) + ); + // Short-circuit camera/image proxy requests with an expired signature or a // missing/undefined token so they don't hit core and get logged as invalid // login attempts. Registered before the generic /api route below so it wins. diff --git a/test/common/map/base-layer.test.ts b/test/common/map/base-layer.test.ts index ea73068e73..7acc17f5ca 100644 --- a/test/common/map/base-layer.test.ts +++ b/test/common/map/base-layer.test.ts @@ -18,6 +18,26 @@ const maplibreGL = vi.hoisted(() => vi.fn(() => maplibreLayer)); vi.mock("@maplibre/maplibre-gl-leaflet", () => ({ maplibreGL })); +// The token module is driven directly here, so a stale token and the one that +// replaces it can be played out without a WebSocket. +// Stands in for the instance the proxy runs on, which on Cast is not the host +// serving the page. +const INSTANCE_URL = vi.hoisted(() => "https://instance.local"); +const tokenListeners = vi.hoisted(() => new Set<(token: string) => void>()); +const refreshMapTilesToken = vi.hoisted(() => vi.fn()); +vi.mock("../../../src/data/map_tiles", () => ({ + MAP_TILES_PATH: "/api/map_tiles", + mapTilesUrl: (path: string) => `${INSTANCE_URL}${path}`, + refreshMapTilesToken, + subscribeMapTilesToken: (listener: (token: string) => void) => { + tokenListeners.add(listener); + return () => tokenListeners.delete(listener); + }, + withMapTilesToken: (url: string) => new URL(url, location.href).href, +})); +const emitToken = (token: string) => + tokenListeners.forEach((listener) => listener(token)); + const setRTLTextPlugin = vi.hoisted(() => vi.fn(async () => undefined)); vi.mock("maplibre-gl", () => ({ setRTLTextPlugin })); @@ -29,19 +49,23 @@ const STYLE = { sprite: [{ id: "basics", url: "/static/map/sprites/basics/sprites" }], }; -const rasterLayer = { addTo: vi.fn() }; -// Kept as a local so tests can flip it: @types/leaflet has it readonly. -const browser = { retina: false }; +const rasterLayer = { + // Leaflet returns the layer from addTo, and the source chains off it. + addTo: vi.fn(() => rasterLayer), + redraw: vi.fn(), + options: {} as Record, +}; const leaflet = { tileLayer: vi.fn(() => rasterLayer), - Browser: browser, } as unknown as LeafletModuleType; +const TOKEN = "test-token"; + // `createVectorLayer` listens on both the MapLibre map and the Leaflet map. -const glHandlers: Record void> = {}; +const glHandlers: Record void> = {}; const glMap = { setStyle: vi.fn(), - on: vi.fn((event: string, handler: () => void) => { + on: vi.fn((event: string, handler: (event?: unknown) => void) => { glHandlers[event] = handler; }), }; @@ -61,6 +85,7 @@ const isRaster = () => vi.mocked(leaflet.tileLayer).mock.calls.length === 1; beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); + tokenListeners.clear(); maplibreLayer.options = {}; maplibreGL.mockReturnValue(maplibreLayer); maplibreLayer.addTo.mockImplementation(() => maplibreLayer); @@ -83,40 +108,47 @@ describe("createBaseLayer", () => { it("falls back to raster tiles without WebGL2", async () => { const createBaseLayer = await setWebGL2(false); - await createBaseLayer(leaflet, map, false); + await createBaseLayer(leaflet, map, false, TOKEN); expect(isRaster()).toBe(true); expect(maplibreGL).not.toHaveBeenCalled(); expect(rasterLayer.addTo).toHaveBeenCalledWith(map); const [url, options = {}] = vi.mocked(leaflet.tileLayer).mock.calls[0]; - // No referrer: the only one a browser can send is its origin, which - // identifies a Nabu Casa installation. The fallback source is chosen so it - // does not need one. - expect(options).not.toHaveProperty("referrerPolicy"); + // Through core's proxy, which is what identifies Home Assistant upstream - + // a browser can set neither a User-Agent nor a Referer. + // Absolute, because on Cast this page is not served by the instance. + expect(url).toBe( + `${INSTANCE_URL}/api/map_tiles/raster/{z}/{x}/{y}.png?token={token}` + ); + // Leaflet substitutes options into the template on every tile request, so + // the token can be refreshed without recreating the layer. + expect(url).toContain("token={token}"); + expect(options).toMatchObject({ token: TOKEN }); + // OSM serves no raster past 19, so the last level is scaled up instead. + expect(options.maxNativeZoom).toBe(19); + expect(options.maxZoom).toBeGreaterThan(19); // The vector layer takes its credit from the style's source instead, so the - // raster layer is the only one carrying attribution itself - and it credits - // both the data and whoever rendered it. + // raster layer is the only one carrying attribution itself. expect(options.attribution).toContain("openstreetmap.org/copyright"); - expect(options.attribution).toContain("carto.com/attributions"); - expect(url).toMatch(/\{z\}\/\{x\}\/\{y\}/); }); - // The devices on the fallback are the old retina tablets, and the source - // serves @2x, so they get sharp tiles without quadrupling the requests. - it("asks for @2x raster tiles on a retina screen", async () => { + // The demo ships without a backend, so there is no proxy to fall back to. + it("falls back to upstream raster in the demo, with a referrer", async () => { + vi.stubGlobal("__DEMO__", true); const createBaseLayer = await setWebGL2(false); - browser.retina = true; + await createBaseLayer(leaflet, map, false, undefined); - await createBaseLayer(leaflet, map, false); - - expect(vi.mocked(leaflet.tileLayer).mock.calls[0][0]).toContain("@2x.png"); - browser.retina = false; + const [url, options = {}] = vi.mocked(leaflet.tileLayer).mock.calls[0]; + expect(url).toBe("https://tile.openstreetmap.org/{z}/{x}/{y}.png"); + // OSM refuses a browser that sends neither, and the demo page's meta + // policy strips the referrer unless the tiles ask for it back. + expect(options.referrerPolicy).toBe("origin"); }); it("registers the RTL text plugin once, lazily, from our own host", async () => { const createBaseLayer = await setWebGL2(true); - await createBaseLayer(leaflet, map, false); - await createBaseLayer(leaflet, map, false); + await createBaseLayer(leaflet, map, false, TOKEN); + await createBaseLayer(leaflet, map, false, TOKEN); expect(setRTLTextPlugin).toHaveBeenCalledOnce(); expect(setRTLTextPlugin).toHaveBeenCalledWith( @@ -128,7 +160,7 @@ describe("createBaseLayer", () => { it("uses vector tiles when WebGL2 is available", async () => { const createBaseLayer = await setWebGL2(true); - await createBaseLayer(leaflet, map, false); + await createBaseLayer(leaflet, map, false, TOKEN); expect(maplibreGL).toHaveBeenCalledOnce(); expect(maplibreLayer.addTo).toHaveBeenCalledWith(map); @@ -144,7 +176,7 @@ describe("createBaseLayer", () => { }) ); - await createBaseLayer(leaflet, map, false); + await createBaseLayer(leaflet, map, false, TOKEN); expect(isRaster()).toBe(true); }); @@ -157,7 +189,7 @@ describe("createBaseLayer", () => { throw new Error("Failed to initialize WebGL"); }); - await createBaseLayer(leaflet, map, false); + await createBaseLayer(leaflet, map, false, TOKEN); expect(isRaster()).toBe(true); expect(maplibreLayer.remove).toHaveBeenCalled(); @@ -172,7 +204,7 @@ describe("createBaseLayer", () => { throw new Error("nothing to remove"); }); - await createBaseLayer(leaflet, map, false); + await createBaseLayer(leaflet, map, false, TOKEN); expect(isRaster()).toBe(true); }); @@ -185,7 +217,7 @@ describe("setDarkMode", () => { it("swaps the style, and ignores a repeat of the current mode", async () => { const createBaseLayer = await setWebGL2(true); - const baseLayer = await createBaseLayer(leaflet, map, false); + const baseLayer = await createBaseLayer(leaflet, map, false, TOKEN); baseLayer.setDarkMode(true); await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce()); @@ -199,7 +231,7 @@ describe("setDarkMode", () => { // the tracked mode, and the next toggle to that mode would do nothing. it("can retry a mode whose request failed while another was in flight", async () => { const createBaseLayer = await setWebGL2(true); - const baseLayer = await createBaseLayer(leaflet, map, false); + const baseLayer = await createBaseLayer(leaflet, map, false, TOKEN); const failing = vi.fn(async () => { throw new Error("offline"); @@ -246,7 +278,7 @@ describe("setDarkMode", () => { // The layer only settles once its first style resolves, so let that one // through before the map exists to switch. - const pending = createBaseLayer(leaflet, map, false); + const pending = createBaseLayer(leaflet, map, false, TOKEN); await vi.waitFor(() => expect(resolvers).toHaveLength(1)); resolvers.shift()!(styleResponse("light")); const baseLayer = await pending; @@ -278,7 +310,7 @@ describe("WebGL context loss", () => { it("falls back to raster tiles when the context stays lost", async () => { const createBaseLayer = await setWebGL2(true); - await createBaseLayer(leaflet, map, false); + await createBaseLayer(leaflet, map, false, TOKEN); expect(leaflet.tileLayer).not.toHaveBeenCalled(); glHandlers.webglcontextlost(); @@ -290,7 +322,7 @@ describe("WebGL context loss", () => { it("keeps the vector layer when the context comes back", async () => { const createBaseLayer = await setWebGL2(true); - await createBaseLayer(leaflet, map, false); + await createBaseLayer(leaflet, map, false, TOKEN); glHandlers.webglcontextlost(); glHandlers.webglcontextrestored(); @@ -306,7 +338,7 @@ describe("WebGL context loss", () => { it("waits for the page to be visible before falling back", async () => { const hidden = vi.spyOn(document, "hidden", "get").mockReturnValue(true); const createBaseLayer = await setWebGL2(true); - await createBaseLayer(leaflet, map, false); + await createBaseLayer(leaflet, map, false, TOKEN); glHandlers.webglcontextlost(); vi.runAllTimers(); @@ -322,7 +354,7 @@ describe("WebGL context loss", () => { it("keeps the vector layer when a hidden page gets its context back", async () => { const hidden = vi.spyOn(document, "hidden", "get").mockReturnValue(true); const createBaseLayer = await setWebGL2(true); - await createBaseLayer(leaflet, map, false); + await createBaseLayer(leaflet, map, false, TOKEN); glHandlers.webglcontextlost(); glHandlers.webglcontextrestored(); @@ -337,7 +369,7 @@ describe("WebGL context loss", () => { it("stops listening for visibility once it has fallen back", async () => { const remove = vi.spyOn(document, "removeEventListener"); const createBaseLayer = await setWebGL2(true); - await createBaseLayer(leaflet, map, false); + await createBaseLayer(leaflet, map, false, TOKEN); glHandlers.webglcontextlost(); vi.runAllTimers(); @@ -354,7 +386,7 @@ describe("WebGL context loss", () => { it("stops answering theme changes once it has fallen back", async () => { const createBaseLayer = await setWebGL2(true); - const baseLayer = await createBaseLayer(leaflet, map, false); + const baseLayer = await createBaseLayer(leaflet, map, false, TOKEN); glHandlers.webglcontextlost(); vi.runAllTimers(); @@ -363,3 +395,95 @@ describe("WebGL context loss", () => { expect(glMap.setStyle).not.toHaveBeenCalled(); }); }); + +// A refused request leaves the source dead: the TileJSON is fetched once and +// MapLibre never retries it, so the map stays blank until the style is +// applied again. +describe("recovering from a refused token", () => { + it("asks for a new token and re-applies the style once it arrives", async () => { + const createBaseLayer = await setWebGL2(true); + await createBaseLayer(leaflet, map, false, TOKEN); + glMap.setStyle.mockClear(); + + glHandlers.error({ error: { status: 403 } }); + expect(refreshMapTilesToken).toHaveBeenCalled(); + + emitToken("fresh-token"); + await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce()); + }); + + it("does not keep asking while the proxy refuses for another reason", async () => { + const createBaseLayer = await setWebGL2(true); + await createBaseLayer(leaflet, map, false, TOKEN); + + for (let i = 0; i < 5; i++) { + glHandlers.error({ error: { status: 403 } }); + } + + expect(refreshMapTilesToken).toHaveBeenCalledOnce(); + }); + + // The style itself always loads - it is a local file - so applying one while + // the token is still stale says nothing about whether requests get through. + it("still recovers when the theme changes before the token arrives", async () => { + const createBaseLayer = await setWebGL2(true); + const baseLayer = await createBaseLayer(leaflet, map, false, TOKEN); + glMap.setStyle.mockClear(); + + glHandlers.error({ error: { status: 403 } }); + baseLayer.setDarkMode(true); + await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce()); + + emitToken("fresh-token"); + await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledTimes(2)); + }); + + // During a restart the proxy can be unregistered rather than refusing, and a + // dropped connection reports no status at all. Both leave the source dead. + it.each([[404], [undefined]])( + "also recovers from status %s", + async (status) => { + const createBaseLayer = await setWebGL2(true); + await createBaseLayer(leaflet, map, false, TOKEN); + glMap.setStyle.mockClear(); + + glHandlers.error({ error: { status } }); + expect(refreshMapTilesToken).toHaveBeenCalled(); + + emitToken("fresh-token"); + await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce()); + } + ); + + it("ignores errors that are not a refusal", async () => { + const createBaseLayer = await setWebGL2(true); + await createBaseLayer(leaflet, map, false, TOKEN); + + glHandlers.error({ error: { status: 500 } }); + + expect(refreshMapTilesToken).not.toHaveBeenCalled(); + }); + + it("leaves a working map alone when the token is merely refreshed", async () => { + const createBaseLayer = await setWebGL2(true); + await createBaseLayer(leaflet, map, false, TOKEN); + glMap.setStyle.mockClear(); + + emitToken("fresh-token"); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + expect(glMap.setStyle).not.toHaveBeenCalled(); + }); + + it("redraws the raster layer so refused tiles are asked for again", async () => { + const createBaseLayer = await setWebGL2(false); + await createBaseLayer(leaflet, map, false, TOKEN); + + emitToken("fresh-token"); + + expect(rasterLayer.options.token).toBe("fresh-token"); + expect(rasterLayer.redraw).toHaveBeenCalled(); + }); +}); diff --git a/test/data/map_tiles.test.ts b/test/data/map_tiles.test.ts new file mode 100644 index 0000000000..ff4defa9d3 --- /dev/null +++ b/test/data/map_tiles.test.ts @@ -0,0 +1,356 @@ +import type { Connection } from "home-assistant-js-websocket"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// MapLibre hands tile URLs to a worker, which has no document to resolve a +// relative URL against. Measured: without absolute URLs nothing loads at all - +// not the TileJSON, not the glyphs, not a tile - and MapLibre reports no error, +// so this is worth pinning down. + +// Cast connects to an instance on another host, so the proxy is not on the +// origin serving the page; everywhere else the two are the same. +const connectionWith = ( + { hassUrl }: { hassUrl?: string } = {}, + ...tokens: string[] +) => { + let call = 0; + const listeners: Record void> = {}; + return { + options: { auth: { data: { hassUrl: hassUrl ?? location.origin } } }, + sendMessagePromise: vi.fn(async () => ({ + token: tokens[Math.min(call++, tokens.length - 1)], + })), + addEventListener: vi.fn((event: string, cb: () => void) => { + listeners[event] = cb; + }), + removeEventListener: vi.fn(), + listeners, + } as unknown as Connection & { listeners: Record void> }; +}; + +// Mirrors BLOCKING_DELAYS_MS, which is not exported. +const BLOCKING_ATTEMPTS = 3; + +const load = async () => { + vi.resetModules(); + return import("../../src/data/map_tiles"); +}; + +describe("withMapTilesToken", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("makes relative URLs absolute", async () => { + const { withMapTilesToken } = await load(); + + expect(withMapTilesToken("/api/map_tiles/tilejson.json")).toBe( + `${location.origin}/api/map_tiles/tilejson.json` + ); + }); + + it("adds the token to proxy URLs once there is one", async () => { + const { ensureMapTilesToken, withMapTilesToken } = await load(); + await ensureMapTilesToken(connectionWith({}, "abc123")); + + const url = new URL(withMapTilesToken("/api/map_tiles/vector/1/0/0.mvt")); + expect(url.searchParams.get("token")).toBe("abc123"); + expect(url.pathname).toBe("/api/map_tiles/vector/1/0/0.mvt"); + }); + + it("leaves anything outside the proxy alone", async () => { + const { ensureMapTilesToken, withMapTilesToken } = await load(); + await ensureMapTilesToken(connectionWith({}, "abc123")); + + const url = new URL( + withMapTilesToken("https://example.com/tiles/1/0/0.png") + ); + expect(url.searchParams.get("token")).toBeNull(); + expect(url.href).toBe("https://example.com/tiles/1/0/0.png"); + }); + + it("still returns an absolute URL when there is no token", async () => { + const { withMapTilesToken } = await load(); + + expect( + withMapTilesToken("/api/map_tiles/fonts/noto_sans_regular/0-255.pbf") + ).toBe( + `${location.origin}/api/map_tiles/fonts/noto_sans_regular/0-255.pbf` + ); + }); +}); + +describe("ensureMapTilesToken", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("asks the backend once and reuses the answer", async () => { + const { ensureMapTilesToken } = await load(); + const connection = connectionWith({}, "abc123"); + + expect(await ensureMapTilesToken(connection)).toBe("abc123"); + expect(await ensureMapTilesToken(connection)).toBe("abc123"); + expect(connection.sendMessagePromise).toHaveBeenCalledTimes(1); + }); + + // A backend without the proxy must not hold the map hostage while the + // retries run; the map falls back to loading nothing rather than to nothing + // being drawn at all. + it("gives up quickly on a backend that does not answer", async () => { + const { ensureMapTilesToken } = await load(); + const connection = { + options: { auth: { data: { hassUrl: location.origin } } }, + sendMessagePromise: vi.fn(async () => { + throw new Error("unknown command"); + }), + } as unknown as Connection; + + const started = Date.now(); + expect(await ensureMapTilesToken(connection)).toBeUndefined(); + expect(Date.now() - started).toBeLessThan(3000); + }); + + it("tells subscribers when a token arrives, for Leaflet's baked template", async () => { + const { ensureMapTilesToken, subscribeMapTilesToken } = await load(); + const listener = vi.fn(); + subscribeMapTilesToken(listener); + + await ensureMapTilesToken(connectionWith({}, "abc123")); + + expect(listener).toHaveBeenCalledWith("abc123"); + }); +}); + +// A token can expire before the refresh interval comes round, so recovery +// hangs on the reconnect and on retrying a refused request. +describe("recovering a stale token", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("fetches a new token when the connection comes back", async () => { + const { ensureMapTilesToken, subscribeMapTilesToken } = await load(); + const connection = connectionWith({}, "first", "second") as Connection & { + listeners: Record void>; + }; + await ensureMapTilesToken(connection); + + const listener = vi.fn(); + subscribeMapTilesToken(listener); + connection.listeners.ready(); + await vi.waitFor(() => expect(listener).toHaveBeenCalledWith("second")); + }); + + it("asks once for a refresh however many tiles were refused", async () => { + const { ensureMapTilesToken, refreshMapTilesToken } = await load(); + const connection = connectionWith({}, "first", "second"); + await ensureMapTilesToken(connection); + expect(connection.sendMessagePromise).toHaveBeenCalledTimes(1); + + await Promise.all([ + refreshMapTilesToken(), + refreshMapTilesToken(), + refreshMapTilesToken(), + ]); + + expect(connection.sendMessagePromise).toHaveBeenCalledTimes(2); + }); + + it("keeps the old token when the refresh fails", async () => { + const { ensureMapTilesToken, refreshMapTilesToken, withMapTilesToken } = + await load(); + const connection = connectionWith({}, "first"); + await ensureMapTilesToken(connection); + + vi.mocked(connection.sendMessagePromise).mockRejectedValueOnce( + new Error("disconnected") + ); + await refreshMapTilesToken(); + + expect( + new URL( + withMapTilesToken("/api/map_tiles/vector/1/0/0.mvt") + ).searchParams.get("token") + ).toBe("first"); + }); +}); + +describe("asking for a token once", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("shares one attempt across every map on the dashboard", async () => { + const { ensureMapTilesToken } = await load(); + const connection = connectionWith({}, "abc123"); + + await Promise.all([ + ensureMapTilesToken(connection), + ensureMapTilesToken(connection), + ensureMapTilesToken(connection), + ]); + + expect(connection.sendMessagePromise).toHaveBeenCalledTimes(1); + }); + + // Without sharing, a backend that has no proxy yet gets every retry of every + // map: three blocking and four background attempts each. + it("shares the retries too when the backend does not answer", async () => { + const { ensureMapTilesToken } = await load(); + const connection = { + options: { auth: { data: { hassUrl: location.origin } } }, + sendMessagePromise: vi.fn(async () => { + throw new Error("unknown command"); + }), + addEventListener: vi.fn(), + } as unknown as Connection; + + await Promise.all([ + ensureMapTilesToken(connection), + ensureMapTilesToken(connection), + ensureMapTilesToken(connection), + ]); + + expect(connection.sendMessagePromise).toHaveBeenCalledTimes( + BLOCKING_ATTEMPTS + ); + }); + + it("does not ask at all in the demo, which has no proxy", async () => { + vi.stubGlobal("__DEMO__", true); + const { ensureMapTilesToken } = await load(); + const connection = connectionWith({}, "abc123"); + + expect(await ensureMapTilesToken(connection)).toBeUndefined(); + expect(connection.sendMessagePromise).not.toHaveBeenCalled(); + }); +}); + +// Cast serves the frontend from its own host and connects to an instance +// elsewhere, so the proxy is not on the origin this page came from. +describe("resolving against the instance", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("points proxy URLs at the instance, not at this page", async () => { + const { ensureMapTilesToken, withMapTilesToken } = await load(); + await ensureMapTilesToken( + connectionWith({ hassUrl: "https://instance.local:8123" }, "abc123") + ); + + const url = new URL(withMapTilesToken("/api/map_tiles/tilejson.json")); + expect(url.origin).toBe("https://instance.local:8123"); + expect(url.searchParams.get("token")).toBe("abc123"); + }); + + // MapLibre resolves the style's paths against the document before handing + // them over, so by then they carry the wrong host. + it("moves a URL already resolved against this page", async () => { + const { ensureMapTilesToken, withMapTilesToken } = await load(); + await ensureMapTilesToken( + connectionWith({ hassUrl: "https://instance.local:8123" }, "abc123") + ); + + expect( + new URL( + withMapTilesToken(`${location.origin}/api/map_tiles/vector/1/0/0.mvt`) + ).origin + ).toBe("https://instance.local:8123"); + }); + + it("keeps template placeholders intact", async () => { + const { ensureMapTilesToken, mapTilesUrl } = await load(); + await ensureMapTilesToken( + connectionWith({ hassUrl: "https://instance.local:8123" }, "abc123") + ); + + expect(mapTilesUrl("/api/map_tiles/raster/{z}/{x}/{y}.png")).toBe( + "https://instance.local:8123/api/map_tiles/raster/{z}/{x}/{y}.png" + ); + }); +}); + +// Cast builds a fresh connection per connect message while this module lives +// on, so anything holding the first one stops working after a reconnect. +describe("following the current connection", () => { + beforeEach(() => { + vi.resetModules(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("refreshes over the newest connection", async () => { + const { ensureMapTilesToken } = await load(); + const first = connectionWith({}, "abc123"); + const second = connectionWith({}, "def456"); + + await ensureMapTilesToken(first); + await ensureMapTilesToken(second); + vi.mocked(first.sendMessagePromise).mockClear(); + + await vi.advanceTimersByTimeAsync(20 * 60 * 1000); + + expect(second.sendMessagePromise).toHaveBeenCalled(); + expect(first.sendMessagePromise).not.toHaveBeenCalled(); + }); + + it("moves the reconnect listener to it", async () => { + const { ensureMapTilesToken } = await load(); + const first = connectionWith({}, "abc123"); + const second = connectionWith({}, "def456"); + + await ensureMapTilesToken(first); + await ensureMapTilesToken(second); + + expect(first.removeEventListener).toHaveBeenCalledWith( + "ready", + expect.any(Function) + ); + expect(second.addEventListener).toHaveBeenCalledWith( + "ready", + expect.any(Function) + ); + }); + + // Every attempt failing left no interval behind, so a token that only + // arrived on the reconnect was never refreshed again. + it("starts refreshing a token that first arrived on a reconnect", async () => { + const { ensureMapTilesToken } = await load(); + let answer = false; + const listeners: Record void> = {}; + const connection = { + options: { auth: { data: { hassUrl: location.origin } } }, + sendMessagePromise: vi.fn(async () => { + if (!answer) { + throw new Error("unknown command"); + } + return { token: "abc123" }; + }), + addEventListener: vi.fn((event: string, cb: () => void) => { + listeners[event] = cb; + }), + removeEventListener: vi.fn(), + } as unknown as Connection; + + const pending = ensureMapTilesToken(connection); + await vi.advanceTimersByTimeAsync(40000); + expect(await pending).toBeUndefined(); + + answer = true; + listeners.ready(); + await vi.waitFor(() => expect(connection.sendMessagePromise).toBeCalled()); + vi.mocked(connection.sendMessagePromise).mockClear(); + + await vi.advanceTimersByTimeAsync(20 * 60 * 1000); + + expect(connection.sendMessagePromise).toHaveBeenCalled(); + }); +}); diff --git a/test/e2e/app/src/onboarding.ts b/test/e2e/app/src/onboarding.ts index 8f5a2a16ef..d4c590b1c8 100644 --- a/test/e2e/app/src/onboarding.ts +++ b/test/e2e/app/src/onboarding.ts @@ -177,6 +177,19 @@ export async function setupOnboardingMocks( ): Promise { const calls: OnboardingCalls = { tokenRequests: [] }; + // The location step shows a map. This app builds with __DEMO__ true, so its + // tiles come from the demo upstreams; answering those here keeps CI off the + // live servers. + await Promise.all( + [ + "https://vector.openstreetmap.org/**", + "https://tiles.versatiles.org/**", + "https://tile.openstreetmap.org/**", + ].map((upstream) => + page.route(upstream, (route) => route.fulfill({ status: 404, body: "" })) + ) + ); + await page.route("**/api/onboarding**", async (route) => { const request = route.request(); const pathname = new URL(request.url()).pathname;