fix(deploy): 前端资源 - 保留旧版本分片并恢复加载
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
52
docker/canvas-web-entrypoint.sh
Normal file
52
docker/canvas-web-entrypoint.sh
Normal 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;'
|
||||
27
nginx.conf
27
nginx.conf
@@ -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/ {
|
||||
|
||||
43
web/src/lib/chunk-recovery.ts
Normal file
43
web/src/lib/chunk-recovery.ts
Normal 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();
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user