将 Next.js 构建切换为 standalone,新增多阶段 Docker 镜像和非 root 运行。 CI 改为启动服务检查页面与静态资源,并移除静态导出校验。 BREAKING CHANGE: 不再生成 out/ 静态产物,部署需运行 Node 服务容器。
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextConfig } from "next"
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "export",
|
||||
output: "standalone",
|
||||
trailingSlash: true,
|
||||
images: {
|
||||
unoptimized: true,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 回归检查。
|
||||
|
||||
@@ -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。`,
|
||||
)
|
||||
@@ -10,7 +10,7 @@ type CategoryPageProps = {
|
||||
params: Promise<{ categoryId: string }>
|
||||
}
|
||||
|
||||
// 静态导出不支持运行时补生成动态路径,未知分类统一返回 404。
|
||||
// 仅预生成 generateStaticParams 列出的分类,未知分类统一返回 404。
|
||||
export const dynamicParams = false
|
||||
|
||||
export function generateStaticParams() {
|
||||
|
||||
Reference in New Issue
Block a user