diff --git a/Dockerfile b/Dockerfile index 62c5250fe..2cb0f9a99 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index cc4327256..c86f5f95f 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -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: diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 7dd0a5504..a95c20666 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -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: diff --git a/docker/canvas-web-entrypoint.sh b/docker/canvas-web-entrypoint.sh new file mode 100644 index 000000000..1bbeed28f --- /dev/null +++ b/docker/canvas-web-entrypoint.sh @@ -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;' diff --git a/nginx.conf b/nginx.conf index 270705bd3..b82e5dec0 100644 --- a/nginx.conf +++ b/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/ { diff --git a/web/src/lib/chunk-recovery.ts b/web/src/lib/chunk-recovery.ts new file mode 100644 index 000000000..697b7430e --- /dev/null +++ b/web/src/lib/chunk-recovery.ts @@ -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(); +} diff --git a/web/src/main.tsx b/web/src/main.tsx index b3e3dbd13..8c668638b 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -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")); } diff --git a/web/src/pages/route-error.tsx b/web/src/pages/route-error.tsx index c318a5d4f..7fae583f3 100644 --- a/web/src/pages/route-error.tsx +++ b/web/src/pages/route-error.tsx @@ -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() {

当前页面没有正常加载

{message}

- +