Skip to content

Commit 0ba01bb

Browse files
authored
Merge pull request #173 from slaveofcode/develop
Promote to production: fix blank map tiles via OFM proxy
2 parents 2e8a700 + db34a58 commit 0ba01bb

4 files changed

Lines changed: 54 additions & 13 deletions

File tree

src/islands/maps/MapExplorer.tsx

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,48 +10,63 @@ import { resolveStyle, MAP_STYLES, haversineMeters, formatDistance, type StyleCh
1010
import { ddToDms, ddToUtm, encodeGeohash, formatDd, type LatLng } from '@/tools/geo/coord.lib';
1111
import type { Lang } from '@/i18n/config';
1212

13-
// Pre-fetch a MapLibre style URL and inline any TileJSON-backed vector sources
14-
// so MapLibre never needs to make a separate TileJSON fetch. This works around
15-
// Cloudflare edge-caching issues where the style URL returns stale/broken content
16-
// at certain CDN nodes. cache:'no-cache' forces revalidation with the origin server.
13+
// Rewrite OFM absolute URLs to go through our same-origin /ofm/ proxy, which
14+
// fetches from tiles.openfreemap.org server-side over Cloudflare's backbone.
15+
// This bypasses the broken Singapore CDN edge that serves corrupt style/tile
16+
// responses to Southeast Asian users.
17+
const ofm = (u: unknown): unknown =>
18+
typeof u === 'string' ? u.replace('https://tiles.openfreemap.org/', '/ofm/') : u;
19+
20+
// Pre-fetch a MapLibre style URL through our proxy, inline any TileJSON-backed
21+
// vector sources, and rewrite all remaining OFM URLs so every subsequent fetch
22+
// (glyphs, sprites, tiles) also goes through the same-origin proxy.
1723
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1824
async function fetchResolvedStyle(url: string): Promise<string | Record<string, any>> {
1925
try {
2026
const res = await fetch(url, { cache: 'no-cache' });
2127
console.log('[map] style fetch', url, res.status, res.ok);
2228
if (!res.ok) return url;
2329
// eslint-disable-next-line @typescript-eslint/no-explicit-any
24-
const style = await res.json() as { version?: number; sources?: Record<string, any> };
25-
// Validate: must look like a MapLibre style (has version + sources)
30+
const style = await res.json() as Record<string, any>;
2631
if (!style.version || !style.sources) {
2732
console.warn('[map] style response invalid (no version/sources):', JSON.stringify(style).slice(0, 200));
2833
return url;
2934
}
35+
// Rewrite top-level glyph/sprite URLs through our proxy
36+
if (typeof style.glyphs === 'string') style.glyphs = ofm(style.glyphs);
37+
if (typeof style.sprite === 'string') style.sprite = ofm(style.sprite);
38+
// Rewrite sources and inline vector TileJSON
3039
await Promise.all(
40+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
3141
Object.values(style.sources).map(async (src) => {
3242
// eslint-disable-next-line @typescript-eslint/no-explicit-any
3343
const s = src as Record<string, any>;
44+
// Rewrite any existing raster tile URLs
45+
if (Array.isArray(s.tiles)) s.tiles = s.tiles.map(ofm);
46+
// Inline vector TileJSON and rewrite tile URLs through proxy
3447
if (s.type === 'vector' && typeof s.url === 'string' && !s.tiles) {
48+
const tjUrl = ofm(s.url) as string;
3549
try {
36-
console.log('[map] fetching TileJSON', s.url);
37-
const tj = await fetch(s.url, { cache: 'no-cache' }).then(r => r.json()) as {
50+
console.log('[map] fetching TileJSON', tjUrl);
51+
const tj = await fetch(tjUrl, { cache: 'no-cache' }).then(r => r.json()) as {
3852
tiles?: string[]; minzoom?: number; maxzoom?: number;
3953
};
4054
console.log('[map] TileJSON tiles[0]:', tj.tiles?.[0], 'maxzoom:', tj.maxzoom);
4155
if (tj.tiles?.length) {
42-
s.tiles = tj.tiles;
56+
s.tiles = tj.tiles.map(ofm);
4357
if (tj.minzoom != null) s.minzoom = tj.minzoom;
4458
if (tj.maxzoom != null) s.maxzoom = tj.maxzoom;
4559
delete s.url;
4660
}
4761
} catch (e) {
4862
console.error('[map] TileJSON fetch failed:', e);
63+
s.url = tjUrl; // leave the proxy URL so MapLibre can retry via proxy
4964
}
5065
}
5166
})
5267
);
5368
// eslint-disable-next-line @typescript-eslint/no-explicit-any
54-
const omt = (style.sources as Record<string, any>)?.openmaptiles;
69+
const omt = style.sources?.openmaptiles;
5570
console.log('[map] openmaptiles source after resolve:', omt?.tiles?.[0] ?? '(still url-based: ' + omt?.url + ')');
5671
return style;
5772
} catch (e) {

src/tools/geo/map-styles.lib.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ describe('resolveStyle', () => {
1010
expect(resolveStyle('positron', 'dark').id).toBe('positron');
1111
expect(resolveStyle('dark', 'light').id).toBe('dark');
1212
});
13-
it('produces an OpenFreeMap style url', () => {
14-
expect(resolveStyle('bright', 'light').url).toBe('https://tiles.openfreemap.org/styles/bright');
13+
it('produces a proxied style url', () => {
14+
expect(resolveStyle('bright', 'light').url).toBe('/ofm/styles/bright');
1515
});
1616
});
1717

src/tools/geo/map-styles.lib.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export const MAP_STYLES: { id: StyleChoice; label: string }[] = [
1515
/** Resolve a style choice (+ current site theme) to a concrete OpenFreeMap style. */
1616
export function resolveStyle(choice: StyleChoice, siteTheme: 'light' | 'dark'): { id: ConcreteStyle; url: string } {
1717
const id: ConcreteStyle = choice === 'auto' ? (siteTheme === 'dark' ? 'dark' : 'liberty') : choice;
18-
return { id, url: `https://tiles.openfreemap.org/styles/${id}` };
18+
return { id, url: `/ofm/styles/${id}` };
1919
}
2020

2121
/** Great-circle distance between two points, in metres. */

worker/index.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,32 @@ export default {
5353
return new Response(res.body, { status: res.status, headers });
5454
}
5555

56+
// Same-origin proxy for OpenFreeMap tile/style/glyph requests. The OFM CDN
57+
// (Cloudflare Singapore edge) sometimes returns corrupt responses to users in
58+
// Southeast Asia — both style JSON and vector PBF tiles. Proxying server-side
59+
// through our Worker fetches from OFM's origin over CF's backbone, bypassing
60+
// the broken edge node entirely. Tiles are versioned by timestamp in the URL
61+
// so they're safe to cache long-term; style/TileJSON use a short TTL.
62+
if (url.pathname.startsWith('/ofm/')) {
63+
if (request.method !== 'GET' && request.method !== 'HEAD') {
64+
return new Response('Method not allowed', { status: 405 });
65+
}
66+
const upstream = 'https://tiles.openfreemap.org/' + url.pathname.slice('/ofm/'.length) + url.search;
67+
const res = await fetch(upstream, {
68+
headers: { 'user-agent': 'goodwebtools-ofm-proxy' },
69+
cf: { cacheEverything: true, cacheTtl: 86400 },
70+
});
71+
if (!res.ok) {
72+
return new Response('Upstream OFM fetch failed', { status: res.status || 502 });
73+
}
74+
const headers = new Headers(res.headers);
75+
headers.set('access-control-allow-origin', '*');
76+
headers.delete('set-cookie');
77+
const isPbf = url.pathname.endsWith('.pbf');
78+
headers.set('cache-control', isPbf ? 'public, max-age=2592000, immutable' : 'public, max-age=3600');
79+
return new Response(res.body, { status: res.status, headers });
80+
}
81+
5682
if (url.pathname.startsWith('/models/')) {
5783
if (request.method !== 'GET' && request.method !== 'HEAD') {
5884
return new Response('Method not allowed', { status: 405 });

0 commit comments

Comments
 (0)