From 2c674b7f2de413d13b06940bebbb45a7b9a74f45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E8=82=B2=E6=96=B0?= <2635131263@qq.com> Date: Mon, 12 Oct 2026 17:42:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(polaris)!:=20=E6=94=B9=E4=B8=BA=20standalo?= =?UTF-8?q?ne=20=E5=AE=B9=E5=99=A8=E8=BF=90=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 Next.js 构建切换为 standalone,新增多阶段 Docker 镜像和非 root 运行。 CI 改为启动服务检查页面与静态资源,并移除静态导出校验。 BREAKING CHANGE: 不再生成 out/ 静态产物,部署需运行 Node 服务容器。 --- .gitea/workflows/polaris-ci.yml | 36 +++++++++++++-- ts/web/nextjs/polaris/.dockerignore | 42 +++++++++++++++++ ts/web/nextjs/polaris/Dockerfile | 39 ++++++++++++++++ .../docs/design/Polaris 项目说明文档.md | 10 ++-- ts/web/nextjs/polaris/next.config.ts | 2 +- ts/web/nextjs/polaris/package.json | 1 - ts/web/nextjs/polaris/patches/README.md | 9 ++-- .../polaris/scripts/verify-static-export.mjs | 46 ------------------- .../src/app/categories/[categoryId]/page.tsx | 2 +- 9 files changed, 126 insertions(+), 61 deletions(-) create mode 100644 ts/web/nextjs/polaris/.dockerignore create mode 100644 ts/web/nextjs/polaris/Dockerfile delete mode 100644 ts/web/nextjs/polaris/scripts/verify-static-export.mjs diff --git a/.gitea/workflows/polaris-ci.yml b/.gitea/workflows/polaris-ci.yml index 70d100c..108b028 100644 --- a/.gitea/workflows/polaris-ci.yml +++ b/.gitea/workflows/polaris-ci.yml @@ -32,8 +32,38 @@ jobs: - name: Test run: corepack pnpm test - - name: Build static export + - name: Build standalone server run: corepack pnpm build - - name: Verify static export - run: corepack pnpm verify:export + - name: Prepare standalone output + run: | + test -f .next/standalone/server.js + cp -r public .next/standalone/ + cp -r .next/static .next/standalone/.next/ + + - name: Smoke test standalone server + shell: bash + run: | + set -euo pipefail + base_url="http://127.0.0.1:3100" + PORT=3100 HOSTNAME=127.0.0.1 node .next/standalone/server.js & + server_pid=$! + trap 'kill "$server_pid" 2>/dev/null || true' EXIT + + ready=0 + for _ in $(seq 1 30); do + if curl -fsS -o /dev/null "$base_url/"; then + ready=1 + break + fi + kill -0 "$server_pid" 2>/dev/null || { echo "server process exited early" >&2; exit 1; } + sleep 1 + done + test "$ready" -eq 1 || { echo "server not ready within 30s" >&2; exit 1; } + + curl -fsS -o /dev/null "$base_url/categories/dev/" + curl -fsS -o /dev/null "$base_url/brand/logo.svg" + + static_asset=$(curl -fsS "$base_url/" | grep -o '/_next/static/[^"]*' | sed -n '1p') + test -n "$static_asset" || { echo "homepage references no /_next/static asset" >&2; exit 1; } + curl -fsS -o /dev/null "$base_url$static_asset" diff --git a/ts/web/nextjs/polaris/.dockerignore b/ts/web/nextjs/polaris/.dockerignore new file mode 100644 index 0000000..bd77e39 --- /dev/null +++ b/ts/web/nextjs/polaris/.dockerignore @@ -0,0 +1,42 @@ +# VCS +.git +.gitignore +.gitattributes + +# Dependencies & build artifacts (rebuilt inside the image) +node_modules +.next +out +coverage +.vercel +.turbo + +# Env files & secrets — never ship credentials into the build context +.env +.env.* +!.env.example +*.pem +*.key +*.p12 +credentials*.json +secrets* + +# Logs & OS noise +*.log +npm-debug.log* +pnpm-debug.log* +yarn-error.log* +.DS_Store +Thumbs.db + +# Docker files themselves +Dockerfile +.dockerignore + +# IDE & editor +.idea +.vscode + +# Non-build project content +docs +.github diff --git a/ts/web/nextjs/polaris/Dockerfile b/ts/web/nextjs/polaris/Dockerfile new file mode 100644 index 0000000..b51db32 --- /dev/null +++ b/ts/web/nextjs/polaris/Dockerfile @@ -0,0 +1,39 @@ +# ---------- deps: install workspace dependencies (keeps patchedDependencies) ---------- +FROM node:22.23.2-bookworm AS deps +WORKDIR /app +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 +RUN corepack enable && corepack prepare pnpm@11.24.0 --activate +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY patches ./patches +RUN pnpm install --frozen-lockfile + +# ---------- builder: build the standalone Next.js output ---------- +FROM node:22.23.2-bookworm AS builder +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 \ + COREPACK_ENABLE_DOWNLOAD_PROMPT=0 +RUN corepack enable && corepack prepare pnpm@11.24.0 --activate +COPY --from=deps /app/node_modules ./node_modules +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY patches ./patches +COPY . . +RUN pnpm run build + +# ---------- runner: minimal runtime with standalone server ---------- +FROM node:22.23.2-bookworm AS runner +WORKDIR /app +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + PORT=3000 \ + HOSTNAME=0.0.0.0 + +COPY --from=builder --chown=node:node /app/public ./public + +RUN mkdir .next && chown node:node .next + +COPY --from=builder --chown=node:node /app/.next/standalone ./ +COPY --from=builder --chown=node:node /app/.next/static ./.next/static + +USER node +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/ts/web/nextjs/polaris/docs/design/Polaris 项目说明文档.md b/ts/web/nextjs/polaris/docs/design/Polaris 项目说明文档.md index 7231ad3..943b95a 100644 --- a/ts/web/nextjs/polaris/docs/design/Polaris 项目说明文档.md +++ b/ts/web/nextjs/polaris/docs/design/Polaris 项目说明文档.md @@ -71,6 +71,8 @@ Polaris 不是后台管理系统,也不是信息密集型 Dashboard。 | 13 | 后端数据库 | PostgreSQL(后续阶段);表结构不锁死 PG 特有语法(JSON 存通用 TEXT) | | 14 | 搜索行为 | 按模式严格分离:选中哪个引擎就只用哪个引擎搜索(回车直跳);仅 Local 模式显示本地结果下拉 | +2026-10-12 部署阶段将第 2 项由静态导出调整为 standalone 服务端输出;当前实现见第 5.2 节。初期内容仍在构建时从本地 JSON 生成,在线数据管理需后续接入后端。 + --- ## 5. 技术选型 @@ -99,16 +101,16 @@ simple-icons ### 5.2 渲染策略 -采用静态导出: +采用 standalone 输出: ```ts // next.config.ts -const nextConfig = { output: "export" } +const nextConfig = { output: "standalone" } ``` - 首页与全部分类页在构建时从本地数据生成(`generateStaticParams`); - 时钟、搜索、侧边栏折叠等交互以 client 组件实现; -- 产物为纯静态文件,可部署到任意静态服务器; +- 构建产物为 `.next/standalone` 下的最小 Node 服务器(`server.js`),`public` 与 `.next/static` 需按 Next 文档在部署时手动复制; - 后续接 Go 后端时,改造 service 层为 fetch,数据页面改为客户端获取。 ### 5.3 数据访问层 @@ -422,7 +424,7 @@ polaris/ types/ # navigation.ts / settings.ts / search.ts lib/ # cn.ts / greeting.ts / local-search.ts / search-providers.ts / icon.ts(纯函数) - next.config.ts # output: 'export' + next.config.ts # output: 'standalone' vitest.config.ts ``` diff --git a/ts/web/nextjs/polaris/next.config.ts b/ts/web/nextjs/polaris/next.config.ts index 9c8987d..7c56538 100644 --- a/ts/web/nextjs/polaris/next.config.ts +++ b/ts/web/nextjs/polaris/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next" const nextConfig: NextConfig = { - output: "export", + output: "standalone", trailingSlash: true, images: { unoptimized: true, diff --git a/ts/web/nextjs/polaris/package.json b/ts/web/nextjs/polaris/package.json index 60ce3f0..7dd0c45 100644 --- a/ts/web/nextjs/polaris/package.json +++ b/ts/web/nextjs/polaris/package.json @@ -7,7 +7,6 @@ "scripts": { "dev": "next dev", "build": "next build", - "verify:export": "node scripts/verify-static-export.mjs", "lint": "eslint .", "typecheck": "tsc --noEmit", "test": "vitest run", diff --git a/ts/web/nextjs/polaris/patches/README.md b/ts/web/nextjs/polaris/patches/README.md index 03996c8..8a11cad 100644 --- a/ts/web/nextjs/polaris/patches/README.md +++ b/ts/web/nextjs/polaris/patches/README.md @@ -1,4 +1,4 @@ -# Next.js Windows 静态导出补丁 +# Next.js Windows 静态导出历史补丁 ## 原因与范围 @@ -17,13 +17,12 @@ ```bash corepack pnpm install --frozen-lockfile corepack pnpm build -corepack pnpm verify:export ``` -`verify:export` 只读取产物:检查首页、全部分类页和 404,并将每个源 RSC segment 与浏览器所需扁平路径下的导出文件逐字节比较。它不会复制、移动或重命名 `out/` 文件。部署前还需在真实静态服务器上验证预取和客户端导航。 +当前项目使用 standalone 服务端输出,不再生成 `out/` 或执行静态导出检查。补丁暂时保留以维持锁文件中的安装约定;服务端产物和 HTTP 路由由 Gitea CI 验证。 ## 后续升级 -补丁仅绑定 `next@16.3.3`,不会自动迁移到其他版本。升级 Next.js 时必须重新检查上游导出实现、运行构建与 `verify:export`,再进行静态浏览器验收;不能仅以 `next build` 成功判断问题已修复。 +补丁仅绑定 `next@16.3.3`,不会自动迁移到其他版本。升级 Next.js 时先评估能否移除这个不再服务当前部署方式的补丁;如果恢复静态导出,再单独验收 Windows 路径与浏览器导航。 -上游修复并验收通过后,使用 pnpm 的补丁移除命令清理对应登记和补丁文件,更新锁文件,保留产物回归检查。 +决定移除时,同步清理 pnpm 的补丁登记和补丁文件,更新锁文件,并保留服务端 HTTP 回归检查。 diff --git a/ts/web/nextjs/polaris/scripts/verify-static-export.mjs b/ts/web/nextjs/polaris/scripts/verify-static-export.mjs deleted file mode 100644 index 5858de8..0000000 --- a/ts/web/nextjs/polaris/scripts/verify-static-export.mjs +++ /dev/null @@ -1,46 +0,0 @@ -import assert from "node:assert/strict" -import { readFile, readdir } from "node:fs/promises" -import path from "node:path" -import { fileURLToPath } from "node:url" - -const projectDir = fileURLToPath(new URL("../", import.meta.url)) -const appDir = path.join(projectDir, ".next/server/app") -const outDir = path.join(projectDir, "out") -const categories = JSON.parse( - await readFile(path.join(projectDir, "src/data/categories.json"), "utf8"), -) -const routes = ["/", ...categories.map(({ id }) => `/categories/${id}`)] -let checkedSegments = 0 - -// 只读构建产物:验证浏览器需要的扁平 URL,绝不修补或复制 out/ 文件。 -for (const route of routes) { - const routeDir = path.join(outDir, route) - assert((await readFile(path.join(routeDir, "index.html"))).length > 0) - assert((await readFile(path.join(routeDir, "index.txt"))).length > 0) - - const segmentsDir = path.join( - appDir, - `${route === "/" ? "index" : route}.segments`, - ) - const files = (await readdir(segmentsDir, { recursive: true })).filter( - (file) => file.endsWith(".segment.rsc"), - ) - assert(files.length > 0, `${route} 缺少 RSC segment 构建产物`) - - for (const file of files) { - const segment = file - .replaceAll("\\", "/") - .slice(0, -".segment.rsc".length) - .replaceAll("/", ".") - const filename = `__next.${segment}.txt` - const source = await readFile(path.join(segmentsDir, file)) - const exported = await readFile(path.join(routeDir, filename)) - assert.deepEqual(exported, source, `${route}/${filename} 内容不一致`) - checkedSegments += 1 - } -} - -assert((await readFile(path.join(outDir, "404.html"))).length > 0) -console.log( - `静态导出检查通过:${routes.length} 个页面、${checkedSegments} 个 RSC 文件及 404.html。`, -) diff --git a/ts/web/nextjs/polaris/src/app/categories/[categoryId]/page.tsx b/ts/web/nextjs/polaris/src/app/categories/[categoryId]/page.tsx index 1d34934..938f3d5 100644 --- a/ts/web/nextjs/polaris/src/app/categories/[categoryId]/page.tsx +++ b/ts/web/nextjs/polaris/src/app/categories/[categoryId]/page.tsx @@ -10,7 +10,7 @@ type CategoryPageProps = { params: Promise<{ categoryId: string }> } -// 静态导出不支持运行时补生成动态路径,未知分类统一返回 404。 +// 仅预生成 generateStaticParams 列出的分类,未知分类统一返回 404。 export const dynamicParams = false export function generateStaticParams() {