diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..81e175a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +node_modules +.next +.env +.env.* +*.log +*.tsbuildinfo diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..da69344 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# syntax=docker/dockerfile:1 +FROM node:24-alpine AS build +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +ARG NEXT_PUBLIC_API_URL=http://localhost:18000 +ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} +RUN node --test tests/*.test.cjs && npm run build + +FROM node:24-alpine AS runtime +WORKDIR /app +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + HOSTNAME=0.0.0.0 \ + PORT=3000 +COPY --from=build --chown=node:node /app/.next/standalone ./ +COPY --from=build --chown=node:node /app/.next/static ./.next/static +COPY --from=build --chown=node:node /app/public ./public +USER node +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/next.config.ts b/next.config.ts index ee61999..8bc1bae 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + output: "standalone", images: { // Wagtail 后端媒体地址(开发环境 + 生产环境国内 OSS/CDN 域名)。 // 详见 documents/设计方案分析与完善版.md §2.3。 diff --git a/package-lock.json b/package-lock.json index 22d5ff8..453612c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -281,10 +281,33 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", "dev": true, "license": "MIT", "optional": true, @@ -2243,6 +2266,29 @@ "node": ">=14.0.0" } }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", diff --git a/services/api-client.ts b/services/api-client.ts index ea12f03..b17c118 100644 --- a/services/api-client.ts +++ b/services/api-client.ts @@ -3,7 +3,10 @@ * 详见 documents/设计方案分析与完善版.md §2.7 Headless API 设计规范。 */ -const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; +const PUBLIC_API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; +const API_BASE_URL = typeof window === "undefined" + ? process.env.API_INTERNAL_URL || PUBLIC_API_URL + : PUBLIC_API_URL; export class ApiError extends Error { constructor( diff --git a/tests/seo.test.cjs b/tests/seo.test.cjs index 2a47f4e..e31e365 100644 --- a/tests/seo.test.cjs +++ b/tests/seo.test.cjs @@ -137,6 +137,21 @@ test("page fetches share the publish cache tag without changing snippet tags", a assert.equal(calls[1].next.tags.join(","), "site-settings"); }); +test("API client separates container internal and browser addresses", async () => { + const env = { NEXT_PUBLIC_API_URL: "http://localhost:18000", API_INTERNAL_URL: "http://backend:8000" }; + for (const browser of [false, true]) { + let requestedURL; + const globals = { + process: { env }, + fetch: async (url) => { requestedURL = url; return { ok: true, json: async () => ({}) }; }, + ...(browser ? { window: {} } : {}), + }; + const { fetchAPI } = loadModule("services/api-client.ts", {}, globals); + await fetchAPI("/api/v2/pages/"); + assert.equal(requestedURL, `${browser ? env.NEXT_PUBLIC_API_URL : env.API_INTERNAL_URL}/api/v2/pages/`); + } +}); + test("authenticated webhook expires shared cache immediately and rejects unauthorized requests", async () => { const calls = []; const { POST } = loadModule("app/api/revalidate/route.ts", {