fix(deploy): 前端资源 - 保留旧版本分片并恢复加载

This commit is contained in:
ddCat
2026-09-24 00:04:55 +08:00
parent 117fa7d70b
commit 69b9f02937
8 changed files with 137 additions and 6 deletions

View File

@@ -21,8 +21,12 @@ RUN bun --bun ./node_modules/vite/bin/vite.js build
# 运行镜像:nginx 托管静态前端,并在 Compose 中把 /api 转发到后端服务。
FROM nginx:1.27-alpine
COPY --from=web-build /app/web/dist /usr/share/nginx/html
COPY --from=web-build /app/web/dist /opt/canvas-release
COPY docker/canvas-web-entrypoint.sh /usr/local/bin/canvas-web-entrypoint
RUN chmod +x /usr/local/bin/canvas-web-entrypoint
COPY nginx.conf /etc/nginx/conf.d/default.conf
ENTRYPOINT ["/usr/local/bin/canvas-web-entrypoint"]
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD wget -qO- http://127.0.0.1:3000/ >/dev/null || exit 1

View File

@@ -74,6 +74,9 @@ services:
image: ghcr.io/ddcat-ai/open-ai-canvas-web:${CANVAS_IMAGE_TAG:-latest}
ports:
- "${CANVAS_HTTP_PORT:-3000}:3000"
volumes:
# Keep hashed chunks from the current and previous web releases for old tabs.
- ${CANVAS_WEB_DATA_PATH:-web-data}:/usr/share/nginx/html
depends_on:
backend:
condition: service_healthy
@@ -83,3 +86,4 @@ volumes:
backend-data:
postgres-data:
redis-data:
web-data:

View File

@@ -41,6 +41,8 @@ services:
dockerfile: Dockerfile
ports:
- "3000:3000"
volumes:
- web-data:/usr/share/nginx/html
depends_on:
backend:
condition: service_healthy
@@ -64,3 +66,4 @@ services:
volumes:
backend-data:
web-data:

View File

