perf: optimize webapp asset delivery, split admin webapp bundle

This commit is contained in:
3252a8
2026-05-21 10:17:23 +03:00
parent f2c722bdfc
commit db3611487e
15 changed files with 820 additions and 139 deletions
+24
View File
@@ -17,10 +17,34 @@ bot/app/web/templates/subscription_webapp.css
bot/app/web/templates/subscription_webapp.js bot/app/web/templates/subscription_webapp.js
bot/app/web/templates/subscription_webapp.min.*.js bot/app/web/templates/subscription_webapp.min.*.js
bot/app/web/templates/subscription_webapp.*.css bot/app/web/templates/subscription_webapp.*.css
bot/app/web/templates/subscription_webapp.min.*.js.br
bot/app/web/templates/subscription_webapp.min.*.js.gz
bot/app/web/templates/subscription_webapp.*.css.br
bot/app/web/templates/subscription_webapp.*.css.gz
bot/app/web/templates/subscription_webapp_admin.css
bot/app/web/templates/subscription_webapp_admin.js
bot/app/web/templates/subscription_webapp_admin.min.*.js
bot/app/web/templates/subscription_webapp_admin.*.css
bot/app/web/templates/subscription_webapp_admin.min.*.js.br
bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
bot/app/web/templates/subscription_webapp_admin.*.css.br
bot/app/web/templates/subscription_webapp_admin.*.css.gz
backend/bot/app/web/templates/subscription_webapp.css backend/bot/app/web/templates/subscription_webapp.css
backend/bot/app/web/templates/subscription_webapp.js backend/bot/app/web/templates/subscription_webapp.js
backend/bot/app/web/templates/subscription_webapp.min.*.js backend/bot/app/web/templates/subscription_webapp.min.*.js
backend/bot/app/web/templates/subscription_webapp.*.css backend/bot/app/web/templates/subscription_webapp.*.css
backend/bot/app/web/templates/subscription_webapp.min.*.js.br
backend/bot/app/web/templates/subscription_webapp.min.*.js.gz
backend/bot/app/web/templates/subscription_webapp.*.css.br
backend/bot/app/web/templates/subscription_webapp.*.css.gz
backend/bot/app/web/templates/subscription_webapp_admin.css
backend/bot/app/web/templates/subscription_webapp_admin.js
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js
backend/bot/app/web/templates/subscription_webapp_admin.*.css
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.br
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
backend/bot/app/web/templates/subscription_webapp_admin.*.css.br
backend/bot/app/web/templates/subscription_webapp_admin.*.css.gz
tmp tmp
.claude .claude
+284 -23
View File
@@ -1,5 +1,6 @@
# ruff: noqa: F401,F403,F405,I001 # ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405 from ._runtime import * # noqa: F403,F405
import gzip
from config.webapp_themes_config import ( from config.webapp_themes_config import (
default_webapp_theme_asset_file, default_webapp_theme_asset_file,
@@ -10,6 +11,8 @@ from config.webapp_themes_config import (
) )
_TEXT_FILE_CACHE: Dict[tuple[str, bool], tuple[int, int, str]] = {} _TEXT_FILE_CACHE: Dict[tuple[str, bool], tuple[int, int, str]] = {}
_BINARY_FILE_CACHE: Dict[str, tuple[int, int, bytes]] = {}
_GZIP_BODY_CACHE: Dict[str, bytes] = {}
_ASSET_NAME_CACHE: Dict[tuple[str, str], tuple[float, str]] = {} _ASSET_NAME_CACHE: Dict[tuple[str, str], tuple[float, str]] = {}
_I18N_PAYLOAD_CACHE: Dict[tuple[int, str, tuple[tuple[str, int, int], ...]], Dict[str, Any]] = {} _I18N_PAYLOAD_CACHE: Dict[tuple[int, str, tuple[tuple[str, int, int], ...]], Dict[str, Any]] = {}
_ASSET_NAME_CACHE_TTL_SECONDS = 30.0 _ASSET_NAME_CACHE_TTL_SECONDS = 30.0
@@ -20,9 +23,22 @@ async def health_route(request: web.Request) -> web.Response:
async def css_asset_route(request: web.Request) -> web.Response: async def css_asset_route(request: web.Request) -> web.Response:
return await _css_asset_route(request, base_name="subscription_webapp")
async def admin_css_asset_route(request: web.Request) -> web.Response:
return await _css_asset_route(request, base_name="subscription_webapp_admin")
async def _css_asset_route(request: web.Request, *, base_name: str) -> web.Response:
asset_hash = request.match_info.get("asset_hash") asset_hash = request.match_info.get("asset_hash")
filename = f"subscription_webapp.{asset_hash}.css" if asset_hash else "subscription_webapp.css" filename = f"{base_name}.{asset_hash}.css" if asset_hash else f"{base_name}.css"
response = await _serve_template_asset(request, filename, "text/css") response = await _serve_template_asset(
request,
filename,
"text/css",
allow_precompressed=bool(asset_hash),
)
response.headers["Cache-Control"] = ( response.headers["Cache-Control"] = (
"public, max-age=31536000, immutable" if asset_hash else "no-cache" "public, max-age=31536000, immutable" if asset_hash else "no-cache"
) )
@@ -78,19 +94,48 @@ async def theme_css_asset_route(request: web.Request) -> web.Response:
except ValueError: except ValueError:
raise web.HTTPNotFound(text="theme_css_not_found") from None raise web.HTTPNotFound(text="theme_css_not_found") from None
cache_control = "no-cache"
try: try:
if path.stat().st_size > WEBAPP_THEME_CSS_MAX_BYTES: stat = path.stat()
if stat.st_size > WEBAPP_THEME_CSS_MAX_BYTES:
raise web.HTTPNotFound(text="theme_css_too_large") raise web.HTTPNotFound(text="theme_css_too_large")
etag = _theme_asset_etag(
"theme-css",
rel_path,
stat_mtime_ns=stat.st_mtime_ns,
size=stat.st_size,
)
if _request_etag_matches(request, etag):
return _not_modified_response(
cache_control=cache_control,
etag=etag,
vary="Accept-Encoding",
)
text = path.read_text(encoding="utf-8") text = path.read_text(encoding="utf-8")
except OSError: except OSError:
defaults = default_webapp_theme_css_files() defaults = default_webapp_theme_css_files()
text = defaults.get(rel_path.as_posix()) text = defaults.get(rel_path.as_posix())
if text is None: if text is None:
raise web.HTTPNotFound(text="theme_css_not_found") from None raise web.HTTPNotFound(text="theme_css_not_found") from None
etag = _theme_asset_etag(
"theme-css",
rel_path,
body=text.encode("utf-8"),
)
if _request_etag_matches(request, etag):
return _not_modified_response(
cache_control=cache_control,
etag=etag,
vary="Accept-Encoding",
)
response = web.Response(text=text, content_type="text/css", charset="utf-8") return _theme_text_response(
response.headers["Cache-Control"] = "no-cache" request,
return response text,
content_type="text/css",
cache_control=cache_control,
etag=etag,
)
async def theme_asset_route(request: web.Request) -> web.Response: async def theme_asset_route(request: web.Request) -> web.Response:
@@ -115,9 +160,23 @@ async def theme_asset_route(request: web.Request) -> web.Response:
if not content_type: if not content_type:
raise web.HTTPNotFound(text="theme_asset_not_found") raise web.HTTPNotFound(text="theme_asset_not_found")
query = getattr(request, "query", {})
cache_control = (
"public, max-age=31536000, immutable" if query.get("v") else "public, max-age=3600"
)
try: try:
if path.stat().st_size > WEBAPP_THEME_ASSET_MAX_BYTES: stat = path.stat()
if stat.st_size > WEBAPP_THEME_ASSET_MAX_BYTES:
raise web.HTTPNotFound(text="theme_asset_too_large") raise web.HTTPNotFound(text="theme_asset_too_large")
etag = _theme_asset_etag(
"theme-asset",
rel_path,
stat_mtime_ns=stat.st_mtime_ns,
size=stat.st_size,
)
if _request_etag_matches(request, etag):
return _not_modified_response(cache_control=cache_control, etag=etag)
body = path.read_bytes() body = path.read_bytes()
except OSError: except OSError:
fallback = default_webapp_theme_asset_file(rel_path) fallback = default_webapp_theme_asset_file(rel_path)
@@ -125,15 +184,16 @@ async def theme_asset_route(request: web.Request) -> web.Response:
raise web.HTTPNotFound(text="theme_asset_not_found") from None raise web.HTTPNotFound(text="theme_asset_not_found") from None
body, fallback_suffix = fallback body, fallback_suffix = fallback
content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(fallback_suffix, content_type) content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(fallback_suffix, content_type)
etag = _theme_asset_etag("theme-asset", rel_path, body=body)
if _request_etag_matches(request, etag):
return _not_modified_response(cache_control=cache_control, etag=etag)
if not body or len(body) > WEBAPP_THEME_ASSET_MAX_BYTES: if not body or len(body) > WEBAPP_THEME_ASSET_MAX_BYTES:
raise web.HTTPNotFound(text="theme_asset_not_found") raise web.HTTPNotFound(text="theme_asset_not_found")
query = getattr(request, "query", {})
response = web.Response(body=body, content_type=content_type) response = web.Response(body=body, content_type=content_type)
response.headers["Cache-Control"] = ( response.headers["Cache-Control"] = cache_control
"public, max-age=31536000, immutable" if query.get("v") else "public, max-age=3600" response.headers["ETag"] = etag
)
return response return response
@@ -885,14 +945,21 @@ async def _enforce_webapp_rate_limit(
async def js_asset_route(request: web.Request) -> web.Response: async def js_asset_route(request: web.Request) -> web.Response:
return await _js_asset_route(request, base_name="subscription_webapp")
async def admin_js_asset_route(request: web.Request) -> web.Response:
return await _js_asset_route(request, base_name="subscription_webapp_admin")
async def _js_asset_route(request: web.Request, *, base_name: str) -> web.Response:
asset_hash = request.match_info.get("asset_hash") asset_hash = request.match_info.get("asset_hash")
filename = ( filename = f"{base_name}.min.{asset_hash}.js" if asset_hash else f"{base_name}.js"
f"subscription_webapp.min.{asset_hash}.js" if asset_hash else "subscription_webapp.js"
)
response = await _serve_template_asset( response = await _serve_template_asset(
request, request,
filename, filename,
"application/javascript", "application/javascript",
allow_precompressed=bool(asset_hash),
strip_dev_mock=not asset_hash, strip_dev_mock=not asset_hash,
) )
response.headers["Cache-Control"] = ( response.headers["Cache-Control"] = (
@@ -986,6 +1053,8 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]:
"faviconUrl": cached["favicon_url"], "faviconUrl": cached["favicon_url"],
"faviconUseCustom": bool(settings.WEBAPP_FAVICON_USE_CUSTOM), "faviconUseCustom": bool(settings.WEBAPP_FAVICON_USE_CUSTOM),
"apiBase": "/api", "apiBase": "/api",
"adminJsAsset": f"/{_resolve_webapp_admin_js_asset_name()}",
"adminCssAsset": f"/{_resolve_webapp_admin_css_asset_name()}",
"telegramLoginBotUsername": request.app.get("bot_username") or "", "telegramLoginBotUsername": request.app.get("bot_username") or "",
"telegramLoginBotId": _resolve_telegram_bot_id(settings.BOT_TOKEN) or 0, "telegramLoginBotId": _resolve_telegram_bot_id(settings.BOT_TOKEN) or 0,
"telegramOAuthClientId": _resolve_telegram_oauth_client_id(settings) or 0, "telegramOAuthClientId": _resolve_telegram_oauth_client_id(settings) or 0,
@@ -1100,6 +1169,7 @@ async def _serve_template_asset(
filename: str, filename: str,
content_type: str, content_type: str,
*, *,
allow_precompressed: bool = False,
strip_dev_mock: bool = False, strip_dev_mock: bool = False,
) -> web.Response: ) -> web.Response:
settings: Settings = request.app["settings"] settings: Settings = request.app["settings"]
@@ -1107,10 +1177,169 @@ async def _serve_template_asset(
raise web.HTTPNotFound(text="webapp_disabled") raise web.HTTPNotFound(text="webapp_disabled")
path = ASSET_DIR / filename path = ASSET_DIR / filename
if allow_precompressed:
compressed = _precompressed_template_asset_response(request, path, content_type)
if compressed is not None:
return compressed
text = _read_template_text_cached(path, strip_dev_mock=strip_dev_mock) text = _read_template_text_cached(path, strip_dev_mock=strip_dev_mock)
return web.Response(text=text, content_type=content_type, charset="utf-8") return web.Response(text=text, content_type=content_type, charset="utf-8")
def _precompressed_template_asset_response(
request: web.Request,
path: Path,
content_type: str,
) -> Optional[web.Response]:
for encoding, suffix in (("br", ".br"), ("gzip", ".gz")):
if not _request_accepts_encoding(request, encoding):
continue
compressed_path = path.with_name(f"{path.name}{suffix}")
try:
body = _read_template_binary_cached(compressed_path)
except OSError:
continue
response = web.Response(body=body, content_type=content_type)
response.headers["Content-Encoding"] = encoding
response.headers["Vary"] = "Accept-Encoding"
return response
return None
def _request_accepts_encoding(request: web.Request, encoding: str) -> bool:
headers = getattr(request, "headers", {}) or {}
value = str(headers.get("Accept-Encoding", ""))
if not value:
return False
expected = encoding.lower()
for part in value.split(","):
token, *params = part.strip().split(";")
token = token.strip().lower()
if token not in {expected, "*"}:
continue
for param in params:
param = param.strip().lower()
if not param.startswith("q="):
continue
try:
if float(param[2:].strip()) <= 0:
return False
except ValueError:
return False
return True
return False
def _request_etag_matches(request: web.Request, etag: str) -> bool:
headers = getattr(request, "headers", {}) or {}
value = str(headers.get("If-None-Match", ""))
if not value:
return False
if value.strip() == "*":
return True
expected = _normalize_etag_for_compare(etag)
return any(_normalize_etag_for_compare(part.strip()) == expected for part in value.split(","))
def _normalize_etag_for_compare(value: str) -> str:
text = str(value or "").strip()
if text.lower().startswith("w/"):
text = text[2:].strip()
return text
def _theme_asset_etag(
kind: str,
rel_path: Path,
*,
stat_mtime_ns: int = 0,
size: int = 0,
body: bytes = b"",
) -> str:
if body:
digest = hashlib.sha256(
b"\0".join(
[
kind.encode("utf-8"),
rel_path.as_posix().encode("utf-8"),
body,
]
)
).hexdigest()[:16]
else:
raw = f"{kind}:{rel_path.as_posix()}:{int(stat_mtime_ns)}:{int(size)}"
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
return f'W/"{digest}"'
def _not_modified_response(
*,
cache_control: str,
etag: str,
vary: Optional[str] = None,
) -> web.Response:
response = web.Response(status=304)
response.headers["Cache-Control"] = cache_control
response.headers["ETag"] = etag
if vary:
response.headers["Vary"] = vary
return response
def _theme_text_response(
request: web.Request,
text: str,
*,
content_type: str,
cache_control: str,
etag: str,
) -> web.Response:
body = text.encode("utf-8")
if _request_accepts_encoding(request, "gzip"):
response = web.Response(
body=_gzip_body_cached(etag, body),
content_type=content_type,
charset="utf-8",
)
response.headers["Content-Encoding"] = "gzip"
response.headers["Vary"] = "Accept-Encoding"
else:
response = web.Response(text=text, content_type=content_type, charset="utf-8")
response.headers["Cache-Control"] = cache_control
response.headers["ETag"] = etag
return response
def _gzip_body_cached(cache_key: str, body: bytes) -> bytes:
cached = _GZIP_BODY_CACHE.get(cache_key)
if cached is not None:
return cached
compressed = gzip.compress(body, compresslevel=9, mtime=0)
_GZIP_BODY_CACHE[cache_key] = compressed
if len(_GZIP_BODY_CACHE) > 24:
_GZIP_BODY_CACHE.clear()
_GZIP_BODY_CACHE[cache_key] = compressed
return compressed
def _read_template_binary_cached(path: Path) -> bytes:
stat = path.stat()
key = str(path.resolve())
cached = _BINARY_FILE_CACHE.get(key)
if cached and cached[0] == stat.st_mtime_ns and cached[1] == stat.st_size:
return cached[2]
body = path.read_bytes()
_BINARY_FILE_CACHE[key] = (stat.st_mtime_ns, stat.st_size, body)
if len(_BINARY_FILE_CACHE) > 24:
_BINARY_FILE_CACHE.clear()
_BINARY_FILE_CACHE[key] = (stat.st_mtime_ns, stat.st_size, body)
return body
def _read_template_text_cached(path: Path, *, strip_dev_mock: bool = False) -> str: def _read_template_text_cached(path: Path, *, strip_dev_mock: bool = False) -> str:
stat = path.stat() stat = path.stat()
key = (str(path.resolve()), strip_dev_mock) key = (str(path.resolve()), strip_dev_mock)
@@ -1133,28 +1362,60 @@ def _read_template_text_cached(path: Path, *, strip_dev_mock: bool = False) -> s
def _resolve_webapp_js_asset_name() -> str: def _resolve_webapp_js_asset_name() -> str:
cached = _get_cached_asset_name("js") return _resolve_hashed_js_asset_name(
kind="js",
base_name="subscription_webapp",
)
def _resolve_webapp_admin_js_asset_name() -> str:
return _resolve_hashed_js_asset_name(
kind="admin-js",
base_name="subscription_webapp_admin",
)
def _resolve_hashed_js_asset_name(*, kind: str, base_name: str) -> str:
cached = _get_cached_asset_name(kind)
if cached: if cached:
return cached return cached
minified_assets = [] minified_assets = []
for path in ASSET_DIR.glob("subscription_webapp.min.*.js"): pattern = re.compile(rf"{re.escape(base_name)}\.min\.[0-9a-f]{{8}}\.js")
for path in ASSET_DIR.glob(f"{base_name}.min.*.js"):
if not pattern.fullmatch(path.name):
continue
try: try:
minified_assets.append((path.stat().st_mtime, path.name)) minified_assets.append((path.stat().st_mtime, path.name))
except OSError: except OSError:
continue continue
if minified_assets: if minified_assets:
minified_assets.sort(reverse=True) minified_assets.sort(reverse=True)
return _set_cached_asset_name("js", minified_assets[0][1]) return _set_cached_asset_name(kind, minified_assets[0][1])
return _set_cached_asset_name("js", "subscription_webapp.js") return _set_cached_asset_name(kind, f"{base_name}.js")
def _resolve_webapp_css_asset_name() -> str: def _resolve_webapp_css_asset_name() -> str:
cached = _get_cached_asset_name("css") return _resolve_hashed_css_asset_name(
kind="css",
base_name="subscription_webapp",
)
def _resolve_webapp_admin_css_asset_name() -> str:
return _resolve_hashed_css_asset_name(
kind="admin-css",
base_name="subscription_webapp_admin",
)
def _resolve_hashed_css_asset_name(*, kind: str, base_name: str) -> str:
cached = _get_cached_asset_name(kind)
if cached: if cached:
return cached return cached
hashed_assets = [] hashed_assets = []
for path in ASSET_DIR.glob("subscription_webapp.*.css"): pattern = re.compile(rf"{re.escape(base_name)}\.[0-9a-f]{{8}}\.css")
if not re.fullmatch(r"subscription_webapp\.[0-9a-f]{8}\.css", path.name): for path in ASSET_DIR.glob(f"{base_name}.*.css"):
if not pattern.fullmatch(path.name):
continue continue
try: try:
hashed_assets.append((path.stat().st_mtime, path.name)) hashed_assets.append((path.stat().st_mtime, path.name))
@@ -1162,8 +1423,8 @@ def _resolve_webapp_css_asset_name() -> str:
continue continue
if hashed_assets: if hashed_assets:
hashed_assets.sort(reverse=True) hashed_assets.sort(reverse=True)
return _set_cached_asset_name("css", hashed_assets[0][1]) return _set_cached_asset_name(kind, hashed_assets[0][1])
return _set_cached_asset_name("css", "subscription_webapp.css") return _set_cached_asset_name(kind, f"{base_name}.css")
def _get_cached_asset_name(kind: str) -> Optional[str]: def _get_cached_asset_name(kind: str) -> Optional[str]:
+7
View File
@@ -39,10 +39,17 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
) )
app.router.add_get("/subscription_webapp.{asset_hash:[0-9a-f]{8}}.css", css_asset_route) app.router.add_get("/subscription_webapp.{asset_hash:[0-9a-f]{8}}.css", css_asset_route)
app.router.add_get("/subscription_webapp.css", css_asset_route) app.router.add_get("/subscription_webapp.css", css_asset_route)
app.router.add_get(
"/subscription_webapp_admin.{asset_hash:[0-9a-f]{8}}.css",
admin_css_asset_route,
)
app.router.add_get("/subscription_webapp_admin.css", admin_css_asset_route)
app.router.add_get(r"/webapp-theme-css/{path:.+}", theme_css_asset_route) app.router.add_get(r"/webapp-theme-css/{path:.+}", theme_css_asset_route)
app.router.add_get(r"/webapp-theme-assets/{path:.+}", theme_asset_route) app.router.add_get(r"/webapp-theme-assets/{path:.+}", theme_asset_route)
app.router.add_get("/subscription_webapp.min.{asset_hash}.js", js_asset_route) app.router.add_get("/subscription_webapp.min.{asset_hash}.js", js_asset_route)
app.router.add_get("/subscription_webapp.js", js_asset_route) app.router.add_get("/subscription_webapp.js", js_asset_route)
app.router.add_get("/subscription_webapp_admin.min.{asset_hash}.js", admin_js_asset_route)
app.router.add_get("/subscription_webapp_admin.js", admin_js_asset_route)
app.router.add_post("/api/auth/telegram/nonce", telegram_oauth_nonce_route) app.router.add_post("/api/auth/telegram/nonce", telegram_oauth_nonce_route)
app.router.add_post("/api/auth/token", auth_token_route) app.router.add_post("/api/auth/token", auth_token_route)
app.router.add_post("/api/auth/email/request", email_auth_request_route) app.router.add_post("/api/auth/email/request", email_auth_request_route)
+24
View File
@@ -106,20 +106,44 @@ COPY --from=version-builder /build-commit /build-commit
COPY backend/bot/app/web/templates/subscription_webapp.html /usr/share/nginx/html/index.html COPY backend/bot/app/web/templates/subscription_webapp.html /usr/share/nginx/html/index.html
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.css /usr/share/nginx/html/subscription_webapp.css COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.css /usr/share/nginx/html/subscription_webapp.css
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.*.css /usr/share/nginx/html/ COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.*.css /usr/share/nginx/html/
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.*.css.gz /usr/share/nginx/html/
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp_admin.css /usr/share/nginx/html/subscription_webapp_admin.css
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp_admin.*.css /usr/share/nginx/html/
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp_admin.*.css.gz /usr/share/nginx/html/
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.js /usr/share/nginx/html/subscription_webapp.js COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.js /usr/share/nginx/html/subscription_webapp.js
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.min.*.js /usr/share/nginx/html/ COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.min.*.js /usr/share/nginx/html/
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.min.*.js.gz /usr/share/nginx/html/
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp_admin.js /usr/share/nginx/html/subscription_webapp_admin.js
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp_admin.min.*.js /usr/share/nginx/html/
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.gz /usr/share/nginx/html/
RUN set -eu; \ RUN set -eu; \
find /docker-entrypoint.d -type f -name '*.sh' -exec sed -i 's/\r$//' {} +; \ find /docker-entrypoint.d -type f -name '*.sh' -exec sed -i 's/\r$//' {} +; \
HASHED=$(ls /usr/share/nginx/html/subscription_webapp.min.*.js 2>/dev/null | sort | tail -n1 | xargs -n1 basename || true); \ HASHED=$(ls /usr/share/nginx/html/subscription_webapp.min.*.js 2>/dev/null | sort | tail -n1 | xargs -n1 basename || true); \
JS_NAME="${HASHED:-subscription_webapp.js}"; \ JS_NAME="${HASHED:-subscription_webapp.js}"; \
CSS_NAME="subscription_webapp.css"; \ CSS_NAME="subscription_webapp.css"; \
ADMIN_HASHED=$(ls /usr/share/nginx/html/subscription_webapp_admin.min.*.js 2>/dev/null | sort | tail -n1 | xargs -n1 basename || true); \
ADMIN_CSS_NAME="subscription_webapp_admin.css"; \
for candidate in /usr/share/nginx/html/subscription_webapp.*.css; do \ for candidate in /usr/share/nginx/html/subscription_webapp.*.css; do \
name="$(basename "$candidate")"; \ name="$(basename "$candidate")"; \
case "$name" in \ case "$name" in \
subscription_webapp.[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f].css) CSS_NAME="$name" ;; \ subscription_webapp.[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f].css) CSS_NAME="$name" ;; \
esac; \ esac; \
done; \ done; \
for candidate in /usr/share/nginx/html/subscription_webapp_admin.*.css; do \
name="$(basename "$candidate")"; \
case "$name" in \
subscription_webapp_admin.[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f].css) ADMIN_CSS_NAME="$name" ;; \
esac; \
done; \
if [ -n "$ADMIN_HASHED" ]; then \
cp "/usr/share/nginx/html/${ADMIN_HASHED}" /usr/share/nginx/html/subscription_webapp_admin.js; \
if [ -f "/usr/share/nginx/html/${ADMIN_HASHED}.gz" ]; then cp "/usr/share/nginx/html/${ADMIN_HASHED}.gz" /usr/share/nginx/html/subscription_webapp_admin.js.gz; fi; \
fi; \
if [ "$ADMIN_CSS_NAME" != "subscription_webapp_admin.css" ]; then \
cp "/usr/share/nginx/html/${ADMIN_CSS_NAME}" /usr/share/nginx/html/subscription_webapp_admin.css; \
if [ -f "/usr/share/nginx/html/${ADMIN_CSS_NAME}.gz" ]; then cp "/usr/share/nginx/html/${ADMIN_CSS_NAME}.gz" /usr/share/nginx/html/subscription_webapp_admin.css.gz; fi; \
fi; \
sed -i \ sed -i \
-e '/WEBAPP_I18N_SCRIPT/d' \ -e '/WEBAPP_I18N_SCRIPT/d' \
-e '/WEBAPP_CONFIG_SCRIPT/d' \ -e '/WEBAPP_CONFIG_SCRIPT/d' \
+3 -2
View File
@@ -5,6 +5,7 @@ server {
index index.html; index index.html;
gzip on; gzip on;
gzip_static on;
gzip_comp_level 5; gzip_comp_level 5;
gzip_min_length 1024; gzip_min_length 1024;
gzip_vary on; gzip_vary on;
@@ -58,13 +59,13 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location ~* ^/subscription_webapp\.(min\.)?[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]\.(css|js)$ { location ~* ^/subscription_webapp(_admin)?\.(min\.)?[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]\.(css|js)$ {
expires off; expires off;
add_header Cache-Control "public, max-age=31536000, immutable"; add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404; try_files $uri =404;
} }
location ~* ^/subscription_webapp\.(css|js)$ { location ~* ^/subscription_webapp(_admin)?\.(css|js)$ {
expires off; expires off;
add_header Cache-Control "no-cache"; add_header Cache-Control "no-cache";
try_files $uri =404; try_files $uri =404;
+3 -1
View File
@@ -1,7 +1,9 @@
{ {
"private": true, "private": true,
"scripts": { "scripts": {
"build:webapp:svelte": "vite build --config ./vite.config.mjs", "build:webapp:svelte:main": "vite build --config ./vite.config.mjs",
"build:webapp:svelte:admin": "vite build --config ./vite.config.mjs --mode admin",
"build:webapp:svelte": "npm run build:webapp:svelte:main && npm run build:webapp:svelte:admin",
"build:webapp:css": "npm run build:webapp:svelte", "build:webapp:css": "npm run build:webapp:svelte",
"build:webapp:js": "node ./scripts/build_subscription_webapp_js.mjs", "build:webapp:js": "node ./scripts/build_subscription_webapp_js.mjs",
"build:webapp": "npm run build:webapp:svelte && npm run build:webapp:js", "build:webapp": "npm run build:webapp:svelte && npm run build:webapp:js",
+106 -48
View File
@@ -3,29 +3,36 @@ import { createHash } from "node:crypto";
import { readFile, readdir, unlink, writeFile } from "node:fs/promises"; import { readFile, readdir, unlink, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { brotliCompressSync, constants as zlibConstants, gzipSync } from "node:zlib";
import { transform } from "esbuild"; import { transform } from "esbuild";
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "..", ".."); const repoRoot = path.resolve(__dirname, "..", "..");
const sourcePath = path.join( const JS_MINIFY_TARGET = "es2020";
repoRoot, const templatesDir = path.join(repoRoot, "backend", "bot", "app", "web", "templates");
"backend", const JS_BUILDS = [
"bot", {
"app", sourcePath: path.join(templatesDir, "subscription_webapp.js"),
"web", outputPrefix: "subscription_webapp.min",
"templates", stripDevMock: true,
"subscription_webapp.js" stripFallbackI18nPayload: true,
); },
const sourceCssPath = path.join( {
repoRoot, sourcePath: path.join(templatesDir, "subscription_webapp_admin.js"),
"backend", outputPrefix: "subscription_webapp_admin.min",
"bot", },
"app", ];
"web", const CSS_BUILDS = [
"templates", {
"subscription_webapp.css" sourcePath: path.join(templatesDir, "subscription_webapp.css"),
); outputPrefix: "subscription_webapp",
},
{
sourcePath: path.join(templatesDir, "subscription_webapp_admin.css"),
outputPrefix: "subscription_webapp_admin",
},
];
function normalizeLineEndings(value) { function normalizeLineEndings(value) {
return value.replace(/\r\n/g, "\n"); return value.replace(/\r\n/g, "\n");
@@ -59,60 +66,111 @@ function stripFallbackI18n(source) {
); );
} }
async function removeOldHashedAssets(assetDir, pattern, keepName) { async function removeOldHashedAssets(assetDir, pattern, keepNames) {
const keep = new Set(Array.isArray(keepNames) ? keepNames : [keepNames]);
const entries = await readdir(assetDir, { withFileTypes: true }); const entries = await readdir(assetDir, { withFileTypes: true });
await Promise.all( await Promise.all(
entries entries
.filter((entry) => entry.isFile() && pattern.test(entry.name) && entry.name !== keepName) .filter((entry) => entry.isFile() && pattern.test(entry.name) && !keep.has(entry.name))
.map((entry) => unlink(path.join(assetDir, entry.name))) .map((entry) => unlink(path.join(assetDir, entry.name)))
); );
} }
async function main() { async function writePrecompressedAssets(outputPath, body) {
const buffer = Buffer.isBuffer(body) ? body : Buffer.from(body, "utf8");
const gzipBody = gzipSync(buffer, { level: 9 });
const brotliBody = brotliCompressSync(buffer, {
params: {
[zlibConstants.BROTLI_PARAM_QUALITY]: 11,
},
});
await Promise.all([
writeFile(`${outputPath}.gz`, gzipBody),
writeFile(`${outputPath}.br`, brotliBody),
]);
return {
gzip: gzipBody.length,
brotli: brotliBody.length,
};
}
async function buildJsAsset({
sourcePath,
outputPrefix,
stripDevMock = false,
stripFallbackI18nPayload = false,
}) {
const rawSource = await readFile(sourcePath, "utf8"); const rawSource = await readFile(sourcePath, "utf8");
const withoutMocks = stripMarkedBlock( let strippedSource = normalizeLineEndings(rawSource);
normalizeLineEndings(rawSource), if (stripDevMock) {
"/* WEBAPP_DEV_MOCK_START */", strippedSource = stripMarkedBlock(
"/* WEBAPP_DEV_MOCK_END */" strippedSource,
); "/* WEBAPP_DEV_MOCK_START */",
const strippedSource = stripFallbackI18n(withoutMocks); "/* WEBAPP_DEV_MOCK_END */"
);
}
if (stripFallbackI18nPayload) {
strippedSource = stripFallbackI18n(strippedSource);
}
const result = await transform(strippedSource, { const result = await transform(strippedSource, {
charset: "utf8", charset: "utf8",
legalComments: "none", legalComments: "none",
loader: "js", loader: "js",
minify: true, minify: true,
target: "es2018", target: JS_MINIFY_TARGET,
}); });
const code = `${result.code.replace(/[ \t]+$/gm, "").trimEnd()}\n`; const code = `${result.code.replace(/[ \t]+$/gm, "").trimEnd()}\n`;
const hash = createHash("sha256").update(code, "utf8").digest("hex").slice(0, 8); const hash = createHash("sha256").update(code, "utf8").digest("hex").slice(0, 8);
const outputPath = path.join(path.dirname(sourcePath), `subscription_webapp.min.${hash}.js`); const outputPath = path.join(path.dirname(sourcePath), `${outputPrefix}.${hash}.js`);
const outputName = path.basename(outputPath);
const escapedPrefix = outputPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
await removeOldHashedAssets( await removeOldHashedAssets(
path.dirname(sourcePath), path.dirname(sourcePath),
/^subscription_webapp\.min\.[0-9a-f]{8}\.js$/, new RegExp(`^${escapedPrefix}\\.[0-9a-f]{8}\\.js(?:\\.(?:br|gz))?$`),
path.basename(outputPath) [outputName, `${outputName}.br`, `${outputName}.gz`]
); );
await writeFile(outputPath, code, "utf8"); await writeFile(outputPath, code, "utf8");
const compressedJs = await writePrecompressedAssets(outputPath, code);
console.log( console.log(
`Wrote ${path.relative(repoRoot, outputPath)} (${Buffer.byteLength(code, "utf8")} bytes)` `Wrote ${path.relative(repoRoot, outputPath)} (${Buffer.byteLength(code, "utf8")} bytes, gzip ${compressedJs.gzip}, br ${compressedJs.brotli})`
);
const css = await readFile(sourceCssPath, "utf8");
const cssHash = createHash("sha256").update(css, "utf8").digest("hex").slice(0, 8);
const cssOutputPath = path.join(
path.dirname(sourceCssPath),
`subscription_webapp.${cssHash}.css`
);
await removeOldHashedAssets(
path.dirname(sourceCssPath),
/^subscription_webapp\.[0-9a-f]{8}\.css$/,
path.basename(cssOutputPath)
);
await writeFile(cssOutputPath, css, "utf8");
console.log(
`Wrote ${path.relative(repoRoot, cssOutputPath)} (${Buffer.byteLength(css, "utf8")} bytes)`
); );
} }
async function buildCssAsset({ sourcePath, outputPrefix }) {
const rawCss = await readFile(sourcePath, "utf8");
const cssResult = await transform(rawCss, {
legalComments: "none",
loader: "css",
minify: true,
});
const css = `${cssResult.code.replace(/[ \t]+$/gm, "").trimEnd()}\n`;
const cssHash = createHash("sha256").update(css, "utf8").digest("hex").slice(0, 8);
const cssOutputPath = path.join(path.dirname(sourcePath), `${outputPrefix}.${cssHash}.css`);
const cssOutputName = path.basename(cssOutputPath);
const escapedPrefix = outputPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
await removeOldHashedAssets(
path.dirname(sourcePath),
new RegExp(`^${escapedPrefix}\\.[0-9a-f]{8}\\.css(?:\\.(?:br|gz))?$`),
[cssOutputName, `${cssOutputName}.br`, `${cssOutputName}.gz`]
);
await writeFile(cssOutputPath, css, "utf8");
const compressedCss = await writePrecompressedAssets(cssOutputPath, css);
console.log(
`Wrote ${path.relative(repoRoot, cssOutputPath)} (${Buffer.byteLength(css, "utf8")} bytes, gzip ${compressedCss.gzip}, br ${compressedCss.brotli})`
);
}
async function main() {
for (const build of JS_BUILDS) {
await buildJsAsset(build);
}
for (const build of CSS_BUILDS) {
await buildCssAsset(build);
}
}
await main(); await main();
+125 -31
View File
@@ -9,7 +9,6 @@
import BrandMark from "$lib/webapp/BrandMark.svelte"; import BrandMark from "$lib/webapp/BrandMark.svelte";
import PreviewBoard from "./PreviewBoard.svelte"; import PreviewBoard from "./PreviewBoard.svelte";
import AdminPanel from "./admin/AdminPanel.svelte";
import WebAppShell from "./webapp/WebAppShell.svelte"; import WebAppShell from "./webapp/WebAppShell.svelte";
import AuthScreen from "./webapp/auth/AuthScreen.svelte"; import AuthScreen from "./webapp/auth/AuthScreen.svelte";
import PaymentDialogs from "./webapp/PaymentDialogs.svelte"; import PaymentDialogs from "./webapp/PaymentDialogs.svelte";
@@ -120,6 +119,9 @@
let scrollLockApplied = false; let scrollLockApplied = false;
let adminI18nLoaded = false; let adminI18nLoaded = false;
let adminI18nPromise = null; let adminI18nPromise = null;
let AdminPanelComponent = null;
let adminBundlePromise = null;
let adminBundleError = "";
let tg = null; let tg = null;
const telegramSdk = createTelegramSdk({ const telegramSdk = createTelegramSdk({
scriptUrl: TELEGRAM_WEBAPP_SCRIPT_URL, scriptUrl: TELEGRAM_WEBAPP_SCRIPT_URL,
@@ -485,12 +487,16 @@
if (mode === "app") { if (mode === "app") {
if (section === "admin" && isAdmin) { if (section === "admin" && isAdmin) {
const pathAtStart = window.location.pathname; const pathAtStart = window.location.pathname;
void ensureI18nScope("admin").finally(() => { void Promise.all([ensureI18nScope("admin"), ensureAdminBundle()])
if (sectionFromPath(window.location.pathname) !== "admin") return; .then(() => {
if (window.location.pathname !== pathAtStart) return; if (sectionFromPath(window.location.pathname) !== "admin") return;
activeTab = "settings"; if (window.location.pathname !== pathAtStart) return;
screen = "admin"; activeTab = "settings";
}); screen = "admin";
})
.catch(() => {
showToast(t("wa_unavailable"));
});
return; return;
} }
const nextSection = const nextSection =
@@ -608,6 +614,71 @@
return adminI18nPromise; return adminI18nPromise;
} }
function resolveWebappAssetPath(configValue, fallbackName) {
const raw = String(configValue || "").trim() || fallbackName;
if (/^(?:https?:)?\/\//i.test(raw) || raw.startsWith("data:")) return fallbackName;
if (window.location.protocol === "file:" && raw.startsWith("/")) return raw.slice(1);
return raw.startsWith("/") ? raw : `/${raw}`;
}
function appendStylesheetOnce(id, href) {
if (!href || document.getElementById(id)) return Promise.resolve();
return new Promise((resolve, reject) => {
const link = document.createElement("link");
link.id = id;
link.rel = "stylesheet";
link.href = href;
link.onload = () => resolve();
link.onerror = () => reject(new Error(`stylesheet_load_failed:${href}`));
document.head.appendChild(link);
});
}
function appendScriptOnce(id, src) {
if (!src || document.getElementById(id)) return Promise.resolve();
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.id = id;
script.src = src;
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`script_load_failed:${src}`));
document.head.appendChild(script);
});
}
async function ensureAdminBundle() {
if (AdminPanelComponent) return true;
if (adminBundlePromise) return adminBundlePromise;
const existing = window.SubscriptionWebAppAdminPanel;
if (existing) {
AdminPanelComponent = existing.default || existing;
return true;
}
adminBundleError = "";
adminBundlePromise = (async () => {
const cssHref = resolveWebappAssetPath(CFG.adminCssAsset, "subscription_webapp_admin.css");
const jsSrc = resolveWebappAssetPath(CFG.adminJsAsset, "subscription_webapp_admin.js");
await appendStylesheetOnce("subscription-webapp-admin-css", cssHref);
await appendScriptOnce("subscription-webapp-admin-js", jsSrc);
const loaded = window.SubscriptionWebAppAdminPanel;
if (!loaded) throw new Error("admin_bundle_missing_component");
AdminPanelComponent = loaded.default || loaded;
return true;
})()
.catch((error) => {
adminBundleError = error?.message || "admin_bundle_load_failed";
throw error;
})
.finally(() => {
adminBundlePromise = null;
});
return adminBundlePromise;
}
async function boot() { async function boot() {
await runWebappBoot({ await runWebappBoot({
MOCK, MOCK,
@@ -702,7 +773,15 @@
const initialAdminSection = const initialAdminSection =
section === "admin" ? adminSectionFromPath(window.location.pathname) : null; section === "admin" ? adminSectionFromPath(window.location.pathname) : null;
if (section === "admin" && payload.user?.is_admin) { if (section === "admin" && payload.user?.is_admin) {
await ensureI18nScope("admin"); try {
await ensureI18nScope("admin");
await ensureAdminBundle();
} catch (_error) {
void _error;
section = "settings";
activeTab = "settings";
showToast(t("wa_unavailable"));
}
} }
const initialSupportTicketId = const initialSupportTicketId =
section === "support" ? supportTicketIdFromPath(window.location.pathname) : null; section === "support" ? supportTicketIdFromPath(window.location.pathname) : null;
@@ -1000,7 +1079,14 @@
if (!isAdmin) return; if (!isAdmin) return;
clearLanguageClickGuard(); clearLanguageClickGuard();
billingStore.closePaymentModal(); billingStore.closePaymentModal();
await ensureI18nScope("admin"); try {
await ensureI18nScope("admin");
await ensureAdminBundle();
} catch (_error) {
void _error;
showToast(t("wa_unavailable"));
return;
}
activeTab = "settings"; activeTab = "settings";
screen = "admin"; screen = "admin";
syncSectionPath("admin", false, adminSectionFromPath(window.location.pathname)); syncSectionPath("admin", false, adminSectionFromPath(window.location.pathname));
@@ -1135,28 +1221,36 @@
setPasswordLoginMode={(enabled) => setPasswordLoginMode(enabled)} setPasswordLoginMode={(enabled) => setPasswordLoginMode(enabled)}
/> />
{:else if screen === "admin" && isAdmin} {:else if screen === "admin" && isAdmin}
<AdminPanel {#if AdminPanelComponent}
{api} <svelte:component
onClose={closeAdminPanel} this={AdminPanelComponent}
onToast={(text) => showToast(text)} {api}
initialSection={adminSectionFromPath(window.location.pathname)} onClose={closeAdminPanel}
initialUserId={adminUserIdFromPath(window.location.pathname)} onToast={(text) => showToast(text)}
onSectionChange={handleAdminSectionChange} initialSection={adminSectionFromPath(window.location.pathname)}
onSettingsSaved={handleAdminPersistedSaved} initialUserId={adminUserIdFromPath(window.location.pathname)}
onTariffsSaved={handleAdminPersistedSaved} onSectionChange={handleAdminSectionChange}
onThemesSaved={handleAdminPersistedSaved} onSettingsSaved={handleAdminPersistedSaved}
{brandTitle} onTariffsSaved={handleAdminPersistedSaved}
{brand} onThemesSaved={handleAdminPersistedSaved}
appFaviconUrl={CFG.faviconUrl} {brandTitle}
appFaviconUseCustom={CFG.faviconUseCustom} {brand}
appVersion={CFG.appVersion} appFaviconUrl={CFG.faviconUrl}
appRepositoryUrl={CFG.appRepositoryUrl} appFaviconUseCustom={CFG.faviconUseCustom}
{currentLang} appVersion={CFG.appVersion}
{languageOptions} appRepositoryUrl={CFG.appRepositoryUrl}
{languageBusy} {currentLang}
onLanguageChange={accountStore.updateAccountLanguage} {languageOptions}
{t} {languageBusy}
/> onLanguageChange={accountStore.updateAccountLanguage}
{t}
/>
{:else}
<div class="loader">
<BrandMark {brand} size="md" />
<div>{adminBundleError ? t("wa_unavailable") : t("wa_loading")}</div>
</div>
{/if}
{:else} {:else}
<WebAppShell <WebAppShell
{screen} {screen}
+9
View File
@@ -0,0 +1,9 @@
import AdminPanel from "./admin/AdminPanel.svelte";
import "./styles-admin.css";
window.SubscriptionWebAppAdminPanel = AdminPanel;
window.dispatchEvent(
new CustomEvent("subscription-webapp-admin-ready", {
detail: { AdminPanel },
})
);
+2
View File
@@ -33,6 +33,8 @@ export const DEV_MOCK = {
faviconUrl: "", faviconUrl: "",
faviconUseCustom: false, faviconUseCustom: false,
apiBase: "/api", apiBase: "/api",
adminJsAsset: "subscription_webapp_admin.js",
adminCssAsset: "subscription_webapp_admin.css",
supportUrl: "https://t.me/support", supportUrl: "https://t.me/support",
privacyPolicyUrl: "https://example.com/privacy", privacyPolicyUrl: "https://example.com/privacy",
userAgreementUrl: "https://example.com/agreement", userAgreementUrl: "https://example.com/agreement",
+3
View File
@@ -0,0 +1,3 @@
@import "./styles/admin.css";
@import "./styles/admin-controls.css";
@import "./styles/admin-dialogs.css";
-3
View File
@@ -4,6 +4,3 @@
@import "./styles/dialogs.css"; @import "./styles/dialogs.css";
@import "./styles/components.css"; @import "./styles/components.css";
@import "./styles/webapp.css"; @import "./styles/webapp.css";
@import "./styles/admin.css";
@import "./styles/admin-controls.css";
@import "./styles/admin-dialogs.css";
+36 -31
View File
@@ -8,39 +8,44 @@ import { defineConfig } from "vite";
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const templateDir = path.resolve(__dirname, "../backend/bot/app/web/templates"); const templateDir = path.resolve(__dirname, "../backend/bot/app/web/templates");
export default defineConfig({ export default defineConfig(({ mode }) => {
resolve: { const isAdminBuild = mode === "admin";
alias: { const outputBase = isAdminBuild ? "subscription_webapp_admin" : "subscription_webapp";
$lib: path.resolve(__dirname, "src/lib"),
$components: path.resolve(__dirname, "src/lib/components"), return {
}, resolve: {
}, alias: {
plugins: [tailwindcss(), svelte()], $lib: path.resolve(__dirname, "src/lib"),
build: { $components: path.resolve(__dirname, "src/lib/components"),
outDir: templateDir,
emptyOutDir: false,
minify: false,
sourcemap: false,
cssCodeSplit: false,
lib: {
entry: path.resolve(__dirname, "src/main.js"),
name: "SubscriptionWebApp",
formats: ["iife"],
fileName: () => "subscription_webapp.js",
cssFileName: "subscription_webapp",
},
rolldownOptions: {
checks: {
pluginTimings: false,
}, },
output: { },
assetFileNames: (assetInfo) => { plugins: [tailwindcss(), svelte()],
if (assetInfo.name && assetInfo.name.endsWith(".css")) { build: {
return "subscription_webapp.css"; outDir: templateDir,
} emptyOutDir: false,
return "subscription_webapp.[name][extname]"; minify: false,
sourcemap: false,
cssCodeSplit: false,
lib: {
entry: path.resolve(__dirname, isAdminBuild ? "src/adminEntry.js" : "src/main.js"),
name: isAdminBuild ? "SubscriptionWebAppAdmin" : "SubscriptionWebApp",
formats: ["iife"],
fileName: () => `${outputBase}.js`,
cssFileName: outputBase,
},
rolldownOptions: {
checks: {
pluginTimings: false,
},
output: {
assetFileNames: (assetInfo) => {
if (assetInfo.name && assetInfo.name.endsWith(".css")) {
return `${outputBase}.css`;
}
return `${outputBase}.[name][extname]`;
},
}, },
}, },
}, },
}, };
}); });
+191
View File
@@ -1,4 +1,5 @@
import asyncio import asyncio
import gzip
import io import io
import json import json
import os import os
@@ -447,6 +448,38 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
"subscription_webapp.min.22222222.js", "subscription_webapp.min.22222222.js",
) )
def test_resolve_webapp_admin_asset_names_prefer_latest_minified_builds(self):
with tempfile.TemporaryDirectory() as tmpdir:
asset_dir = Path(tmpdir)
(asset_dir / "subscription_webapp_admin.js").write_text(
"console.log('admin fallback');", encoding="utf-8"
)
(asset_dir / "subscription_webapp_admin.css").write_text(
".admin{color:red}", encoding="utf-8"
)
old_js = asset_dir / "subscription_webapp_admin.min.11111111.js"
new_js = asset_dir / "subscription_webapp_admin.min.22222222.js"
old_css = asset_dir / "subscription_webapp_admin.11111111.css"
new_css = asset_dir / "subscription_webapp_admin.22222222.css"
old_js.write_text("console.log('old');", encoding="utf-8")
new_js.write_text("console.log('new');", encoding="utf-8")
old_css.write_text(".old{}", encoding="utf-8")
new_css.write_text(".new{}", encoding="utf-8")
os.utime(old_js, (1, 1))
os.utime(old_css, (1, 1))
os.utime(new_js, (2, 2))
os.utime(new_css, (2, 2))
with patch.object(webapp_assets, "ASSET_DIR", asset_dir):
self.assertEqual(
subscription_webapp._resolve_webapp_admin_js_asset_name(),
"subscription_webapp_admin.min.22222222.js",
)
self.assertEqual(
subscription_webapp._resolve_webapp_admin_css_asset_name(),
"subscription_webapp_admin.22222222.css",
)
async def test_js_asset_route_sets_immutable_cache_control_for_minified_asset(self): async def test_js_asset_route_sets_immutable_cache_control_for_minified_asset(self):
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
asset_dir = Path(tmpdir) asset_dir = Path(tmpdir)
@@ -466,6 +499,71 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
) )
self.assertEqual(response.text, "console.log('minified');") self.assertEqual(response.text, "console.log('minified');")
async def test_admin_js_asset_route_serves_admin_bundle(self):
with tempfile.TemporaryDirectory() as tmpdir:
asset_dir = Path(tmpdir)
minified_asset = asset_dir / "subscription_webapp_admin.min.abcdef12.js"
minified_asset.write_text("console.log('admin');", encoding="utf-8")
request = SimpleNamespace(
app={"settings": SimpleNamespace(WEBAPP_ENABLED=True)},
match_info={"asset_hash": "abcdef12"},
)
with patch.object(webapp_assets, "ASSET_DIR", asset_dir):
response = await subscription_webapp.admin_js_asset_route(request)
self.assertEqual(
response.headers["Cache-Control"], "public, max-age=31536000, immutable"
)
self.assertEqual(response.text, "console.log('admin');")
async def test_js_asset_route_prefers_precompressed_brotli_asset(self):
with tempfile.TemporaryDirectory() as tmpdir:
asset_dir = Path(tmpdir)
minified_asset = asset_dir / "subscription_webapp.min.abcdef12.js"
minified_asset.write_text("console.log('minified');", encoding="utf-8")
(asset_dir / "subscription_webapp.min.abcdef12.js.br").write_bytes(b"br-body")
request = SimpleNamespace(
app={"settings": SimpleNamespace(WEBAPP_ENABLED=True)},
match_info={"asset_hash": "abcdef12"},
headers={"Accept-Encoding": "gzip, br"},
)
with patch.object(webapp_assets, "ASSET_DIR", asset_dir):
response = await subscription_webapp.js_asset_route(request)
self.assertEqual(response.body, b"br-body")
self.assertEqual(response.headers["Content-Encoding"], "br")
self.assertEqual(response.headers["Vary"], "Accept-Encoding")
self.assertEqual(
response.headers["Cache-Control"], "public, max-age=31536000, immutable"
)
async def test_css_asset_route_falls_back_to_precompressed_gzip_asset(self):
with tempfile.TemporaryDirectory() as tmpdir:
asset_dir = Path(tmpdir)
css_asset = asset_dir / "subscription_webapp.abcdef12.css"
css_asset.write_text(".app{color:red}", encoding="utf-8")
(asset_dir / "subscription_webapp.abcdef12.css.gz").write_bytes(b"gz-body")
request = SimpleNamespace(
app={"settings": SimpleNamespace(WEBAPP_ENABLED=True)},
match_info={"asset_hash": "abcdef12"},
headers={"Accept-Encoding": "gzip"},
)
with patch.object(webapp_assets, "ASSET_DIR", asset_dir):
response = await subscription_webapp.css_asset_route(request)
self.assertEqual(response.body, b"gz-body")
self.assertEqual(response.headers["Content-Encoding"], "gzip")
self.assertEqual(response.headers["Vary"], "Accept-Encoding")
self.assertEqual(
response.headers["Cache-Control"], "public, max-age=31536000, immutable"
)
async def test_theme_css_asset_route_serves_file_from_configured_directory(self): async def test_theme_css_asset_route_serves_file_from_configured_directory(self):
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
themes_dir = Path(tmpdir) themes_dir = Path(tmpdir)
@@ -487,8 +585,68 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(response.content_type, "text/css") self.assertEqual(response.content_type, "text/css")
self.assertEqual(response.headers["Cache-Control"], "no-cache") self.assertEqual(response.headers["Cache-Control"], "no-cache")
self.assertIn("ETag", response.headers)
self.assertIn("--bg: red", response.text) self.assertIn("--bg: red", response.text)
async def test_theme_css_asset_route_returns_not_modified_for_matching_etag(self):
with tempfile.TemporaryDirectory() as tmpdir:
themes_dir = Path(tmpdir)
(themes_dir / "custom").mkdir()
(themes_dir / "custom" / "theme.css").write_text(
".theme-key-custom { --bg: red; }", encoding="utf-8"
)
request = SimpleNamespace(
app={
"settings": SimpleNamespace(
WEBAPP_ENABLED=True,
WEBAPP_THEMES_DIR=str(themes_dir),
)
},
match_info={"path": "custom/theme.css"},
headers={},
)
response = await subscription_webapp.theme_css_asset_route(request)
etag = response.headers["ETag"]
cached_request = SimpleNamespace(
app=request.app,
match_info=request.match_info,
headers={"If-None-Match": etag},
)
cached_response = await subscription_webapp.theme_css_asset_route(cached_request)
self.assertEqual(cached_response.status, 304)
self.assertEqual(cached_response.headers["ETag"], etag)
self.assertEqual(cached_response.headers["Cache-Control"], "no-cache")
async def test_theme_css_asset_route_serves_cached_gzip_when_accepted(self):
with tempfile.TemporaryDirectory() as tmpdir:
themes_dir = Path(tmpdir)
(themes_dir / "custom").mkdir()
(themes_dir / "custom" / "theme.css").write_text(
".theme-key-custom { --bg: red; }\n", encoding="utf-8"
)
request = SimpleNamespace(
app={
"settings": SimpleNamespace(
WEBAPP_ENABLED=True,
WEBAPP_THEMES_DIR=str(themes_dir),
)
},
match_info={"path": "custom/theme.css"},
headers={"Accept-Encoding": "gzip"},
)
response = await subscription_webapp.theme_css_asset_route(request)
self.assertEqual(response.headers["Content-Encoding"], "gzip")
self.assertEqual(response.headers["Vary"], "Accept-Encoding")
self.assertEqual(
gzip.decompress(response.body).decode("utf-8"),
".theme-key-custom { --bg: red; }\n",
)
async def test_theme_css_asset_route_serves_default_theme_asset_from_theme_folder(self): async def test_theme_css_asset_route_serves_default_theme_asset_from_theme_folder(self):
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
request = SimpleNamespace( request = SimpleNamespace(
@@ -542,8 +700,41 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(response.content_type, "image/png") self.assertEqual(response.content_type, "image/png")
self.assertEqual(response.headers["Cache-Control"], "public, max-age=3600") self.assertEqual(response.headers["Cache-Control"], "public, max-age=3600")
self.assertIn("ETag", response.headers)
self.assertEqual(response.body, b"png-bytes") self.assertEqual(response.body, b"png-bytes")
async def test_theme_asset_route_returns_not_modified_for_matching_etag(self):
with tempfile.TemporaryDirectory() as tmpdir:
themes_dir = Path(tmpdir)
(themes_dir / "custom" / "icons").mkdir(parents=True)
(themes_dir / "custom" / "icons" / "save.png").write_bytes(b"png-bytes")
request = SimpleNamespace(
app={
"settings": SimpleNamespace(
WEBAPP_ENABLED=True,
WEBAPP_THEMES_DIR=str(themes_dir),
)
},
match_info={"path": "custom/icons/save.png"},
query={},
headers={},
)
response = await subscription_webapp.theme_asset_route(request)
etag = response.headers["ETag"]
cached_request = SimpleNamespace(
app=request.app,
match_info=request.match_info,
query={},
headers={"If-None-Match": etag},
)
cached_response = await subscription_webapp.theme_asset_route(cached_request)
self.assertEqual(cached_response.status, 304)
self.assertEqual(cached_response.headers["ETag"], etag)
self.assertEqual(cached_response.headers["Cache-Control"], "public, max-age=3600")
async def test_theme_asset_route_uses_immutable_cache_for_versioned_assets(self): async def test_theme_asset_route_uses_immutable_cache_for_versioned_assets(self):
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
themes_dir = Path(tmpdir) themes_dir = Path(tmpdir)
+3
View File
@@ -63,9 +63,12 @@ class WebAppRouteContractTests(unittest.TestCase):
("GET", "/webapp-uploaded-logo/{filename}"): "webapp_uploaded_logo_route", ("GET", "/webapp-uploaded-logo/{filename}"): "webapp_uploaded_logo_route",
("GET", "/webapp-emoji/{codepoints}/512.{ext}"): "webapp_animated_emoji_route", ("GET", "/webapp-emoji/{codepoints}/512.{ext}"): "webapp_animated_emoji_route",
("GET", "/subscription_webapp.css"): "css_asset_route", ("GET", "/subscription_webapp.css"): "css_asset_route",
("GET", "/subscription_webapp_admin.css"): "admin_css_asset_route",
("GET", "/webapp-theme-css/{path}"): "theme_css_asset_route", ("GET", "/webapp-theme-css/{path}"): "theme_css_asset_route",
("GET", "/subscription_webapp.min.{asset_hash}.js"): "js_asset_route", ("GET", "/subscription_webapp.min.{asset_hash}.js"): "js_asset_route",
("GET", "/subscription_webapp.js"): "js_asset_route", ("GET", "/subscription_webapp.js"): "js_asset_route",
("GET", "/subscription_webapp_admin.min.{asset_hash}.js"): "admin_js_asset_route",
("GET", "/subscription_webapp_admin.js"): "admin_js_asset_route",
("POST", "/api/auth/telegram/nonce"): "telegram_oauth_nonce_route", ("POST", "/api/auth/telegram/nonce"): "telegram_oauth_nonce_route",
("POST", "/api/auth/token"): "auth_token_route", ("POST", "/api/auth/token"): "auth_token_route",
("POST", "/api/auth/email/request"): "email_auth_request_route", ("POST", "/api/auth/email/request"): "email_auth_request_route",