From 878a2d9a43e1f3378cd4a52665157777e23b44b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=80=82?= Date: Fri, 3 Jul 2026 16:44:44 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E9=83=A8=E7=BD=B2):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E7=9B=B4=E8=BF=9E=E5=89=8D=E7=AB=AF=E6=97=B6=E7=9A=84=20API=20?= =?UTF-8?q?=E5=8F=8D=E4=BB=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.local-server.example | 2 ++ .env.production.example | 2 ++ Dockerfile.web | 4 +++ apps/web/lib/next-config-watch.test.ts | 42 ++++++++++++++++++++++++++ apps/web/next.config.js | 17 +++++++++++ docker-compose.local.yml | 2 ++ docker-compose.prod.yml | 2 ++ 7 files changed, 71 insertions(+) diff --git a/.env.local-server.example b/.env.local-server.example index 874ebbd..43b6f88 100644 --- a/.env.local-server.example +++ b/.env.local-server.example @@ -18,6 +18,8 @@ REDIS_URL=redis://redis:6379 # Web NEXT_PUBLIC_API_URL=/api/v1 +# Fallback used by the Next.js server if the web container is exposed directly. +NEXT_API_PROXY_TARGET=http://server:3001 NEXTAUTH_URL=http://localhost:8080 NEXTAUTH_SECRET=local-server-change-me diff --git a/.env.production.example b/.env.production.example index a5d7eb0..0454f7d 100644 --- a/.env.production.example +++ b/.env.production.example @@ -19,6 +19,8 @@ REDIS_URL=redis://redis:6379 # Web # For same-domain Nginx deployment, keep this relative path so browser requests go to /api/v1. NEXT_PUBLIC_API_URL=/api/v1 +# Fallback used by the Next.js server if the web container is exposed directly. +NEXT_API_PROXY_TARGET=http://server:3001 NEXTAUTH_URL=https://pm.example.com NEXTAUTH_SECRET=change-me-random-32-byte-secret diff --git a/Dockerfile.web b/Dockerfile.web index de3d88f..be7c0de 100644 --- a/Dockerfile.web +++ b/Dockerfile.web @@ -18,7 +18,9 @@ RUN pnpm config set fetch-retries 5 \ FROM deps AS builder ARG NEXT_PUBLIC_API_URL=/api/v1 +ARG NEXT_API_PROXY_TARGET= ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL +ENV NEXT_API_PROXY_TARGET=$NEXT_API_PROXY_TARGET ENV NEXT_TELEMETRY_DISABLED=1 ENV NODE_ENV=production COPY packages/shared packages/shared @@ -28,7 +30,9 @@ RUN pnpm --filter web build FROM base AS runner ARG NEXT_PUBLIC_API_URL=/api/v1 +ARG NEXT_API_PROXY_TARGET= ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL +ENV NEXT_API_PROXY_TARGET=$NEXT_API_PROXY_TARGET ENV NEXT_TELEMETRY_DISABLED=1 ENV NODE_ENV=production COPY --from=builder /app/package.json ./package.json diff --git a/apps/web/lib/next-config-watch.test.ts b/apps/web/lib/next-config-watch.test.ts index 51a60ae..2a4f169 100644 --- a/apps/web/lib/next-config-watch.test.ts +++ b/apps/web/lib/next-config-watch.test.ts @@ -20,3 +20,45 @@ test('next dev keeps watch ignored entries compatible with webpack schema', () = assert.deepEqual(ignoredList, ['**/.tmp-test/**']); }); + +test('next config proxies API requests when the web container is exposed directly', async () => { + const previousTarget = process.env.NEXT_API_PROXY_TARGET; + process.env.NEXT_API_PROXY_TARGET = 'http://server:3001'; + + try { + const rewrites = await nextConfig.rewrites(); + assert.deepEqual(rewrites, [ + { + source: '/api/v1/:path*', + destination: 'http://server:3001/api/v1/:path*', + }, + ]); + } finally { + if (previousTarget === undefined) { + delete process.env.NEXT_API_PROXY_TARGET; + } else { + process.env.NEXT_API_PROXY_TARGET = previousTarget; + } + } +}); + +test('next config accepts API proxy targets that already include the API prefix', async () => { + const previousTarget = process.env.NEXT_API_PROXY_TARGET; + process.env.NEXT_API_PROXY_TARGET = 'http://server:3001/api/v1/'; + + try { + const rewrites = await nextConfig.rewrites(); + assert.deepEqual(rewrites, [ + { + source: '/api/v1/:path*', + destination: 'http://server:3001/api/v1/:path*', + }, + ]); + } finally { + if (previousTarget === undefined) { + delete process.env.NEXT_API_PROXY_TARGET; + } else { + process.env.NEXT_API_PROXY_TARGET = previousTarget; + } + } +}); diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 54b4278..2f0bc1f 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -7,8 +7,25 @@ function toIgnoredList(ignored) { return entries.filter((entry) => typeof entry === 'string' && entry.length > 0); } +function toApiProxyDestination(target) { + const cleanTarget = target.trim().replace(/\/+$/, ''); + if (!cleanTarget) return null; + const apiBase = cleanTarget.endsWith('/api/v1') ? cleanTarget : `${cleanTarget}/api/v1`; + return `${apiBase}/:path*`; +} + const nextConfig = { transpilePackages: ['@ftb/shared'], + async rewrites() { + const destination = toApiProxyDestination(process.env.NEXT_API_PROXY_TARGET || ''); + if (!destination) return []; + return [ + { + source: '/api/v1/:path*', + destination, + }, + ]; + }, webpack(config, { dev }) { if (dev) { const ignored = toIgnoredList(config.watchOptions?.ignored); diff --git a/docker-compose.local.yml b/docker-compose.local.yml index e603e4e..4f55e5e 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -68,10 +68,12 @@ services: dockerfile: Dockerfile.web args: NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-/api/v1} + NEXT_API_PROXY_TARGET: ${NEXT_API_PROXY_TARGET:-http://server:3001} restart: unless-stopped environment: NODE_ENV: production NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-/api/v1} + NEXT_API_PROXY_TARGET: ${NEXT_API_PROXY_TARGET:-http://server:3001} NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:8080} NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-local-server-change-me} depends_on: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 322a098..c56c2fd 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -68,10 +68,12 @@ services: dockerfile: Dockerfile.web args: NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-/api/v1} + NEXT_API_PROXY_TARGET: ${NEXT_API_PROXY_TARGET:-http://server:3001} restart: unless-stopped environment: NODE_ENV: production NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-/api/v1} + NEXT_API_PROXY_TARGET: ${NEXT_API_PROXY_TARGET:-http://server:3001} NEXTAUTH_URL: ${NEXTAUTH_URL:-} NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-} depends_on: