Skip to content

Commit c1193ca

Browse files
bramkragtenclaude
andcommitted
Stop sending a referrer, and keep CARTO on the fallback for now
The only referrer a browser can send is its origin, and for a Nabu Casa instance that origin is a per-installation identifier. Sending it to a third party on every tile request is not a trade we want to make, and it turns out we do not have to: measured across several tiles and the TileJSON, vector.openstreetmap.org serves a browser with no Referer at all. So the vector layer sends none, and `transformRequest` goes with it. The raster fallback is the half that did need one. OSM's raster tiles block a refererless browser - with HTTP 200 and a PNG reading "Access blocked", so nothing would even look broken - and a browser cannot set a User-Agent instead. Rather than leak the origin for the devices least able to choose, that path keeps CARTO: for them it is not a regression, it is exactly what they see today, overlay and all. Both halves are temporary. Once maps.home-assistant.io proxies with a real application User-Agent, there is no referrer anywhere and the fallback moves to OSM raster with it. CARTO serves @2x, so retina tablets get sharp tiles at the same request count, where Leaflet's `detectRetina` would have fetched a zoom deeper at four times the requests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent d5a9635 commit c1193ca

2 files changed

Lines changed: 61 additions & 45 deletions

File tree

src/common/map/base-layer.ts

Lines changed: 35 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,11 @@ import type { Map as LeafletMap } from "leaflet";
33
import type { StyleSpecification } from "maplibre-gl";
44
import type { LeafletModuleType } from "../dom/setup-leaflet-map";
55

6-
// Only the raster fallback needs this: a Leaflet tile layer reads it, while
7-
// the vector layer takes its attribution from the source in the style, which
8-
// the TileJSON fills in. That is deliberately the better way around - the
9-
// credit follows whoever serves the tiles, and once that TileJSON is ours it
10-
// can be corrected remotely instead of in a release. Do not override it here:
11-
// upstream's currently omits "contributors", and that is theirs to fix or ours
12-
// to serve, not something to hardcode past.
13-
const OSM_ATTRIBUTION =
14-
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
6+
// The vector layer sets no attribution of its own: it takes the credit from the
7+
// source in the style, which the TileJSON fills in. That is deliberately the
8+
// better way around - the credit follows whoever serves the tiles, and once that
9+
// TileJSON is ours it can be corrected remotely instead of in a release. Do not
10+
// hardcode one past it.
1511

1612
// Shortbread vector tiles, served by the OpenStreetMap Foundation under
1713
// https://operations.osmfoundation.org/policies/vector/. Only their tile
@@ -22,12 +18,20 @@ const VECTOR_STYLES = {
2218
dark: "/static/map/dark.json",
2319
} as const;
2420

25-
// Raster tiles for browsers that cannot run MapLibre. MapLibre draws raster
26-
// sources through WebGL too, so the fallback has to stay a Leaflet tile layer.
27-
const RASTER_TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
28-
// The raster tiles stop at 19, the vector tiles at 14 and are overzoomed. Both
29-
// keep rendering up to MAP_MAX_ZOOM.
30-
const RASTER_MAX_NATIVE_ZOOM = 19;
21+
// Raster tiles for browsers that cannot run MapLibre - it draws raster sources
22+
// through WebGL as well, so the fallback has to stay a Leaflet tile layer.
23+
//
24+
// This is temporary and it is CARTO, the basemap this change replaces, overlay
25+
// and all. OSM's raster tiles block a browser that sends no Referer, and the
26+
// only referrer a browser can send is its origin - which for a Nabu Casa
27+
// instance is a per-installation identifier. Rather than leak that, the devices
28+
// on this path keep exactly what they see today until maps.home-assistant.io can
29+
// proxy raster with an application User-Agent, at which point no referrer is
30+
// needed anywhere.
31+
const RASTER_TILE_URL = "https://basemaps.cartocdn.com/rastertiles/voyager";
32+
const CARTO_ATTRIBUTION =
33+
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, ' +
34+
'&copy; <a href="https://carto.com/attributions">CARTO</a>';
3135

