From d492adb1ffe0ee748fb62297eb11e04c2239286a Mon Sep 17 00:00:00 2001 From: ypratama Date: Wed, 2 Sep 2026 11:15:16 +0700 Subject: [PATCH] Feat: Auto-resolve backend API base URL so Swarm redeploys no longer break proxied requests (fetch failed) --- src/app/api/[...route]/route.ts | 17 +++++--- src/lib/api-base.ts | 75 +++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 src/lib/api-base.ts diff --git a/src/app/api/[...route]/route.ts b/src/app/api/[...route]/route.ts index 25e21e0..cbeb504 100644 --- a/src/app/api/[...route]/route.ts +++ b/src/app/api/[...route]/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; +import { getApiBase, resetApiBase } from '@/lib/api-base'; export async function GET(req: NextRequest, props: { params: Promise<{ route: string[] }> }) { return handleProxy(req, props); @@ -27,9 +28,10 @@ async function handleProxy(req: NextRequest, { params }: { params: Promise<{ rou const url = new URL(req.url); - // INTERNAL_API_URL is read at RUNTIME (not baked at build time like NEXT_PUBLIC_*) - // This allows Docker Swarm / Portainer to set the backend URL dynamically - const localBase = process.env.INTERNAL_API_URL || process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:3001'; + // INTERNAL_API_URL is read at RUNTIME (not baked at build time like NEXT_PUBLIC_*). + // getApiBase() probes configured URLs and transparently falls back to the stable + // Swarm service alias if the configured one (e.g. task hostname ...-0) is unreachable. + const localBase = await getApiBase(); const destinationUrl = `${localBase}/api/${route}${url.search}`; // Prepare headers @@ -67,7 +69,7 @@ async function handleProxy(req: NextRequest, { params }: { params: Promise<{ rou headers, body: body, cache: 'no-store', // Prevent Next.js from aggressively caching the backend response - // @ts-ignore + // @ts-expect-error - `duplex` is required for streaming bodies in older fetch typings duplex: hasBody ? 'half' : undefined, }); @@ -93,8 +95,11 @@ async function handleProxy(req: NextRequest, { params }: { params: Promise<{ rou } return nextResponse; - } catch (error: any) { + } catch (error) { + // Backend may have been redeployed (task hostname changed) — re-resolve next request. + resetApiBase(); console.error('[Proxy Error]', error); - return NextResponse.json({ ok: false, message: error.message }, { status: 500 }); + const message = error instanceof Error ? error.message : 'Unknown proxy error'; + return NextResponse.json({ ok: false, message }, { status: 500 }); } } diff --git a/src/lib/api-base.ts b/src/lib/api-base.ts new file mode 100644 index 0000000..f030806 --- /dev/null +++ b/src/lib/api-base.ts @@ -0,0 +1,75 @@ +// src/lib/api-base.ts +// Server-only helper: resolve the backend API base URL at runtime. +// +// Why: +// The frontend proxies /api/* to the backend via NEXT_PUBLIC_API_URL / +// INTERNAL_API_URL. In Docker Swarm deployments these may point to a +// task-instance hostname like `backone-inspect-backend-bun-0:3001`. When the +// Swarm service is redeployed the task index can change (`-0` -> `-1`), orphaning +// the configured URL and breaking every proxied request ("fetch failed"). +// +// Solution: +// Probe candidates in order and cache the first healthy one. Also auto-derive +// the stable service alias by stripping a trailing `-` suffix, so a +// stale `-0` URL transparently recovers to the plain service DNS name. + +let cachedBase: string | null = null; + +function pushUnique(list: string[], value?: string | null) { + if (!value) return; + const clean = value.replace(/\/+$/, ''); + if (clean && !list.includes(clean)) list.push(clean); +} + +function buildCandidates(): string[] { + const list: string[] = []; + pushUnique(list, process.env.INTERNAL_API_URL); + pushUnique(list, process.env.NEXT_PUBLIC_API_URL); + + // Derive stable Swarm service alias (strip task index: ...-0 -> ...) + for (const base of [...list]) { + try { + const u = new URL(base); + const strippedHost = u.host.replace(/-\d+$/, ''); + if (strippedHost && strippedHost !== u.host) { + pushUnique(list, `${u.protocol}//${strippedHost}`); + } + } catch { + // ignore malformed URLs + } + } + + pushUnique(list, 'http://127.0.0.1:3001'); + return list; +} + +async function isHealthy(base: string): Promise { + try { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 2500); + const res = await fetch(`${base}/api/health`, { signal: ctrl.signal }); + clearTimeout(timer); + return res.ok; + } catch { + return false; + } +} + +export async function getApiBase(): Promise { + if (cachedBase) return cachedBase; + + for (const base of buildCandidates()) { + if (await isHealthy(base)) { + cachedBase = base; + return base; + } + } + + // Fallback: use the first configured candidate even if the probe failed. + cachedBase = buildCandidates()[0] || 'http://127.0.0.1:3001'; + return cachedBase; +} + +export function resetApiBase() { + cachedBase = null; +} \ No newline at end of file