Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 25 additions & 10 deletions src/islands/maps/MapExplorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,48 +10,63 @@ 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<string | Record<string, any>> {
try {
const res = await fetch(url, { cache: 'no-cache' });
console.log('[map] style fetch', url, res.status, res.ok);
if (!res.ok) return url;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const style = await res.json() as { version?: number; sources?: Record<string, any> };
// Validate: must look like a MapLibre style (has version + sources)
const style = await res.json() as Record<string, any>;
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<string, any>;
// 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<string, any>)?.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) {
Expand Down
4 changes: 2 additions & 2 deletions src/tools/geo/map-styles.lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});

Expand Down
2 changes: 1 addition & 1 deletion src/tools/geo/map-styles.lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
26 changes: 26 additions & 0 deletions worker/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Loading