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.<hash>.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.
This commit is contained in:
+27
-16
@@ -86,8 +86,35 @@ COPY --from=version-builder /build-commit /app/.build-commit
|
|||||||
USER appuser
|
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
|
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.<hash>.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" \
|
LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minishop" \
|
||||||
org.opencontainers.image.title="remnawave-minishop-backend" \
|
org.opencontainers.image.title="remnawave-minishop-backend" \
|
||||||
org.opencontainers.image.description="Remnawave Minishop backend (Telegram bot API, web app, webhooks)." \
|
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"]
|
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
|
FROM nginx:1.27-alpine AS frontend
|
||||||
|
|
||||||
LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minishop" \
|
LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minishop" \
|
||||||
|
|||||||
@@ -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.<hash>.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<body>.*?)(?:\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()
|
||||||
Reference in New Issue
Block a user