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: