将 Next.js 构建切换为 standalone,新增多阶段 Docker 镜像和非 root 运行。 CI 改为启动服务检查页面与静态资源,并移除静态导出校验。 BREAKING CHANGE: 不再生成 out/ 静态产物,部署需运行 Node 服务容器。
This commit is contained in:
@@ -32,8 +32,38 @@ jobs:
|
|||||||
- name: Test
|
- name: Test
|
||||||
run: corepack pnpm test
|
run: corepack pnpm test
|
||||||
|
|
||||||
- name: Build static export
|
- name: Build standalone server
|
||||||
run: corepack pnpm build
|
run: corepack pnpm build
|
||||||
|
|
||||||
- name: Verify static export
|
- name: Prepare standalone output
|
||||||
run: corepack pnpm verify:export
|
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) |
|
| 13 | 后端数据库 | PostgreSQL(后续阶段);表结构不锁死 PG 特有语法(JSON 存通用 TEXT) |
|
||||||
| 14 | 搜索行为 | 按模式严格分离:选中哪个引擎就只用哪个引擎搜索(回车直跳);仅 Local 模式显示本地结果下拉 |
|
| 14 | 搜索行为 | 按模式严格分离:选中哪个引擎就只用哪个引擎搜索(回车直跳);仅 Local 模式显示本地结果下拉 |
|
||||||
|
|
||||||
|
2026-10-12 部署阶段将第 2 项由静态导出调整为 standalone 服务端输出;当前实现见第 5.2 节。初期内容仍在构建时从本地 JSON 生成,在线数据管理需后续接入后端。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 技术选型
|
## 5. 技术选型
|
||||||
@@ -99,16 +101,16 @@ simple-icons
|
|||||||
|
|
||||||
### 5.2 渲染策略
|
### 5.2 渲染策略
|
||||||
|
|
||||||
采用静态导出:
|
采用 standalone 输出:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// next.config.ts
|
// next.config.ts
|
||||||
const nextConfig = { output: "export" }
|
const nextConfig = { output: "standalone" }
|
||||||
```
|
```
|
||||||
|
|
||||||
- 首页与全部分类页在构建时从本地数据生成(`generateStaticParams`);
|
- 首页与全部分类页在构建时从本地数据生成(`generateStaticParams`);
|
||||||
- 时钟、搜索、侧边栏折叠等交互以 client 组件实现;
|
- 时钟、搜索、侧边栏折叠等交互以 client 组件实现;
|
||||||
- 产物为纯静态文件,可部署到任意静态服务器;
|
- 构建产物为 `.next/standalone` 下的最小 Node 服务器(`server.js`),`public` 与 `.next/static` 需按 Next 文档在部署时手动复制;
|
||||||
- 后续接 Go 后端时,改造 service 层为 fetch,数据页面改为客户端获取。
|
- 后续接 Go 后端时,改造 service 层为 fetch,数据页面改为客户端获取。
|
||||||
|
|
||||||
### 5.3 数据访问层
|
### 5.3 数据访问层
|
||||||
@@ -422,7 +424,7 @@ polaris/
|
|||||||
types/ # navigation.ts / settings.ts / search.ts
|
types/ # navigation.ts / settings.ts / search.ts
|
||||||
lib/ # cn.ts / greeting.ts / local-search.ts / search-providers.ts / icon.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
|
vitest.config.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { NextConfig } from "next"
|
import type { NextConfig } from "next"
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "export",
|
output: "standalone",
|
||||||
trailingSlash: true,
|
trailingSlash: true,
|
||||||
images: {
|
images: {
|
||||||
unoptimized: true,
|
unoptimized: true,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"verify:export": "node scripts/verify-static-export.mjs",
|
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Next.js Windows 静态导出补丁
|
# Next.js Windows 静态导出历史补丁
|
||||||
|
|
||||||
## 原因与范围
|
## 原因与范围
|
||||||
|
|
||||||
@@ -17,13 +17,12 @@
|
|||||||
```bash
|
```bash
|
||||||
corepack pnpm install --frozen-lockfile
|
corepack pnpm install --frozen-lockfile
|
||||||
corepack pnpm build
|
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 }>
|
params: Promise<{ categoryId: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
// 静态导出不支持运行时补生成动态路径,未知分类统一返回 404。
|
// 仅预生成 generateStaticParams 列出的分类,未知分类统一返回 404。
|
||||||
export const dynamicParams = false
|
export const dynamicParams = false
|
||||||
|
|
||||||
export function generateStaticParams() {
|
export function generateStaticParams() {
|
||||||
|
|||||||
Reference in New Issue
Block a user