fix(basemap-style): emit absolute URLs so MapLibre worker can fetch

The previous rewrite produced relative `/api/basemap-tile/…` URLs in
the resolved style. MapLibre loads vector tiles + sprites + glyphs
inside a Web Worker, where relative URLs have no base context and
fetch() rejects them with "Failed to construct 'Request': Failed to
parse URL". Browser console filled with one such error per tile
request → empty cream map all over again.

Fix: prepend the request origin (honoring Traefik's
x-forwarded-proto / x-forwarded-host) so every rewritten URL is
absolute. Same behaviour from the main thread; Web Worker fetch
also works because it now has a parseable URL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude VM
2026-05-24 12:38:06 +03:00
parent 44ba50f226
commit 9c496419fd
+23 -9
View File
@@ -24,7 +24,7 @@ export const dynamic = "force-dynamic";
const ALLOWED_STYLES = new Set(["liberty", "dark", "positron", "bright"]); const ALLOWED_STYLES = new Set(["liberty", "dark", "positron", "bright"]);
const OFM_BASE = "https://tiles.openfreemap.org"; const OFM_BASE = "https://tiles.openfreemap.org";
const TILE_PROXY_PREFIX = "/api/basemap-tile"; const TILE_PROXY_PATH = "/api/basemap-tile";
type StyleSource = { type StyleSource = {
type: string; type: string;
@@ -62,20 +62,31 @@ async function fetchJson<T>(url: string): Promise<T> {
/** Replace `https://tiles.openfreemap.org/<rest>` with the architots /** Replace `https://tiles.openfreemap.org/<rest>` with the architots
* proxy prefix so the browser never talks to openfreemap directly. * proxy prefix so the browser never talks to openfreemap directly.
* Leaves `{z}/{x}/{y}` template tokens intact. */ * Emits ABSOLUTE URLs — MapLibre loads tiles in a Web Worker context
function rewriteUpstreamUrl(value: string): string { * where relative URLs fail to parse ("Failed to construct 'Request':
* Failed to parse URL"). Leaves `{z}/{x}/{y}` template tokens intact. */
function rewriteUpstreamUrl(value: string, origin: string): string {
if (typeof value !== "string") return value; if (typeof value !== "string") return value;
if (value.startsWith(OFM_BASE)) { if (value.startsWith(OFM_BASE)) {
return `${TILE_PROXY_PREFIX}${value.slice(OFM_BASE.length)}`; return `${origin}${TILE_PROXY_PATH}${value.slice(OFM_BASE.length)}`;
} }
return value; return value;
} }
export async function GET( export async function GET(
_request: Request, request: Request,
{ params }: { params: Promise<{ id: string }> }, { params }: { params: Promise<{ id: string }> },
) { ) {
const { id } = await params; const { id } = await params;
// Build the absolute origin used in rewritten URLs. Honor proxy
// headers (Traefik forwards x-forwarded-{proto,host}) so URLs match
// the user's external view of the site even when the Next process
// sees the request on its internal hostname:port.
const proto =
request.headers.get("x-forwarded-proto") ?? new URL(request.url).protocol.replace(":", "");
const host =
request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? new URL(request.url).host;
const origin = `${proto}://${host}`;
if (!ALLOWED_STYLES.has(id)) { if (!ALLOWED_STYLES.has(id)) {
return NextResponse.json({ error: "unknown_style" }, { status: 404 }); return NextResponse.json({ error: "unknown_style" }, { status: 404 });
} }
@@ -119,7 +130,7 @@ export async function GET(
// that point. // that point.
for (const source of Object.values(resolved)) { for (const source of Object.values(resolved)) {
if (Array.isArray(source.tiles)) { if (Array.isArray(source.tiles)) {
source.tiles = source.tiles.map(rewriteUpstreamUrl); source.tiles = source.tiles.map((u) => rewriteUpstreamUrl(u, origin));
} }
} }
@@ -128,18 +139,21 @@ export async function GET(
// multi-image-set styles). Handle both. // multi-image-set styles). Handle both.
let sprite: unknown = style.sprite; let sprite: unknown = style.sprite;
if (typeof sprite === "string") { if (typeof sprite === "string") {
sprite = rewriteUpstreamUrl(sprite); sprite = rewriteUpstreamUrl(sprite, origin);
} else if (Array.isArray(sprite)) { } else if (Array.isArray(sprite)) {
sprite = sprite.map((s) => sprite = sprite.map((s) =>
s && typeof s === "object" && "url" in s s && typeof s === "object" && "url" in s
? { ...s, url: rewriteUpstreamUrl(String((s as { url: unknown }).url)) } ? {
...s,
url: rewriteUpstreamUrl(String((s as { url: unknown }).url), origin),
}
: s, : s,
); );
} }
const glyphs = const glyphs =
typeof style.glyphs === "string" typeof style.glyphs === "string"
? rewriteUpstreamUrl(style.glyphs) ? rewriteUpstreamUrl(style.glyphs, origin)
: style.glyphs; : style.glyphs;
return NextResponse.json( return NextResponse.json(