Skip to content

Commit da39ee9

Browse files
committed
feat: add Thunderforest OpenCycleMap as fallback for CyclOSM tile proxy
1 parent 38aefe5 commit da39ee9

3 files changed

Lines changed: 50 additions & 3 deletions

File tree

apps/api/.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ FLICKR_API_KEY=
6161
# CyclOSM tiles (optional, default uses public OSM France servers)
6262
# CYCLOSM_TILE_URL=https://{s}.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png
6363

64+
# Thunderforest OpenCycleMap (fallback when CyclOSM is down)
65+
# Sign up for a free Hobby Project key at https://www.thunderforest.com/
66+
THUNDERFOREST_API_KEY=
67+
6468
# Waymarked Trails cycling routes tiles (optional)
6569
# WAYMARKED_CYCLING_TILE_URL=https://tile.waymarkedtrails.org/cycling/{z}/{x}/{y}.png
6670

apps/api/src/routes/tiles.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@ function nextCyclOSMSubdomain(): string {
99
return sub;
1010
}
1111

12+
const THUNDERFOREST_SUBDOMAINS = ["a", "b", "c"] as const;
13+
let thunderforestSubdomainIndex = 0;
14+
15+
function nextThunderforestSubdomain(): string {
16+
const sub =
17+
THUNDERFOREST_SUBDOMAINS[thunderforestSubdomainIndex % THUNDERFOREST_SUBDOMAINS.length];
18+
thunderforestSubdomainIndex++;
19+
return sub;
20+
}
21+
1222
const TILE_PARAMS_SCHEMA = {
1323
type: "object" as const,
1424
required: ["z", "x", "y"],
@@ -56,11 +66,11 @@ export const tilesRoute: FastifyPluginAsync = async (fastify) => {
5666
headers: {
5767
"User-Agent": "OpenMapX/1.0 (+https://openmapx.org)",
5868
},
59-
signal: AbortSignal.timeout(15_000),
69+
signal: AbortSignal.timeout(10_000),
6070
});
6171

6272
if (!response.ok) {
63-
return reply.status(response.status).send({ message: "Upstream tile fetch failed" });
73+
throw new Error(`CyclOSM returned ${response.status}`);
6474
}
6575

6676
const buffer = Buffer.from(await response.arrayBuffer());
@@ -69,7 +79,35 @@ export const tilesRoute: FastifyPluginAsync = async (fastify) => {
6979
return reply.send(buffer);
7080
} catch (error) {
7181
req.log.warn({ err: error, z, x, y }, "CyclOSM tile fetch failed");
72-
return reply.status(502).send({ message: "CyclOSM tile provider unavailable" });
82+
83+
const thunderforestKey = process.env.THUNDERFOREST_API_KEY;
84+
if (!thunderforestKey) {
85+
return reply.status(502).send({ message: "CyclOSM tile provider unavailable" });
86+
}
87+
88+
const tfSub = nextThunderforestSubdomain();
89+
const tfUrl = `https://${tfSub}.tile.thunderforest.com/cycle/${z}/${x}/${y}.png?apikey=${thunderforestKey}`;
90+
91+
try {
92+
const tfResponse = await fetch(tfUrl, {
93+
headers: {
94+
"User-Agent": "OpenMapX/1.0 (+https://openmapx.org)",
95+
},
96+
signal: AbortSignal.timeout(10_000),
97+
});
98+
99+
if (!tfResponse.ok) {
100+
return reply.status(tfResponse.status).send({ message: "Fallback tile fetch failed" });
101+
}
102+
103+
const tfBuffer = Buffer.from(await tfResponse.arrayBuffer());
104+
reply.header("Cache-Control", "public, max-age=604800, s-maxage=604800");
105+
reply.type("image/png");
106+
return reply.send(tfBuffer);
107+
} catch (tfError) {
108+
req.log.warn({ err: tfError, z, x, y }, "Thunderforest fallback tile fetch failed");
109+
return reply.status(502).send({ message: "All cycling tile providers unavailable" });
110+
}
73111
}
74112
},
75113
});

infra/docker/.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ MAPILLARY_TOKEN=
3333
MAPILLARY_CLIENT_ID=
3434
MAPILLARY_CLIENT_SECRET=
3535

36+
# Cycling Tiles
37+
# Thunderforest OpenCycleMap (fallback when CyclOSM is down)
38+
# Sign up for a free Hobby Project key at https://www.thunderforest.com/
39+
THUNDERFOREST_API_KEY=
40+
3641
# Traffic
3742
# TRAFFIC_PROVIDER=tomtom
3843
TOMTOM_TRAFFIC_KEY=

0 commit comments

Comments
 (0)