3236
// Browsers keep a limited number of live WebGL contexts - around 16 in Chrome -
3337
// and drop the oldest when a new one is created. A dashboard with more map
@@ -104,10 +108,11 @@ const createVectorLayer = async (
104108
// Renders CJK, kana and hangul with a font from the device, which is why
105109
// we only have to ship a tenth of the glyph set.
106110
localIdeographFontFamily: "sans-serif",
107-
// The page sets a same-origin referrer policy, but the OSMF asks for a
108-
// referrer to see which application their tiles are serving. Sending it
109-
// for the tiles alone keeps it to the origin, never the page URL.
110-
transformRequest: (url) => ({ url, referrerPolicy: "origin" }),
111+
// No referrer is set for these: the vector endpoint serves them without
112+
// one (measured), and the only referrer a browser can send is its origin,
113+
// which identifies a Nabu Casa installation. Identifying Home Assistant
114+
// properly is a job for maps.home-assistant.io, which can send a real
115+
// application User-Agent.
111116
});
112117
// The plugin builds the MapLibre map in `onAdd`, so this is where a
113118
// missing WebGL context, a blocked worker or a rejected blob URL throws.
@@ -220,20 +225,17 @@ const createRasterLayer = (
220225
map: LeafletMap
221226
): MapBaseLayer => {
222227
leaflet
223-
.tileLayer(RASTER_TILE_URL, {
224-
attribution: OSM_ATTRIBUTION,
225-
maxNativeZoom: RASTER_MAX_NATIVE_ZOOM,
226-
maxZoom: MAP_MAX_ZOOM,
227-
// The tile images inherit the page's same-origin referrer policy, which
228-
// sends nothing at all cross-origin. OSM's raster tile policy asks for a
229-
// referrer, and being blocked would take out the fallback on exactly the
230-
// devices that depend on it.
231-
referrerPolicy: "origin",
232-
// The devices that end up here are the old retina tablets, and OSM
233-
// serves no @2x raster tiles, so sharp tiles have to come from loading a
234-
// zoom level deeper at half the tile size.
235-
detectRetina: true,
236-
})
228+
.tileLayer(
229+
// The devices on this path are the old retina tablets. CARTO serves @2x,
230+
// so they get sharp tiles for the same number of requests - Leaflet's
231+
// `detectRetina` would instead fetch a zoom level deeper at four times
232+
// the requests, which is only worth it against a source without @2x.
233+
`${RASTER_TILE_URL}/{z}/{x}/{y}${leaflet.Browser.retina ? "@2x" : ""}.png`,
234+
{
235+
attribution: CARTO_ATTRIBUTION,
236+
maxZoom: MAP_MAX_ZOOM,
237+
}
238+
)
237239
.addTo(map);
238240

239241
return { setDarkMode: () => undefined };

test/common/map/base-layer.test.ts

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,11 @@ const STYLE = {
2626
};
2727

2828
const rasterLayer = { addTo: vi.fn() };
29+
// Kept as a local so tests can flip it: @types/leaflet has it readonly.
30+
const browser = { retina: false };
2931
const leaflet = {
3032
tileLayer: vi.fn(() => rasterLayer),
33+
Browser: browser,
3134
} as unknown as LeafletModuleType;
3235

3336
// `createVectorLayer` listens on both the MapLibre map and the Leaflet map.
@@ -81,18 +84,29 @@ describe("createBaseLayer", () => {
8184
expect(isRaster()).toBe(true);
8285
expect(maplibreGL).not.toHaveBeenCalled();
8386
expect(rasterLayer.addTo).toHaveBeenCalledWith(map);
84-
// The page sends no referrer cross-origin, and OSM's raster tile policy
85-
// asks for one. Being blocked would take out the fallback on exactly the
86-
// devices that depend on it.
87-
expect(vi.mocked(leaflet.tileLayer).mock.calls[0][1]).toMatchObject({
88-
referrerPolicy: "origin",
89-
// OSM serves no @2x raster tiles, and the devices that end up on the
90-
// fallback are the old retina tablets.
91-
detectRetina: true,
92-
// The vector layer gets this from the style's source instead, so the
93-
// raster layer is the only one that has to carry it itself.
94-
attribution: expect.stringContaining("openstreetmap.org/copyright"),
95-
});
87+
const [url, options = {}] = vi.mocked(leaflet.tileLayer).mock.calls[0];
88+
// No referrer: the only one a browser can send is its origin, which
89+
// identifies a Nabu Casa installation. The fallback source is chosen so it
90+
// does not need one.
91+
expect(options).not.toHaveProperty("referrerPolicy");
92+
// The vector layer takes its credit from the style's source instead, so the
93+
// raster layer is the only one carrying attribution itself - and it credits
94+
// both the data and whoever rendered it.
95+
expect(options.attribution).toContain("openstreetmap.org/copyright");
96+
expect(options.attribution).toContain("carto.com/attributions");
97+
expect(url).toMatch(/\{z\}\/\{x\}\/\{y\}/);
98+
});
99+
100+
// The devices on the fallback are the old retina tablets, and the source
101+
// serves @2x, so they get sharp tiles without quadrupling the requests.
102+
it("asks for @2x raster tiles on a retina screen", async () => {
103+
const createBaseLayer = await setWebGL2(false);
104+
browser.retina = true;
105+
106+
await createBaseLayer(leaflet, map, false);
107+
108+
expect(vi.mocked(leaflet.tileLayer).mock.calls[0][0]).toContain("@2x.png");
109+
browser.retina = false;
96110
});
97111

98112
it("uses vector tiles when WebGL2 is available", async () => {

0 commit comments

Comments
 (0)