From 3c1b84ccd0941ed9ffbce76bb96d893c685c7582 Mon Sep 17 00:00:00 2001 From: 134 Date: Wed, 15 Jul 2026 11:26:41 +0800 Subject: [PATCH] fix: prevent white screens during production rebuilds --- next.config.ts | 1 + package.json | 2 +- scripts/deploy-pascal-production.sh | 70 ++++++++++++++++--- .../routes/production-local-fallbacks.test.ts | 20 ++++++ tsconfig.json | 4 +- 5 files changed, 86 insertions(+), 11 deletions(-) diff --git a/next.config.ts b/next.config.ts index fdd09a564..972b39199 100644 --- a/next.config.ts +++ b/next.config.ts @@ -29,6 +29,7 @@ function resolvePascalEditorFrameSources(isDev: boolean) { } const nextConfig: NextConfig = { + distDir: process.env.NEXT_DIST_DIR?.trim() || ".next", poweredByHeader: false, allowedDevOrigins: ["127.0.0.1"], // 禁用 cacheComponents 以支持 dynamic = "force-dynamic" diff --git a/package.json b/package.json index d107e84ef..5ef2de287 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "prebuild": "node scripts/security/auto-enable-perf-guard.mjs", "build": "next build", "build:with-migrate": "pnpm db:migrate && next build", - "postbuild": "find .next/static -type f -name '*.map' -delete 2>/dev/null || true", + "postbuild": "find \"${NEXT_DIST_DIR:-.next}/static\" -type f -name '*.map' -delete 2>/dev/null || true", "start": "next start", "lint": "pnpm exec ultracite check", "check:logs": "node scripts/check-log-hygiene.mjs && tsx scripts/check-log-redaction.ts", diff --git a/scripts/deploy-pascal-production.sh b/scripts/deploy-pascal-production.sh index 0d7c311c8..f429d6482 100755 --- a/scripts/deploy-pascal-production.sh +++ b/scripts/deploy-pascal-production.sh @@ -10,6 +10,7 @@ MAIN_PM2_NAME="${MAIN_PM2_NAME:-ai-chatbot}" MAIN_INTERNAL_URL="${MAIN_INTERNAL_URL:-http://127.0.0.1:3125}" MAIN_NGINX_UPSTREAM="${MAIN_NGINX_UPSTREAM:-127.0.0.1:3125}" BUILD_MAIN_APP="${BUILD_MAIN_APP:-true}" +MAIN_STATIC_RETENTION_DAYS="${MAIN_STATIC_RETENTION_DAYS:-7}" PASCAL_INSTALL_DIR="${PASCAL_INSTALL_DIR:-/opt/pascal-editor}" PASCAL_SOURCE_DIR="${PASCAL_SOURCE_DIR:-$MAIN_APP_DIR/services/pascal-editor}" @@ -137,9 +138,20 @@ install_rsync() { DEBIAN_FRONTEND=noninteractive apt-get install -y -qq rsync } +restart_main_app() { + set -a + # shellcheck disable=SC1090 + source "$MAIN_ENV_FILE" + set +a + pm2 restart "$MAIN_PM2_NAME" --update-env +} + configure_main_app() { local secret="$1" local next_env_backup="" + local current_build="$MAIN_APP_DIR/.next" + local staged_build="$MAIN_APP_DIR/.next-build" + local previous_build="$MAIN_APP_DIR/.next-previous" mkdir -p "$PASCAL_BACKUP_DIR" if [ -f "$MAIN_ENV_FILE" ]; then cp "$MAIN_ENV_FILE" "$PASCAL_BACKUP_DIR/main-env-$(date +%Y%m%d%H%M%S).bak" @@ -155,7 +167,11 @@ configure_main_app() { require_command pnpm require_command pm2 + [[ "$MAIN_STATIC_RETENTION_DAYS" =~ ^[0-9]+$ ]] || + die "MAIN_STATIC_RETENTION_DAYS must be a non-negative integer" log "Building main application in $MAIN_APP_DIR" + [ ! -e "$staged_build" ] || rm -r "$staged_build" + [ ! -e "$previous_build" ] || rm -r "$previous_build" if [ -f "$MAIN_APP_DIR/next-env.d.ts" ]; then next_env_backup="$(mktemp)" cp "$MAIN_APP_DIR/next-env.d.ts" "$next_env_backup" @@ -169,21 +185,56 @@ configure_main_app() { source "$MAIN_ENV_FILE" set +a DISABLE_PERF_GUARD_AUTO_INSTALL=1 pnpm install --frozen-lockfile --prod=false - DISABLE_PERF_GUARD_AUTO_INSTALL=1 pnpm build + NEXT_DIST_DIR=.next-build DISABLE_PERF_GUARD_AUTO_INSTALL=1 pnpm build ) || build_status=$? set -e if [ -n "$next_env_backup" ]; then cp "$next_env_backup" "$MAIN_APP_DIR/next-env.d.ts" rm -f "$next_env_backup" fi - [ "$build_status" -eq 0 ] || die "Main application build failed" - ( - set -a - # shellcheck disable=SC1090 - source "$MAIN_ENV_FILE" - set +a - pm2 restart "$MAIN_PM2_NAME" --update-env - ) + [ "$build_status" -eq 0 ] || { + rm -rf "$staged_build" + die "Main application build failed" + } + [ -f "$staged_build/BUILD_ID" ] || die "Main staged build did not produce a BUILD_ID" + + if [ -d "$current_build/static" ]; then + log "Retaining static chunks from the active main build" + mkdir -p "$staged_build/static" + rsync -a --ignore-existing "$current_build/static/" "$staged_build/static/" + find "$staged_build/static" -type f -mtime +"$MAIN_STATIC_RETENTION_DAYS" -delete + find "$staged_build/static" -depth -type d -empty -delete + fi + + if [ -e "$current_build" ]; then + mv "$current_build" "$previous_build" + fi + if ! mv "$staged_build" "$current_build"; then + [ ! -e "$previous_build" ] || mv "$previous_build" "$current_build" + die "Could not activate the staged main build" + fi + + if ! restart_main_app; then + log "Main restart failed; rolling back the previous build" + rm -rf "$current_build" + [ ! -e "$previous_build" ] || mv "$previous_build" "$current_build" + restart_main_app || true + die "Main application failed to restart" + fi + + local health_attempt + for health_attempt in $(seq 1 30); do + if curl -fsS "$MAIN_INTERNAL_URL/ping" >/dev/null 2>&1; then + return + fi + sleep 1 + done + + log "Main health check failed; rolling back the previous build" + rm -rf "$current_build" + [ ! -e "$previous_build" ] || mv "$previous_build" "$current_build" + restart_main_app || true + die "Main application did not become healthy after deployment" } sync_pascal_source() { @@ -444,6 +495,7 @@ main() { fi [ "$(id -u)" -eq 0 ] || die "Production deployment must run as root" + install_rsync configure_main_app "$secret" install_bun sync_pascal_source diff --git a/tests/routes/production-local-fallbacks.test.ts b/tests/routes/production-local-fallbacks.test.ts index f690a657a..6deffcecc 100644 --- a/tests/routes/production-local-fallbacks.test.ts +++ b/tests/routes/production-local-fallbacks.test.ts @@ -21,3 +21,23 @@ test("requires an explicit environment switch for local fallbacks", () => { ); } }); + +test("production deploy stages the main build and retains active static chunks", () => { + const nextConfig = readFileSync( + resolve(process.cwd(), "next.config.ts"), + "utf8" + ); + const deployScript = readFileSync( + resolve(process.cwd(), "scripts/deploy-pascal-production.sh"), + "utf8" + ); + + expect(nextConfig).toContain( + 'distDir: process.env.NEXT_DIST_DIR?.trim() || ".next"' + ); + expect(deployScript).toContain("NEXT_DIST_DIR=.next-build"); + expect(deployScript).toContain( + 'rsync -a --ignore-existing "$current_build/static/" "$staged_build/static/"' + ); + expect(deployScript).toContain('curl -fsS "$MAIN_INTERNAL_URL/ping"'); +}); diff --git a/tsconfig.json b/tsconfig.json index 93154cf65..9c088efda 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,7 +29,9 @@ "**/*.tsx", ".next/types/**/*.ts", "next.config.js", - ".next/dev/types/**/*.ts" + ".next/dev/types/**/*.ts", + ".next-build/types/**/*.ts", + ".next-build/dev/types/**/*.ts" ], "exclude": ["node_modules", "services/pascal-editor"] }