diff --git a/src/islands/maps/MapExplorer.tsx b/src/islands/maps/MapExplorer.tsx index a08675b..ba83eec 100644 --- a/src/islands/maps/MapExplorer.tsx +++ b/src/islands/maps/MapExplorer.tsx @@ -10,10 +10,16 @@ import { resolveStyle, MAP_STYLES, haversineMeters, formatDistance, type StyleCh import { ddToDms, ddToUtm, encodeGeohash, formatDd, type LatLng } from '@/tools/geo/coord.lib'; import type { Lang } from '@/i18n/config'; -// Pre-fetch a MapLibre style URL and inline any TileJSON-backed vector sources -// so MapLibre never needs to make a separate TileJSON fetch. This works around -// Cloudflare edge-caching issues where the style URL returns stale/broken content -// at certain CDN nodes. cache:'no-cache' forces revalidation with the origin server. +// Rewrite OFM absolute URLs to go through our same-origin /ofm/ proxy, which +// fetches from tiles.openfreemap.org server-side over Cloudflare's backbone. +// This bypasses the broken Singapore CDN edge that serves corrupt style/tile +// responses to Southeast Asian users. +const ofm = (u: unknown): unknown => + typeof u === 'string' ? u.replace('https://tiles.openfreemap.org/', '/ofm/') : u; + +// Pre-fetch a MapLibre style URL through our proxy, inline any TileJSON-backed +// vector sources, and rewrite all remaining OFM URLs so every subsequent fetch +// (glyphs, sprites, tiles) also goes through the same-origin proxy. // eslint-disable-next-line @typescript-eslint/no-explicit-any async function fetchResolvedStyle(url: string): Promise> { try { @@ -21,37 +27,46 @@ async function fetchResolvedStyle(url: string): Promise }; - // Validate: must look like a MapLibre style (has version + sources) + const style = await res.json() as Record; if (!style.version || !style.sources) { console.warn('[map] style response invalid (no version/sources):', JSON.stringify(style).slice(0, 200)); return url; } + // Rewrite top-level glyph/sprite URLs through our proxy + if (typeof style.glyphs === 'string') style.glyphs = ofm(style.glyphs); + if (typeof style.sprite === 'string') style.sprite = ofm(style.sprite); + // Rewrite sources and inline vector TileJSON await Promise.all( + // eslint-disable-next-line @typescript-eslint/no-explicit-any Object.values(style.sources).map(async (src) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const s = src as Record; + // Rewrite any existing raster tile URLs + if (Array.isArray(s.tiles)) s.tiles = s.tiles.map(ofm); + // Inline vector TileJSON and rewrite tile URLs through proxy if (s.type === 'vector' && typeof s.url === 'string' && !s.tiles) { + const tjUrl = ofm(s.url) as string; try { - console.log('[map] fetching TileJSON', s.url); - const tj = await fetch(s.url, { cache: 'no-cache' }).then(r => r.json()) as { + console.log('[map] fetching TileJSON', tjUrl); + const tj = await fetch(tjUrl, { cache: 'no-cache' }).then(r => r.json()) as { tiles?: string[]; minzoom?: number; maxzoom?: number; }; console.log('[map] TileJSON tiles[0]:', tj.tiles?.[0], 'maxzoom:', tj.maxzoom); if (tj.tiles?.length) { - s.tiles = tj.tiles; + s.tiles = tj.tiles.map(ofm); if (tj.minzoom != null) s.minzoom = tj.minzoom; if (tj.maxzoom != null) s.maxzoom = tj.maxzoom; delete s.url; } } catch (e) { console.error('[map] TileJSON fetch failed:', e); + s.url = tjUrl; // leave the proxy URL so MapLibre can retry via proxy } } }) ); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const omt = (style.sources as Record)?.openmaptiles; + const omt = style.sources?.openmaptiles; console.log('[map] openmaptiles source after resolve:', omt?.tiles?.[0] ?? '(still url-based: ' + omt?.url + ')'); return style; } catch (e) { diff --git a/src/tools/geo/map-styles.lib.test.ts b/src/tools/geo/map-styles.lib.test.ts index 76adfd1..7f73f15 100644 --- a/src/tools/geo/map-styles.lib.test.ts +++ b/src/tools/geo/map-styles.lib.test.ts @@ -10,8 +10,8 @@ describe('resolveStyle', () => { expect(resolveStyle('positron', 'dark').id).toBe('positron'); expect(resolveStyle('dark', 'light').id).toBe('dark'); }); - it('produces an OpenFreeMap style url', () => { - expect(resolveStyle('bright', 'light').url).toBe('https://tiles.openfreemap.org/styles/bright'); + it('produces a proxied style url', () => { + expect(resolveStyle('bright', 'light').url).toBe('/ofm/styles/bright'); }); }); diff --git a/src/tools/geo/map-styles.lib.ts b/src/tools/geo/map-styles.lib.ts index c017903..88a2d7f 100644 --- a/src/tools/geo/map-styles.lib.ts +++ b/src/tools/geo/map-styles.lib.ts @@ -15,7 +15,7 @@ export const MAP_STYLES: { id: StyleChoice; label: string }[] = [ /** Resolve a style choice (+ current site theme) to a concrete OpenFreeMap style. */ export function resolveStyle(choice: StyleChoice, siteTheme: 'light' | 'dark'): { id: ConcreteStyle; url: string } { const id: ConcreteStyle = choice === 'auto' ? (siteTheme === 'dark' ? 'dark' : 'liberty') : choice; - return { id, url: `https://tiles.openfreemap.org/styles/${id}` }; + return { id, url: `/ofm/styles/${id}` }; } /** Great-circle distance between two points, in metres. */ diff --git a/worker/index.js b/worker/index.js index bcc7867..38a5df5 100644 --- a/worker/index.js +++ b/worker/index.js @@ -53,6 +53,32 @@ export default { return new Response(res.body, { status: res.status, headers }); } + // Same-origin proxy for OpenFreeMap tile/style/glyph requests. The OFM CDN + // (Cloudflare Singapore edge) sometimes returns corrupt responses to users in + // Southeast Asia — both style JSON and vector PBF tiles. Proxying server-side + // through our Worker fetches from OFM's origin over CF's backbone, bypassing + // the broken edge node entirely. Tiles are versioned by timestamp in the URL + // so they're safe to cache long-term; style/TileJSON use a short TTL. + if (url.pathname.startsWith('/ofm/')) { + if (request.method !== 'GET' && request.method !== 'HEAD') { + return new Response('Method not allowed', { status: 405 }); + } + const upstream = 'https://tiles.openfreemap.org/' + url.pathname.slice('/ofm/'.length) + url.search; + const res = await fetch(upstream, { + headers: { 'user-agent': 'goodwebtools-ofm-proxy' }, + cf: { cacheEverything: true, cacheTtl: 86400 }, + }); + if (!res.ok) { + return new Response('Upstream OFM fetch failed', { status: res.status || 502 }); + } + const headers = new Headers(res.headers); + headers.set('access-control-allow-origin', '*'); + headers.delete('set-cookie'); + const isPbf = url.pathname.endsWith('.pbf'); + headers.set('cache-control', isPbf ? 'public, max-age=2592000, immutable' : 'public, max-age=3600'); + return new Response(res.body, { status: res.status, headers }); + } + if (url.pathname.startsWith('/models/')) { if (request.method !== 'GET' && request.method !== 'HEAD') { return new Response('Method not allowed', { status: 405 });