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()