@@ -0,0 +1,52 @@
#!/bin/sh
set -eu
release_dir=${CANVAS_WEB_RELEASE_DIR:-/opt/canvas-release}
html_dir=${CANVAS_WEB_HTML_DIR:-/usr/share/nginx/html}
retention_days=${CANVAS_WEB_ASSET_RETENTION_DAYS:-7}
case "$retention_days" in ''|*[!0-9]*|0) echo 'Invalid asset retention days' >&2; exit 1;; esac
test -s "$release_dir/index.html"
mkdir -p "$html_dir"
# One writer per volume; do not serve a partial publication after a copy error.
lock="$html_dir/.canvas-publish-lock"
mkdir "$lock" || { echo 'Another publication is active; inspect the publication lock' >&2; exit 1; }
stage=''
cleanup() { test -z "$stage" || rm -rf "$stage"; rmdir "$lock"; }
trap cleanup EXIT
trap 'exit 1' HUP INT TERM
stage=$(mktemp -d "$html_dir/.canvas-stage.XXXXXX")
cp -R "$release_dir/." "$stage/"
test -d "$stage/assets"
(cd "$stage" && find assets -type f | LC_ALL=C sort) > "$stage/.canvas-assets"
# Publish complete files by rename on the same filesystem. HTML is switched last.
find "$stage" -type f ! -path "$stage/index.html" ! -path "$stage/.canvas-assets" | while IFS= read -r file; do
relative=${file#"$stage/"}
mkdir -p "$html_dir/$(dirname "$relative")"
if test -f "$html_dir/$relative" && cmp -s "$file" "$html_dir/$relative"; then
touch "$html_dir/$relative"
else
mv -f "$file" "$html_dir/$relative"
touch "$html_dir/$relative"
fi
done
if test -f "$html_dir/.canvas-assets-current" && ! cmp -s "$stage/.canvas-assets" "$html_dir/.canvas-assets-current"; then
cp "$html_dir/.canvas-assets-current" "$html_dir/.canvas-assets-previous"
fi
mv -f "$stage/index.html" "$html_dir/index.html"
mv -f "$stage/.canvas-assets" "$html_dir/.canvas-assets-current"
# Retain current + previous builds unconditionally, and older chunks for 7 days.
# Only prune hashed assets, never application data or non-asset public files.
find "$html_dir/assets" -type f -mtime +"$retention_days" | while IFS= read -r file; do
relative=${file#"$html_dir/"}
if grep -Fxq "$relative" "$html_dir/.canvas-assets-current" || { test -f "$html_dir/.canvas-assets-previous" && grep -Fxq "$relative" "$html_dir/.canvas-assets-previous"; }; then
continue
fi
rm -f "$file"
done
cleanup
trap - EXIT HUP INT TERM
exec nginx -g 'daemon off;'

View File

@@ -16,6 +16,8 @@ server {
client_max_body_size 1024m;
access_log /var/log/nginx/access.log canvas_no_query;
add_header Referrer-Policy "no-referrer" always;
# Also covers exact SPA aliases (/canvas, /assets, /welcome).
add_header Cache-Control "no-cache, must-revalidate" always;
gzip on;
gzip_comp_level 5;
gzip_min_length 1024;
@@ -23,13 +25,32 @@ server {
gzip_vary on;
gzip_types application/json application/javascript text/css text/plain image/svg+xml;
location = / {
try_files /index.html =404;
}
location = /index.html {
try_files $uri =404;
}
# Vite assets use content hashes, so they are safe to cache across deployments.
# Keep index.html uncached below so it always points to the current asset manifest.
# The deployment volume keeps old chunks available while existing tabs update.
location ^~ /assets/ {
access_log off;
try_files $uri =404;
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header Referrer-Policy "no-referrer" always;
add_header Cache-Control "public, max-age=31536000, immutable";
error_page 404 = @missing_asset;
}
location @missing_asset {
add_header Cache-Control "no-store" always;
add_header Referrer-Policy "no-referrer" always;
return 404;
}
location ~ /\.canvas- {
return 404;
}
location ^~ /api/public/canvas-shares/ {

View File

@@ -0,0 +1,43 @@
const RECOVERY_QUERY = "__chunk_recovery";
const RECOVERY_KEY = "canvas:chunk-recovery";
let recovering = false;
export function installChunkRecovery() {
window.addEventListener("vite:preloadError", handlePreloadError);
}
export function markChunkRecoverySucceeded() {
// An entry module loading says nothing about lazy routes. Keep the guard for
// the tab's lifetime; the URL also protects browsers with disabled storage.
}
/** Explicit user retry is allowed even after the automatic recovery was used. */
export function reloadAfterChunkFailure() {
navigateToFreshEntry();
}
export function tryRecoverChunkFailure() {
if (recovering) return true;
const url = new URL(window.location.href);
if (url.searchParams.has(RECOVERY_QUERY)) return false;
try {
if (sessionStorage.getItem(RECOVERY_KEY) === "1") return false;
sessionStorage.setItem(RECOVERY_KEY, "1");
} catch {
// The query marker survives the reload when session storage is denied.
}
recovering = true;
navigateToFreshEntry();
return true;
}
function navigateToFreshEntry() {
const url = new URL(window.location.href);
url.searchParams.set(RECOVERY_QUERY, String(Date.now()));
window.location.replace(url.toString());
}
function handlePreloadError(event: Event) {
// On the second failure let Vite throw to the route error boundary.
if (tryRecoverChunkFailure()) event.preventDefault();
}

View File

@@ -1,12 +1,15 @@
import "@fontsource-variable/inter";
import "@fontsource-variable/jetbrains-mono";
import { installChunkRecovery } from "@/lib/chunk-recovery";
import { bootstrapAppearance } from "@/services/appearance-bootstrap";
import { isIsolatedDirectorRepro } from "@/lib/dev-repro";
installChunkRecovery();
// The public film entry checks its availability independently of workspace bootstrap.
if (/^\/welcome\/?$/.test(window.location.pathname)) void import("./welcome-application");
else {
// The backend-free DEV lab must not make requests before AppProviders isolates it.
const appearanceReady = isIsolatedDirectorRepro(import.meta.env.DEV, window.location.pathname) ? Promise.resolve() : bootstrapAppearance();
void appearanceReady.finally(() => import("./application"));
void appearanceReady.then(() => import("./application"));
}

View File

@@ -3,6 +3,7 @@ import { Home, RefreshCw } from "lucide-react";
import { useNavigate, useRouteError } from "react-router";
import { WorkspaceSignalIcon } from "@/components/ui/aceternity/workspace-signal-icon";
import { reloadAfterChunkFailure } from "@/lib/chunk-recovery";
export default function RouteErrorPage() {
const error = useRouteError();
@@ -17,7 +18,7 @@ export default function RouteErrorPage() {
<h1 className="mt-3 text-2xl font-semibold">当前页面没有正常加载</h1>
<p className="mt-3 break-words text-sm leading-6 text-muted-foreground">{message}</p>
<div className="mt-6 flex justify-center gap-3">
<Button icon={<RefreshCw className="size-4" />} onClick={() => window.location.reload()}>重新加载</Button>
<Button icon={<RefreshCw className="size-4" />} onClick={reloadAfterChunkFailure}>重新加载</Button>
<Button type="primary" icon={<Home className="size-4" />} onClick={() => navigate("/")}>返回主页</Button>
</div>
</section>