From a90aa92d0c19da166aae2805df6c53a65c590336 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=97=E5=A4=A7?= Date: Fri, 24 Jul 2026 11:02:46 +0800 Subject: [PATCH] fix(web): resolve API and docs upstreams at runtime (#4840) * fix(web): resolve upstream URLs at runtime * fix(web): keep unconfigured upstreams same-origin * fix(web): restore dev-only localhost fallbacks for API and docs upstreams `next dev` on a developer machine now falls back to the conventional http://localhost:8080 backend (honoring BACKEND_PORT) and http://localhost:4000 docs origin when nothing is configured, so a bare `pnpm dev:web` keeps proxying out of the box. Builds and the runtime proxy keep the strict resolvers, so prebuilt images still leave unset upstreams unproxied. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- Dockerfile.web | 6 +- SELF_HOSTING.md | 2 +- .../docs/getting-started/self-hosting.zh.mdx | 4 +- .../content/docs/self-host-quickstart.mdx | 2 +- apps/web/app/layout.tsx | 13 +- apps/web/components/web-providers.tsx | 13 +- apps/web/config/runtime-urls.test.ts | 214 ++++++++++++++---- apps/web/config/runtime-urls.ts | 126 ++++++++++- .../components/contact-sales-page-client.tsx | 8 +- apps/web/next.config.ts | 82 ++++--- apps/web/proxy.test.ts | 152 ++++++++++++- apps/web/proxy.ts | 23 +- deploy/helm/multica/templates/backend.yaml | 11 +- deploy/helm/multica/templates/frontend.yaml | 15 ++ deploy/helm/multica/values.yaml | 30 +-- docker-compose.selfhost.build.yml | 2 - docker-compose.selfhost.yml | 4 + 17 files changed, 569 insertions(+), 138 deletions(-) diff --git a/Dockerfile.web b/Dockerfile.web index f8c3d7fc5..5502ba6c1 100644 --- a/Dockerfile.web +++ b/Dockerfile.web @@ -42,12 +42,7 @@ COPY packages/ packages/ # Re-link after source overlay (fixes any symlinks overwritten by COPY) RUN pnpm install --frozen-lockfile --offline -# Set build-time env: tells Next.js rewrites to proxy API calls to the backend service -ARG REMOTE_API_URL=http://backend:8080 -ARG NEXT_PUBLIC_WS_URL ARG NEXT_PUBLIC_APP_VERSION=dev -ENV REMOTE_API_URL=$REMOTE_API_URL -ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION ENV STANDALONE=true @@ -60,6 +55,7 @@ FROM node:22-alpine AS runner WORKDIR /app ENV NODE_ENV=production +ENV REMOTE_API_URL=http://backend:8080 RUN addgroup --system --gid 1001 nodejs && \ adduser --system --uid 1001 nextjs diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md index 2fdd8b2fb..0ed76a5a1 100644 --- a/SELF_HOSTING.md +++ b/SELF_HOSTING.md @@ -154,7 +154,7 @@ The chart creates the following resources in the target namespace: The `multica-secrets` Secret is **not** managed by the chart — you create it once with `kubectl` so real values never need to land in git. -> **One release per namespace:** the prebuilt `multica-web` image bakes `REMOTE_API_URL=http://backend:8080` at build time, so the chart ships an ExternalName Service literally named `backend`. Because that name is unprefixed, you can run only one Multica release per namespace, and `helm install` will fail if a `Service/backend` already exists there (pass `--take-ownership`, or use a dedicated namespace). If you build a web image with a patched `REMOTE_API_URL`, set `frontend.compatibility.backendAlias: false` to drop the alias. +> **Runtime frontend upstreams:** current `multica-web` images read `REMOTE_API_URL` and `DOCS_URL` when the Next.js server runs, so API/docs upstream changes do not require a web rebuild. The chart defaults `REMOTE_API_URL` to this release's backend Service. `frontend.compatibility.backendAlias` exists only for legacy images that still baked `REMOTE_API_URL=http://backend:8080` at build time. > **Prerequisites:** `kubectl` and `helm` (v3.13+ for `--take-ownership`, or v4+) configured for the target cluster, an Ingress controller (Traefik / NGINX), and a default StorageClass. diff --git a/apps/docs/content/docs/getting-started/self-hosting.zh.mdx b/apps/docs/content/docs/getting-started/self-hosting.zh.mdx index 380767f74..f8e2c8c30 100644 --- a/apps/docs/content/docs/getting-started/self-hosting.zh.mdx +++ b/apps/docs/content/docs/getting-started/self-hosting.zh.mdx @@ -497,10 +497,12 @@ When using separate domains for frontend and backend, set these environment vari FRONTEND_ORIGIN=https://app.example.com CORS_ALLOWED_ORIGINS=https://app.example.com -# Frontend +# Frontend (runtime env; no rebuild required) REMOTE_API_URL=https://api.example.com NEXT_PUBLIC_API_URL=https://api.example.com NEXT_PUBLIC_WS_URL=wss://api.example.com/ws +# Optional: route /docs through a separately deployed docs service +DOCS_URL=https://docs.example.com ``` ## Health Check diff --git a/apps/docs/content/docs/self-host-quickstart.mdx b/apps/docs/content/docs/self-host-quickstart.mdx index 4946ed084..650198085 100644 --- a/apps/docs/content/docs/self-host-quickstart.mdx +++ b/apps/docs/content/docs/self-host-quickstart.mdx @@ -337,7 +337,7 @@ multica setup self-host \ To move to a specific Multica release, set `images.backend.tag` / `images.frontend.tag` in your values file and `helm upgrade` — that is also how you upgrade, since a changed tag forces a new pull and rollout. `kubectl -n multica rollout restart deploy/multica-backend deploy/multica-frontend` only re-pulls a floating tag if you also set `images.backend.pullPolicy` / `images.frontend.pullPolicy` to `Always`; the chart default is `IfNotPresent`, which reuses whatever the node already cached. `helm -n multica uninstall multica` removes the workloads but keeps the PVCs and Secret; `kubectl delete namespace multica` wipes everything. -The full reference — three login modes, the `backend` ExternalName workaround for the build-time-baked `REMOTE_API_URL` in the web image, resource limits, and TLS — lives in the repo's [`SELF_HOSTING.md`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING.md#kubernetes-deployment-alternative). +The full reference — three login modes, runtime frontend API/docs upstreams, legacy `backend` ExternalName compatibility, resource limits, and TLS — lives in the repo's [`SELF_HOSTING.md`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING.md#kubernetes-deployment-alternative). ## Common issues diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 7b7084d2f..a080f802f 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -8,6 +8,10 @@ import { WebProviders } from "@/components/web-providers"; import type { SupportedLocale } from "@multica/core/i18n"; import { RESOURCES } from "@multica/views/locales"; import { getRequestLocale } from "@/lib/request-locale"; +import { + resolveBrowserApiBaseUrl, + resolveBrowserWsUrl, +} from "@/config/runtime-urls"; import "./globals.css"; // Inter is the Latin UI face. next/font produces a hashed family (`__Inter_xxx`) @@ -109,6 +113,8 @@ export default async function RootLayout({ }) { const locale = await getRequestLocale(); const resources = { [locale]: RESOURCES[locale] }; + const apiBaseUrl = resolveBrowserApiBaseUrl(process.env); + const wsUrl = resolveBrowserWsUrl(process.env); return ( )} - + {children} diff --git a/apps/web/components/web-providers.tsx b/apps/web/components/web-providers.tsx index 1adbf72cd..898c4a722 100644 --- a/apps/web/components/web-providers.tsx +++ b/apps/web/components/web-providers.tsx @@ -29,10 +29,9 @@ function hasLegacyToken(): boolean { } // Derive WebSocket URL from the page origin so self-hosted / LAN deployments -// work without explicit NEXT_PUBLIC_WS_URL. The Next.js rewrite rule -// (/ws → backend) handles proxying. +// work without an explicit runtime wsUrl. The Next.js runtime proxy handles +// /ws -> backend when the deployment keeps WebSockets same-origin. function deriveWsUrl(): string | undefined { - if (process.env.NEXT_PUBLIC_WS_URL) return process.env.NEXT_PUBLIC_WS_URL; if (typeof window === "undefined") return undefined; const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; return `${proto}//${window.location.host}/ws`; @@ -48,10 +47,14 @@ export function WebProviders({ children, locale, resources, + apiBaseUrl, + wsUrl, }: { children: React.ReactNode; locale: SupportedLocale; resources: Record; + apiBaseUrl?: string; + wsUrl?: string; }) { const cookieAuth = !hasLegacyToken(); // Stable identity reference so downstream effects keyed on it don't see a @@ -63,8 +66,8 @@ export function WebProviders({ const localeAdapter = useMemo(() => createBrowserCookieLocaleAdapter(), []); return ( { diff --git a/apps/web/config/runtime-urls.test.ts b/apps/web/config/runtime-urls.test.ts index dae792786..81ac3dce2 100644 --- a/apps/web/config/runtime-urls.test.ts +++ b/apps/web/config/runtime-urls.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from "vitest"; -import { resolveRemoteApiUrl } from "./runtime-urls"; +import { + resolveBrowserApiBaseUrl, + resolveBrowserWsUrl, + resolveDevDocsUrl, + resolveDevRemoteApiUrl, + resolveDocsUrl, + resolveRemoteApiUrl, + runtimeRewriteDestination, +} from "./runtime-urls"; describe("resolveRemoteApiUrl", () => { it("prefers REMOTE_API_URL when explicitly configured", () => { @@ -22,61 +30,183 @@ describe("resolveRemoteApiUrl", () => { ).toBe("http://localhost:19000"); }); - it("derives localhost backend URL from PORT when no API URL is set", () => { - expect(resolveRemoteApiUrl({ PORT: "19080" })).toBe("http://localhost:19080"); - }); - - it("supports explicit backend port aliases before PORT", () => { - expect(resolveRemoteApiUrl({ BACKEND_PORT: "28080", PORT: "19080" })).toBe( - "http://localhost:28080", - ); - expect(resolveRemoteApiUrl({ API_PORT: "38080", PORT: "19080" })).toBe( - "http://localhost:38080", - ); - expect(resolveRemoteApiUrl({ SERVER_PORT: "48080", PORT: "19080" })).toBe( - "http://localhost:48080", - ); - }); - - it("prefers backend port aliases by documented precedence", () => { + it("does not infer a backend URL from frontend or backend port env vars", () => { expect( resolveRemoteApiUrl({ BACKEND_PORT: "28080", API_PORT: "38080", SERVER_PORT: "48080", - PORT: "19080", + PORT: "3000", }), - ).toBe("http://localhost:28080"); - - expect( - resolveRemoteApiUrl({ - API_PORT: "38080", - SERVER_PORT: "48080", - PORT: "19080", - }), - ).toBe("http://localhost:38080"); - - expect(resolveRemoteApiUrl({ SERVER_PORT: "48080", PORT: "19080" })).toBe( - "http://localhost:48080", - ); + ).toBeUndefined(); + expect(resolveRemoteApiUrl({ PORT: "3000" })).toBeUndefined(); }); - it("ignores whitespace-only backend URL values", () => { + it("does not use relative public API URLs for server-side rewrites", () => { + expect( + resolveRemoteApiUrl({ + NEXT_PUBLIC_API_URL: "/api", + }), + ).toBeUndefined(); + }); + + it("ignores whitespace-only or invalid backend URL values", () => { expect( resolveRemoteApiUrl({ REMOTE_API_URL: " ", - NEXT_PUBLIC_API_URL: " ", - BACKEND_PORT: " ", - API_PORT: " ", - SERVER_PORT: " ", + NEXT_PUBLIC_API_URL: "ftp://api.example.com", PORT: "19080", }), - ).toBe("http://localhost:19080"); - - expect(resolveRemoteApiUrl({ PORT: " " })).toBe("http://localhost:8080"); + ).toBeUndefined(); }); - it("falls back to the historical backend port when no env is configured", () => { - expect(resolveRemoteApiUrl({})).toBe("http://localhost:8080"); + it("returns undefined when no API origin is configured", () => { + expect(resolveRemoteApiUrl({})).toBeUndefined(); + }); +}); + +describe("resolveDocsUrl", () => { + it("uses DOCS_URL when configured", () => { + expect(resolveDocsUrl({ DOCS_URL: " http://docs:4000/ " })).toBe( + "http://docs:4000", + ); + }); + + it("returns undefined when no docs origin is configured", () => { + expect(resolveDocsUrl({})).toBeUndefined(); + }); + + it("ignores relative or invalid docs URL values", () => { + expect(resolveDocsUrl({ DOCS_URL: "/docs" })).toBeUndefined(); + expect(resolveDocsUrl({ DOCS_URL: "ftp://docs.example.com" })).toBeUndefined(); + }); +}); + +describe("browser runtime URLs", () => { + it("exposes NEXT_PUBLIC_API_URL at server render time", () => { + expect( + resolveBrowserApiBaseUrl({ + NEXT_PUBLIC_API_URL: " https://api.example.com/ ", + }), + ).toBe("https://api.example.com"); + }); + + it("derives browser websocket URL from the public API URL", () => { + expect( + resolveBrowserWsUrl({ + NEXT_PUBLIC_API_URL: "https://api.example.com/base", + }), + ).toBe("wss://api.example.com/base/ws"); + }); + + it("prefers an explicit browser websocket URL", () => { + expect( + resolveBrowserWsUrl({ + NEXT_PUBLIC_API_URL: "https://api.example.com", + NEXT_PUBLIC_WS_URL: " wss://ws.example.com/socket/ ", + }), + ).toBe("wss://ws.example.com/socket"); + }); + + it("falls back to same-origin websocket derivation for relative public API URLs", () => { + expect( + resolveBrowserWsUrl({ + NEXT_PUBLIC_API_URL: "/api", + }), + ).toBeUndefined(); + }); +}); + +describe("runtimeRewriteDestination", () => { + it("keeps same-origin fallback when no runtime upstreams are configured", () => { + expect(runtimeRewriteDestination("/api/config", {})).toBeUndefined(); + expect(runtimeRewriteDestination("/auth/send-code", {})).toBeUndefined(); + expect( + runtimeRewriteDestination("/uploads/workspaces/a.png", {}), + ).toBeUndefined(); + expect(runtimeRewriteDestination("/ws", {})).toBeUndefined(); + expect(runtimeRewriteDestination("/docs/zh", {})).toBeUndefined(); + }); + + it("keeps same-origin fallback for runtime API paths when only frontend PORT is configured", () => { + expect( + runtimeRewriteDestination("/api/config", { + PORT: "3000", + }), + ).toBeUndefined(); + }); + + it("does not rewrite runtime API paths to relative public API URLs", () => { + expect( + runtimeRewriteDestination("/api/config", { + NEXT_PUBLIC_API_URL: "/api", + }), + ).toBeUndefined(); + }); + + it("maps backend HTTP paths to the runtime API origin", () => { + expect( + runtimeRewriteDestination("/api/config", { + REMOTE_API_URL: "http://backend:8080", + }), + ).toBe("http://backend:8080/api/config"); + expect( + runtimeRewriteDestination("/auth/send-code", { + REMOTE_API_URL: "http://backend:8080", + }), + ).toBe("http://backend:8080/auth/send-code"); + expect( + runtimeRewriteDestination("/uploads/workspaces/a.png", { + REMOTE_API_URL: "http://backend:8080", + }), + ).toBe("http://backend:8080/uploads/workspaces/a.png"); + }); + + it("does not rewrite frontend auth callback pages", () => { + expect(runtimeRewriteDestination("/auth/callback", {})).toBeUndefined(); + expect( + runtimeRewriteDestination("/auth/hg-sso/callback", {}), + ).toBeUndefined(); + }); + + it("maps docs paths to the runtime docs origin", () => { + expect( + runtimeRewriteDestination("/docs/zh/agents", { + DOCS_URL: "http://multica-docs:3000", + }), + ).toBe("http://multica-docs:3000/docs/zh/agents"); + }); + + it("maps websocket paths to the runtime API origin", () => { + expect( + runtimeRewriteDestination("/ws", { + REMOTE_API_URL: "http://backend:8080", + }), + ).toBe("http://backend:8080/ws"); + }); +}); + +describe("dev-only fallbacks", () => { + it("falls back to the conventional local backend port", () => { + expect(resolveDevRemoteApiUrl({})).toBe("http://localhost:8080"); + }); + + it("honors BACKEND_PORT for the dev backend fallback", () => { + expect(resolveDevRemoteApiUrl({ BACKEND_PORT: "19080" })).toBe( + "http://localhost:19080", + ); + }); + + it("prefers configured origins over the dev fallbacks", () => { + expect( + resolveDevRemoteApiUrl({ REMOTE_API_URL: "http://backend:8080" }), + ).toBe("http://backend:8080"); + expect(resolveDevDocsUrl({ DOCS_URL: "http://docs:4000" })).toBe( + "http://docs:4000", + ); + }); + + it("falls back to the local docs port", () => { + expect(resolveDevDocsUrl({})).toBe("http://localhost:4000"); }); }); diff --git a/apps/web/config/runtime-urls.ts b/apps/web/config/runtime-urls.ts index 419243e4b..4b28e9725 100644 --- a/apps/web/config/runtime-urls.ts +++ b/apps/web/config/runtime-urls.ts @@ -1,18 +1,120 @@ type RuntimeEnv = Record; -export function resolveRemoteApiUrl(env: RuntimeEnv): string { - const explicitRemote = env.REMOTE_API_URL?.trim(); +function cleanUrl(raw: string | undefined): string | undefined { + const value = raw?.trim(); + if (!value) return undefined; + return value.replace(/\/+$/, ""); +} + +function cleanHttpUrl(raw: string | undefined): string | undefined { + const value = cleanUrl(raw); + if (!value) return undefined; + + try { + const url = new URL(value); + if (url.protocol === "http:" || url.protocol === "https:") return value; + } catch { + return undefined; + } + + return undefined; +} + +function appendPath(baseUrl: string, path: string): string { + return `${baseUrl}${path.startsWith("/") ? path : `/${path}`}`; +} + +export function resolveRemoteApiUrl(env: RuntimeEnv): string | undefined { + const explicitRemote = cleanHttpUrl(env.REMOTE_API_URL); if (explicitRemote) return explicitRemote; - const publicApi = env.NEXT_PUBLIC_API_URL?.trim(); + const publicApi = cleanHttpUrl(env.NEXT_PUBLIC_API_URL); if (publicApi) return publicApi; - - const port = - env.BACKEND_PORT?.trim() || - env.API_PORT?.trim() || - env.SERVER_PORT?.trim() || - env.PORT?.trim(); - if (port) return `http://localhost:${port}`; - - return "http://localhost:8080"; + return undefined; +} + +export function resolveDocsUrl(env: RuntimeEnv): string | undefined { + return cleanHttpUrl(env.DOCS_URL); +} + +// Dev-only fallbacks: `next dev` runs on a developer machine, where the +// conventional localhost backend/docs ports are safe to assume when nothing +// is configured. Builds and the runtime proxy keep the strict resolvers so a +// prebuilt image never guesses an origin (#4787). +export function resolveDevRemoteApiUrl(env: RuntimeEnv): string { + const configured = resolveRemoteApiUrl(env); + if (configured) return configured; + const backendPort = env.BACKEND_PORT?.trim() || "8080"; + return `http://localhost:${backendPort}`; +} + +export function resolveDevDocsUrl(env: RuntimeEnv): string { + return resolveDocsUrl(env) ?? "http://localhost:4000"; +} + +export function resolveBrowserApiBaseUrl(env: RuntimeEnv): string | undefined { + return cleanUrl(env.NEXT_PUBLIC_API_URL); +} + +export function resolveBrowserWsUrl(env: RuntimeEnv): string | undefined { + const explicit = cleanUrl(env.NEXT_PUBLIC_WS_URL); + if (explicit) return explicit; + + const apiUrl = resolveBrowserApiBaseUrl(env); + return apiUrl ? tryDeriveWsUrl(apiUrl) : undefined; +} + +export function runtimeRewriteDestination( + pathname: string, + env: RuntimeEnv, +): string | undefined { + const docsUrl = resolveDocsUrl(env); + if (pathname === "/docs") { + return docsUrl ? appendPath(docsUrl, "/docs") : undefined; + } + if (pathname.startsWith("/docs/")) { + return docsUrl ? appendPath(docsUrl, pathname) : undefined; + } + + const remoteApiUrl = resolveRemoteApiUrl(env); + if (!remoteApiUrl) return undefined; + + if (pathname === "/api" || pathname.startsWith("/api/")) { + return appendPath(remoteApiUrl, pathname); + } + if (pathname === "/uploads" || pathname.startsWith("/uploads/")) { + return appendPath(remoteApiUrl, pathname); + } + if (pathname === "/ws") { + return appendPath(remoteApiUrl, "/ws"); + } + if (isBackendAuthPath(pathname)) { + return appendPath(remoteApiUrl, pathname); + } + + return undefined; +} + +function isBackendAuthPath(pathname: string): boolean { + if (pathname === "/auth/callback") return false; + if (pathname.startsWith("/auth/callback/")) return false; + if (pathname === "/auth/hg-sso/callback") return false; + if (pathname.startsWith("/auth/hg-sso/callback/")) return false; + return pathname === "/auth" || pathname.startsWith("/auth/"); +} + +function tryDeriveWsUrl(apiUrl: string): string | undefined { + let url: URL; + try { + url = new URL(apiUrl); + } catch { + return undefined; + } + if (url.protocol === "https:") url.protocol = "wss:"; + else if (url.protocol === "http:") url.protocol = "ws:"; + else return undefined; + url.pathname = appendPath(url.pathname.replace(/\/+$/, ""), "/ws"); + url.search = ""; + url.hash = ""; + return url.toString().replace(/\/$/, ""); } diff --git a/apps/web/features/landing/components/contact-sales-page-client.tsx b/apps/web/features/landing/components/contact-sales-page-client.tsx index ae8f620c9..0e6ff79b6 100644 --- a/apps/web/features/landing/components/contact-sales-page-client.tsx +++ b/apps/web/features/landing/components/contact-sales-page-client.tsx @@ -102,13 +102,7 @@ export function ContactSalesPageClient() { setState({ status: "submitting" }); try { - // Call the API origin directly (same as the rest of the web app via - // `apiBaseUrl={process.env.NEXT_PUBLIC_API_URL}`). The `/api/*` Vercel - // rewrite uses server-only `REMOTE_API_URL`, which on Vercel may not - // be publicly resolvable — relying on it makes this endpoint 404 even - // when every other API works. - const apiBase = process.env.NEXT_PUBLIC_API_URL ?? ""; - const res = await fetch(`${apiBase}/api/contact-sales`, { + const res = await fetch("/api/contact-sales", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 775b62804..4348027a5 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,14 +1,28 @@ import type { NextConfig } from "next"; import { config } from "dotenv"; import { resolve } from "path"; -import { resolveRemoteApiUrl } from "./config/runtime-urls"; +import { + resolveDevDocsUrl, + resolveDevRemoteApiUrl, + resolveDocsUrl, + resolveRemoteApiUrl, +} from "./config/runtime-urls"; import { createMDX } from "fumadocs-mdx/next"; -// Load root .env so REMOTE_API_URL is available to next.config.ts +// Load root .env so local next.config.ts rewrites see REMOTE_API_URL / DOCS_URL. +// Production requests use proxy.ts runtime rewrites, which read process.env +// when the Next.js server runs instead of baking these URLs at build time. config({ path: resolve(__dirname, "../../.env") }); -const remoteApiUrl = resolveRemoteApiUrl(process.env); -const docsUrl = process.env.DOCS_URL || "http://localhost:4000"; +// `next dev` falls back to the conventional localhost upstreams; builds use +// the strict resolvers so prebuilt images keep unset upstreams unproxied. +const isDev = process.env.NODE_ENV === "development"; +const remoteApiUrl = isDev + ? resolveDevRemoteApiUrl(process.env) + : resolveRemoteApiUrl(process.env); +const docsUrl = isDev + ? resolveDevDocsUrl(process.env) + : resolveDocsUrl(process.env); // Parse hostnames from CORS_ALLOWED_ORIGINS so that Next.js dev server // allows cross-origin HMR / webpack requests (e.g. from Tailscale IPs). @@ -38,34 +52,38 @@ const nextConfig: NextConfig = { return { // Run before file-system routes so /docs isn't shadowed by the // [workspaceSlug] dynamic segment. - beforeFiles: [ - { - source: "/docs", - destination: `${docsUrl}/docs`, - }, - { - source: "/docs/:path*", - destination: `${docsUrl}/docs/:path*`, - }, - ], - afterFiles: [ - { - source: "/api/:path*", - destination: `${remoteApiUrl}/api/:path*`, - }, - { - source: "/ws", - destination: `${remoteApiUrl}/ws`, - }, - { - source: "/auth/:path*", - destination: `${remoteApiUrl}/auth/:path*`, - }, - { - source: "/uploads/:path*", - destination: `${remoteApiUrl}/uploads/:path*`, - }, - ], + beforeFiles: docsUrl + ? [ + { + source: "/docs", + destination: `${docsUrl}/docs`, + }, + { + source: "/docs/:path*", + destination: `${docsUrl}/docs/:path*`, + }, + ] + : [], + afterFiles: remoteApiUrl + ? [ + { + source: "/api/:path*", + destination: `${remoteApiUrl}/api/:path*`, + }, + { + source: "/ws", + destination: `${remoteApiUrl}/ws`, + }, + { + source: "/auth/:path*", + destination: `${remoteApiUrl}/auth/:path*`, + }, + { + source: "/uploads/:path*", + destination: `${remoteApiUrl}/uploads/:path*`, + }, + ] + : [], fallback: [], }; }, diff --git a/apps/web/proxy.test.ts b/apps/web/proxy.test.ts index f335d6782..cc537c134 100644 --- a/apps/web/proxy.test.ts +++ b/apps/web/proxy.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { NextRequest } from "next/server"; +import { MULTICA_LOCALE_HEADER } from "./lib/locale-routing"; import { proxy } from "./proxy"; function makeRequest( @@ -24,6 +25,31 @@ function redirectLocation( return proxy(makeRequest(path, cookies, host)).headers.get("location"); } +function restoreEnv(key: string, value: string | undefined) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; +} + +function withoutRuntimeUpstreams(run: () => void) { + const previousRemoteApiUrl = process.env.REMOTE_API_URL; + const previousDocsUrl = process.env.DOCS_URL; + const previousPublicApiUrl = process.env.NEXT_PUBLIC_API_URL; + const previousPort = process.env.PORT; + delete process.env.REMOTE_API_URL; + delete process.env.DOCS_URL; + delete process.env.NEXT_PUBLIC_API_URL; + process.env.PORT = "3000"; + + try { + run(); + } finally { + restoreEnv("REMOTE_API_URL", previousRemoteApiUrl); + restoreEnv("DOCS_URL", previousDocsUrl); + restoreEnv("NEXT_PUBLIC_API_URL", previousPublicApiUrl); + restoreEnv("PORT", previousPort); + } +} + describe("proxy legacy workspace route redirects", () => { const sessionCookies = { multica_logged_in: "1", @@ -42,11 +68,14 @@ describe("proxy legacy workspace route redirects", () => { ["skills", "/acme/skills"], ["settings", "/acme/settings"], ["usage", "/acme/usage"], - ])("redirects legacy /%s URLs through the last workspace slug", (segment, expectedPath) => { - expect(redirectLocation(`/${segment}?tab=all`, sessionCookies)).toBe( - `https://app.multica.test${expectedPath}?tab=all`, - ); - }); + ])( + "redirects legacy /%s URLs through the last workspace slug", + (segment, expectedPath) => { + expect(redirectLocation(`/${segment}?tab=all`, sessionCookies)).toBe( + `https://app.multica.test${expectedPath}?tab=all`, + ); + }, + ); it("preserves nested legacy paths and query strings", () => { expect( @@ -89,3 +118,116 @@ describe("proxy legacy workspace route redirects", () => { ); }); }); + +describe("proxy runtime upstream rewrites", () => { + it("does not rewrite API requests when no runtime API origin is configured", () => { + withoutRuntimeUpstreams(() => { + const res = proxy(makeRequest("/api/config?x=1")); + + expect(res.status).toBe(200); + expect(res.headers.get("x-middleware-rewrite")).toBeNull(); + expect( + res.headers.get(`x-middleware-request-${MULTICA_LOCALE_HEADER}`), + ).toBe("en"); + }); + }); + + it("does not rewrite docs requests when no runtime docs origin is configured", () => { + withoutRuntimeUpstreams(() => { + const res = proxy(makeRequest("/docs/zh")); + + expect(res.status).toBe(200); + expect(res.headers.get("x-middleware-rewrite")).toBeNull(); + expect( + res.headers.get(`x-middleware-request-${MULTICA_LOCALE_HEADER}`), + ).toBe("en"); + }); + }); + + it("rewrites API requests to the runtime API origin", () => { + const previous = process.env.REMOTE_API_URL; + process.env.REMOTE_API_URL = "http://backend:8080"; + try { + const res = proxy(makeRequest("/api/config?x=1")); + + expect(res.status).toBe(200); + expect(res.headers.get("x-middleware-rewrite")).toBe( + "http://backend:8080/api/config?x=1", + ); + } finally { + restoreEnv("REMOTE_API_URL", previous); + } + }); + + it("rewrites docs requests to the runtime docs origin", () => { + const previous = process.env.DOCS_URL; + process.env.DOCS_URL = "http://docs:4000"; + try { + const res = proxy(makeRequest("/docs/zh/agents")); + + expect(res.status).toBe(200); + expect(res.headers.get("x-middleware-rewrite")).toBe( + "http://docs:4000/docs/zh/agents", + ); + } finally { + restoreEnv("DOCS_URL", previous); + } + }); + + it("rewrites websocket requests to the runtime API origin", () => { + const previous = process.env.REMOTE_API_URL; + process.env.REMOTE_API_URL = "http://backend:8080"; + try { + const res = proxy(makeRequest("/ws")); + + expect(res.status).toBe(200); + expect(res.headers.get("x-middleware-rewrite")).toBe( + "http://backend:8080/ws", + ); + } finally { + restoreEnv("REMOTE_API_URL", previous); + } + }); + + it("does not rewrite frontend auth callback pages", () => { + const previous = process.env.REMOTE_API_URL; + process.env.REMOTE_API_URL = "http://backend:8080"; + try { + const res = proxy(makeRequest("/auth/callback")); + + expect(res.status).toBe(200); + expect(res.headers.get("x-middleware-rewrite")).toBeNull(); + expect( + res.headers.get(`x-middleware-request-${MULTICA_LOCALE_HEADER}`), + ).toBe("en"); + } finally { + restoreEnv("REMOTE_API_URL", previous); + } + }); +}); + +describe("proxy root and locale handling", () => { + it("redirects logged-in root visits to the last workspace", () => { + const res = proxy( + makeRequest("/", { + multica_logged_in: "1", + last_workspace_slug: "acme", + }), + ); + + expect(res.status).toBe(307); + expect(res.headers.get("location")).toBe( + "https://app.multica.test/acme/issues", + ); + }); + + it("forwards locale on login requests", () => { + const res = proxy(makeRequest("/login", { "multica-locale": "zh-Hans" })); + + expect(res.status).toBe(200); + expect(res.headers.get("location")).toBeNull(); + expect( + res.headers.get(`x-middleware-request-${MULTICA_LOCALE_HEADER}`), + ).toBe("zh-Hans"); + }); +}); diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index 22eb52eec..997ef7370 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -4,6 +4,7 @@ import { MULTICA_LOCALE_HEADER, resolveLocaleFromSignals, } from "./lib/locale-routing"; +import { runtimeRewriteDestination } from "./config/runtime-urls"; import { isOfficialMarketingHost } from "./lib/public-host"; // Old workspace-scoped route segments that existed before the URL refactor @@ -47,6 +48,13 @@ function nextWithLocale(req: NextRequest): NextResponse { // edge. export function proxy(req: NextRequest) { const { pathname } = req.nextUrl; + const runtimeDestination = runtimeRewriteDestination(pathname, process.env); + if (runtimeDestination) { + const url = new URL(runtimeDestination); + url.search = req.nextUrl.search; + return NextResponse.rewrite(url); + } + const hasSession = req.cookies.has("multica_logged_in"); const lastSlug = req.cookies.get("last_workspace_slug")?.value; @@ -98,8 +106,15 @@ export function proxy(req: NextRequest) { export const config = { // i18n header must land on every page request, so we use the standard - // negative-lookahead pattern from Next's i18n guide: skip API routes - // (Go backend), Next internals, and any path with a file extension - // (favicons, sw.js, public/* assets). - matcher: ["/((?!api|_next/static|_next/image|favicon.ico|.*\\.).*)"], + // negative-lookahead pattern from Next's i18n guide, plus explicit runtime + // proxy routes whose upstream origins are resolved from process.env at + // request time instead of being baked into next.config.js at build time. + matcher: [ + "/api/:path*", + "/auth/:path*", + "/uploads/:path*", + "/docs/:path*", + "/ws", + "/((?!api|_next/static|_next/image|favicon.ico|.*\\.).*)", + ], }; diff --git a/deploy/helm/multica/templates/backend.yaml b/deploy/helm/multica/templates/backend.yaml index 19c44051d..0b9f9d783 100644 --- a/deploy/helm/multica/templates/backend.yaml +++ b/deploy/helm/multica/templates/backend.yaml @@ -138,18 +138,15 @@ spec: name: http {{- if .Values.frontend.compatibility.backendAlias }} --- -# DNS alias: the multica-web image bakes REMOTE_API_URL=http://backend:8080 -# at build time, and the Next.js standalone build does not re-evaluate the -# rewrite destinations from runtime env. This ExternalName makes the bare -# host "backend" resolve to the backend Service inside the cluster, so the -# frontend's /api, /ws, /auth, and /uploads proxies work out of the box. +# DNS alias for legacy multica-web images that baked +# REMOTE_API_URL=http://backend:8080 at build time. Current images resolve +# frontend.config.remoteApiUrl at runtime and do not need this Service. # # The name is intentionally unprefixed ("backend", not "{{ .Release.Name }}-backend") # because the baked-in host has no release prefix. As a result only ONE release # of this chart can run per namespace, and the name may collide with a # pre-existing Service/backend (see frontend.compatibility.backendAlias in -# values.yaml). Operators running a web image built with a patched -# REMOTE_API_URL can set that value to false to drop this Service entirely. +# values.yaml). apiVersion: v1 kind: Service metadata: diff --git a/deploy/helm/multica/templates/frontend.yaml b/deploy/helm/multica/templates/frontend.yaml index f756e4e71..b442c1f44 100644 --- a/deploy/helm/multica/templates/frontend.yaml +++ b/deploy/helm/multica/templates/frontend.yaml @@ -1,4 +1,5 @@ {{- $frontendImageTag := default .Chart.AppVersion .Values.images.frontend.tag -}} +{{- $remoteApiUrl := default (printf "http://%s:8080" (include "multica.backend.fullname" .)) .Values.frontend.config.remoteApiUrl -}} apiVersion: apps/v1 kind: Deployment metadata: @@ -31,6 +32,20 @@ spec: value: "0.0.0.0" - name: PORT value: "3000" + - name: REMOTE_API_URL + value: {{ $remoteApiUrl | quote }} + {{- if .Values.frontend.config.docsUrl }} + - name: DOCS_URL + value: {{ .Values.frontend.config.docsUrl | quote }} + {{- end }} + {{- if .Values.frontend.config.publicApiUrl }} + - name: NEXT_PUBLIC_API_URL + value: {{ .Values.frontend.config.publicApiUrl | quote }} + {{- end }} + {{- if .Values.frontend.config.publicWsUrl }} + - name: NEXT_PUBLIC_WS_URL + value: {{ .Values.frontend.config.publicWsUrl | quote }} + {{- end }} resources: {{- toYaml .Values.frontend.resources | nindent 12 }} {{- with .Values.frontend.affinity }} diff --git a/deploy/helm/multica/values.yaml b/deploy/helm/multica/values.yaml index c69be9311..ce2600d7d 100644 --- a/deploy/helm/multica/values.yaml +++ b/deploy/helm/multica/values.yaml @@ -144,23 +144,27 @@ backend: # ----------------------------------------------------------------------------- # Frontend (Next.js standalone) -# -# The multica-web image bakes REMOTE_API_URL=http://backend:8080 at build time; -# the chart ships an ExternalName Service named "backend" so that bare host -# resolves to the in-cluster backend Service. # ----------------------------------------------------------------------------- frontend: replicas: 1 - # Compatibility shim for the prebuilt multica-web image. + config: + # Backend origin used by the frontend runtime proxy for /api, /auth, + # /uploads, and /ws. Empty defaults to this release's backend Service. + remoteApiUrl: "" + # Optional docs origin used by the frontend runtime proxy for /docs. + docsUrl: "" + # Optional browser-visible split-origin overrides. Leave empty for the + # default same-origin frontend proxy. + publicApiUrl: "" + publicWsUrl: "" + # Compatibility shim for legacy multica-web images built before runtime + # URL rewrites. New images do not need this unprefixed Service. compatibility: - # When true (default) the chart creates an ExternalName Service literally - # named "backend" so the REMOTE_API_URL=http://backend:8080 baked into the - # web image resolves in-cluster. Because that name is unprefixed, only ONE - # release of this chart can run per namespace, and it will collide with any - # pre-existing Service/backend (helm install then fails without - # --take-ownership). Set to false if you run a web image built with a - # patched REMOTE_API_URL and don't need the alias. - backendAlias: true + # When true the chart creates an ExternalName Service literally named + # "backend" for old images that baked REMOTE_API_URL=http://backend:8080. + # Because that name is unprefixed, only ONE such release can run per + # namespace. Keep false for current images. + backendAlias: false resources: requests: cpu: 100m diff --git a/docker-compose.selfhost.build.yml b/docker-compose.selfhost.build.yml index 4bf437f45..dd069256e 100644 --- a/docker-compose.selfhost.build.yml +++ b/docker-compose.selfhost.build.yml @@ -18,6 +18,4 @@ services: context: . dockerfile: Dockerfile.web args: - REMOTE_API_URL: http://backend:8080 - NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-} NEXT_PUBLIC_APP_VERSION: dev diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml index 7f83e2a38..6983ca127 100644 --- a/docker-compose.selfhost.yml +++ b/docker-compose.selfhost.yml @@ -129,6 +129,10 @@ services: - "127.0.0.1:${FRONTEND_PORT:-3000}:3000" environment: HOSTNAME: "0.0.0.0" + REMOTE_API_URL: ${REMOTE_API_URL:-http://backend:8080} + DOCS_URL: ${DOCS_URL:-} + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-} + NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-} restart: unless-stopped volumes: