mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
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 <[email protected]> --------- Co-authored-by: Naiyuan Qing <[email protected]> Co-authored-by: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
Naiyuan Qing
parent
8d18d3a9ec
commit
a90aa92d0c
+1
-5
@@ -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
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+12
-1
@@ -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 (
|
||||
<html
|
||||
@@ -136,7 +142,12 @@ export default async function RootLayout({
|
||||
/>
|
||||
)}
|
||||
<ThemeProvider>
|
||||
<WebProviders locale={locale} resources={resources}>
|
||||
<WebProviders
|
||||
locale={locale}
|
||||
resources={resources}
|
||||
apiBaseUrl={apiBaseUrl}
|
||||
wsUrl={wsUrl}
|
||||
>
|
||||
{children}
|
||||
</WebProviders>
|
||||
<Toaster />
|
||||
|
||||
@@ -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<string, LocaleResources>;
|
||||
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 (
|
||||
<CoreProvider
|
||||
apiBaseUrl={process.env.NEXT_PUBLIC_API_URL}
|
||||
wsUrl={deriveWsUrl()}
|
||||
apiBaseUrl={apiBaseUrl}
|
||||
wsUrl={wsUrl || deriveWsUrl()}
|
||||
cookieAuth={cookieAuth}
|
||||
onLogin={setLoggedInCookie}
|
||||
onLogout={() => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
+112
-10
@@ -1,18 +1,120 @@
|
||||
type RuntimeEnv = Record<string, string | undefined>;
|
||||
|
||||
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;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const port =
|
||||
env.BACKEND_PORT?.trim() ||
|
||||
env.API_PORT?.trim() ||
|
||||
env.SERVER_PORT?.trim() ||
|
||||
env.PORT?.trim();
|
||||
if (port) return `http://localhost:${port}`;
|
||||
export function resolveDocsUrl(env: RuntimeEnv): string | undefined {
|
||||
return cleanHttpUrl(env.DOCS_URL);
|
||||
}
|
||||
|
||||
return "http://localhost:8080";
|
||||
// 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(/\/$/, "");
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
+50
-32
@@ -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: [],
|
||||
};
|
||||
},
|
||||
|
||||
+147
-5
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
+19
-4
@@ -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|.*\\.).*)",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user