Files
multica/apps/web/proxy.ts
苗大 a90aa92d0c 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 <noreply@anthropic.com>

---------

Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:02:46 +08:00

121 lines
4.3 KiB
TypeScript

import { NextResponse, type NextRequest } from "next/server";
import { LOCALE_COOKIE } from "@multica/core/i18n";
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
// (pre-#1131). Any URL with these as the FIRST segment is a legacy URL that
// needs to be rewritten to /{slug}/{route}/... so old bookmarks, deep links,
// and post-revert-and-reapply users don't hit 404.
const LEGACY_ROUTE_SEGMENTS = new Set([
"issues",
"projects",
"agents",
"squads",
"inbox",
"my-issues",
"autopilots",
"runtimes",
"skills",
"settings",
"usage",
]);
function resolveLocale(req: NextRequest): string {
return resolveLocaleFromSignals({
cookieLocale: req.cookies.get(LOCALE_COOKIE)?.value,
acceptLanguage: req.headers.get("accept-language"),
});
}
// Forward the resolved locale to RSC layouts via the `x-multica-locale`
// request header. layout.tsx reads it through `await headers()`. The
// `request: { headers }` form is what makes the header land on the upstream
// request — without it the value would only sit on the response.
function nextWithLocale(req: NextRequest): NextResponse {
const headers = new Headers(req.headers);
headers.set(MULTICA_LOCALE_HEADER, resolveLocale(req));
return NextResponse.next({ request: { headers } });
}
// Next.js 16 renamed `middleware` → `proxy`. API surface (NextRequest /
// NextResponse / cookies / matcher) is identical; the only behavioral
// change is the runtime — proxy is forced to nodejs and cannot opt into
// 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;
// --- Legacy URL redirect: /issues/... → /{slug}/issues/... ---
// Old bookmarks and clients that hit us before the slug migration would
// otherwise 404 since the route moved under [workspaceSlug].
const firstSegment = pathname.split("/")[1] ?? "";
if (LEGACY_ROUTE_SEGMENTS.has(firstSegment)) {
const url = req.nextUrl.clone();
if (!hasSession) {
url.pathname = "/login";
return NextResponse.redirect(url);
}
if (lastSlug) {
// Preserve deep-link path + query: /issues/abc → /{lastSlug}/issues/abc
url.pathname = `/${lastSlug}${pathname}`;
return NextResponse.redirect(url);
}
// Logged-in but no cookie yet (first login since slug migration, or
// cookie cleared). Bounce to root; the root-path logic below picks a
// workspace and writes the cookie, then future hits short-circuit here.
url.pathname = "/";
return NextResponse.redirect(url);
}
// --- Root path: redirect logged-in users to their last workspace ---
// The official cloud host also serves the public marketing site. Visiting
// https://multica.ai/ must remain a public-site navigation even when a local
// desktop/runtime session has fresh auth cookies; explicit app routes such
// as /acme/issues and legacy /issues still route to the workspace app.
if (
pathname === "/" &&
hasSession &&
lastSlug &&
!isOfficialMarketingHost(req.nextUrl.hostname)
) {
const url = req.nextUrl.clone();
url.pathname = `/${lastSlug}/issues`;
return NextResponse.redirect(url);
}
// --- Default: forward locale header to RSC, no redirect/rewrite ---
// Covers logged-out root path, /login, /:slug/*, and everything else.
return nextWithLocale(req);
}
export const config = {
// i18n header must land on every page request, so we use the standard
// 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|.*\\.).*)",
],
};