From a2ce29da453ba5c04e16308f2a96fbe6d54fc64b Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Mon, 8 Jun 2026 22:34:03 +0300 Subject: [PATCH] fix: ship hashed webapp assets in backend image The backend renders the Mini App shell and rewrites the stylesheet and script tags to content-hashed names (subscription_webapp..css). Those hashed files are gitignored build artifacts, so a clean checkout has none of them and the backend image was built without any webapp assets. The resolver therefore stat()-ed a missing file and fell back to the bare /subscription_webapp.css URL. That bare URL never changes between deploys and is served no-store. Most clients re-fetch it, but iOS WebViews (WKWebView) ignore no-store for subresources and keep serving a stale cached copy, so after every deploy the CSS no longer matched the markup and the Mini App looked broken on iOS only. The earlier no-store / ?v= / Clear-Site-Data attempts could not help because none of them gave iOS a new URL to fetch. Copy the freshly built assets from the frontend-builder stage into the backend image (frontend-builder is reordered ahead of the backend stage so the copy resolves). The build is deterministic, so the hash matches the one the nginx image serves; the shell now emits immutable, hashed URLs that change on every asset change and force iOS to fetch fresh CSS. --- deploy/docker/Dockerfile | 43 +++++++++++++-------- tests/test_docker_webapp_assets.py | 62 ++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 16 deletions(-) create mode 100644 tests/test_docker_webapp_assets.py diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index 66791a4..8b1b1f5 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -86,8 +86,35 @@ COPY --from=version-builder /build-commit /app/.build-commit USER appuser +FROM node:22-slim AS frontend-builder + +WORKDIR /app/frontend + +COPY frontend/package.json frontend/package-lock.json* ./ +RUN --mount=type=cache,target=/root/.npm \ + if [ -f package-lock.json ]; then npm ci; else npm install; fi + +COPY frontend ./ +RUN mkdir -p ../backend/bot/app/web/templates + +RUN npm run build:webapp +COPY backend/bot/app/web/templates/subscription_webapp.html ../backend/bot/app/web/templates/subscription_webapp.html +RUN node ./scripts/prepare_nginx_assets.mjs --out ../frontend-nginx-dist + + FROM python-base AS backend +# The Mini App shell is rendered by the backend, which rewrites the stylesheet +# and script tags to the content-hashed asset names (subscription_webapp..css). +# Those hashed files are build artifacts and gitignored, so a clean checkout has +# none of them: without this copy the asset resolver falls back to the bare +# /subscription_webapp.css URL. That URL never changes between deploys and is +# served no-store, which iOS WebViews (WKWebView) cache aggressively and refuse to +# revalidate -> stale CSS and a broken-looking Mini App on iOS only. Pull the +# freshly built assets (the same hashes the nginx image serves) into the image so +# the shell emits immutable, cache-busting URLs. +COPY --from=frontend-builder /app/backend/bot/app/web/templates/ ./backend/bot/app/web/templates/ + LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minishop" \ org.opencontainers.image.title="remnawave-minishop-backend" \ org.opencontainers.image.description="Remnawave Minishop backend (Telegram bot API, web app, webhooks)." \ @@ -107,22 +134,6 @@ LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minis CMD ["python", "backend/main_worker.py"] -FROM node:22-slim AS frontend-builder - -WORKDIR /app/frontend - -COPY frontend/package.json frontend/package-lock.json* ./ -RUN --mount=type=cache,target=/root/.npm \ - if [ -f package-lock.json ]; then npm ci; else npm install; fi - -COPY frontend ./ -RUN mkdir -p ../backend/bot/app/web/templates - -RUN npm run build:webapp -COPY backend/bot/app/web/templates/subscription_webapp.html ../backend/bot/app/web/templates/subscription_webapp.html -RUN node ./scripts/prepare_nginx_assets.mjs --out ../frontend-nginx-dist - - FROM nginx:1.27-alpine AS frontend LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minishop" \ diff --git a/tests/test_docker_webapp_assets.py b/tests/test_docker_webapp_assets.py new file mode 100644 index 0000000..b99c490 --- /dev/null +++ b/tests/test_docker_webapp_assets.py @@ -0,0 +1,62 @@ +"""Guards the Docker packaging of the Mini App's hashed web assets. + +The backend renders the Mini App shell and rewrites the stylesheet/script tags +to content-hashed asset names (``subscription_webapp..css``). Those hashed +files are gitignored build artifacts, so the backend image only sees them if the +Dockerfile copies them in from the ``frontend-builder`` stage. Without that copy +the asset resolver falls back to the bare ``/subscription_webapp.css`` URL, which +never changes between deploys and is served ``no-store`` -- iOS WebViews cache it +aggressively and render a stale, broken-looking Mini App. + +These checks fail loudly if a future Dockerfile refactor drops the copy or moves +the stages so the copy can no longer resolve. +""" + +import re +import unittest +from pathlib import Path + +DOCKERFILE_PATH = Path(__file__).resolve().parents[1] / "deploy" / "docker" / "Dockerfile" + + +class DockerWebappAssetTests(unittest.TestCase): + def setUp(self) -> None: + self.dockerfile = DOCKERFILE_PATH.read_text(encoding="utf-8") + + def test_backend_stage_copies_built_webapp_assets(self) -> None: + self.assertRegex( + self.dockerfile, + r"COPY\s+--from=frontend-builder\s+\S*backend/bot/app/web/templates/" + r"\s+\S*backend/bot/app/web/templates/", + "backend image must copy the freshly built (hashed) webapp assets so the " + "shell emits immutable, cache-busting asset URLs", + ) + + def test_frontend_builder_is_defined_before_backend_stage(self) -> None: + builder_idx = self.dockerfile.find("AS frontend-builder") + backend_idx = self.dockerfile.find("AS backend") + self.assertNotEqual(builder_idx, -1, "frontend-builder stage is missing") + self.assertNotEqual(backend_idx, -1, "backend stage is missing") + self.assertLess( + builder_idx, + backend_idx, + "frontend-builder must be defined before the backend stage that copies from it", + ) + + def test_frontend_builder_builds_the_webapp_assets(self) -> None: + self.assertIn("npm run build:webapp", self.dockerfile) + + def test_worker_stage_does_not_copy_webapp_assets(self) -> None: + # The worker runs background jobs and never serves the web shell, so it + # should stay lean and not depend on the frontend build. + worker_match = re.search( + r"FROM\s+python-base\s+AS\s+worker(?P.*?)(?:\nFROM\s|\Z)", + self.dockerfile, + flags=re.DOTALL, + ) + self.assertIsNotNone(worker_match, "worker stage is missing") + self.assertNotIn("frontend-builder", worker_match.group("body")) + + +if __name__ == "__main__": + unittest.main()