From a86f5d75e1085c700cd5e9ba331e3eb4d0888858 Mon Sep 17 00:00:00 2001
From: 3252a8 <3252a8@proton.me>
Date: Sun, 7 Jun 2026 23:47:36 +0300
Subject: [PATCH] build: reduce docker image layer churn
---
.dockerignore | 11 ++
.gitignore | 1 +
deploy/docker/Dockerfile | 51 +-------
frontend/scripts/prepare_nginx_assets.mjs | 150 ++++++++++++++++++++++
4 files changed, 165 insertions(+), 48 deletions(-)
create mode 100644 frontend/scripts/prepare_nginx_assets.mjs
diff --git a/.dockerignore b/.dockerignore
index 434ec09..e76f0cf 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -17,6 +17,7 @@ docs-site/node_modules/
docs-site/.astro/
docs-site/dist/
docs-site/src/content/docs/
+frontend-nginx-dist/
deploy/compose/docker-compose-dev.yml
data/*
!data/tariffs.example.json
@@ -51,6 +52,11 @@ 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
+bot/app/web/templates/subscription_webapp_docs_demo.css
+bot/app/web/templates/subscription_webapp_docs_demo.js
+bot/app/web/templates/subscription_webapp_docs_demo.*.css
+bot/app/web/templates/subscription_webapp_docs_demo.*.css.br
+bot/app/web/templates/subscription_webapp_docs_demo.*.css.gz
backend/bot/app/web/templates/subscription_webapp.css
backend/bot/app/web/templates/subscription_webapp.js
backend/bot/app/web/templates/subscription_webapp.min.*.js
@@ -67,6 +73,11 @@ 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
+backend/bot/app/web/templates/subscription_webapp_docs_demo.css
+backend/bot/app/web/templates/subscription_webapp_docs_demo.js
+backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css
+backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css.br
+backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css.gz
# Byte-compiled / optimized / DLL files
**/__pycache__/
diff --git a/.gitignore b/.gitignore
index ade87d4..8ff0f22 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,6 +17,7 @@ docs-site/.astro/
docs-site/dist/
docs-site/public/demo/runtime/
docs-site/src/content/docs/
+frontend-nginx-dist/
# WebApp build artifacts (regenerated by `npm run build:webapp` / Docker build)
bot/app/web/templates/subscription_webapp.css
diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile
index 21d5085..66791a4 100644
--- a/deploy/docker/Dockerfile
+++ b/deploy/docker/Dockerfile
@@ -119,6 +119,8 @@ 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
@@ -131,57 +133,10 @@ LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minis
COPY deploy/docker/frontend/nginx.conf /etc/nginx/conf.d/default.conf
COPY deploy/docker/frontend/00-startup-banner.sh /docker-entrypoint.d/00-startup-banner.sh
COPY deploy/docker/frontend/robots.txt /usr/share/nginx/html/robots.txt
-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/
-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.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; \
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); \
- JS_NAME="${HASHED:-subscription_webapp.js}"; \
- 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 \
- name="$(basename "$candidate")"; \
- 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" ;; \
- esac; \
- 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 \
- -e '/WEBAPP_I18N_SCRIPT/d' \
- -e '/WEBAPP_CONFIG_SCRIPT/d' \
- -e "s|href=\"/subscription_webapp.css\"|href=\"/${CSS_NAME}\"|" \
- -e "/WEBAPP_JS_SCRIPT/c\\ " \
- -e '/WEBAPP_DEV_MOCK_START/d' \
- -e '/WEBAPP_DEV_MOCK_END/d' \
- -e '/subscription_webapp.js" defer/d' \
- /usr/share/nginx/html/index.html; \
chmod +x /docker-entrypoint.d/00-startup-banner.sh
+COPY --from=frontend-builder /app/frontend-nginx-dist/ /usr/share/nginx/html/
COPY --from=version-builder /build-tag /build-tag
COPY --from=version-builder /build-commit /build-commit
diff --git a/frontend/scripts/prepare_nginx_assets.mjs b/frontend/scripts/prepare_nginx_assets.mjs
new file mode 100644
index 0000000..01714b3
--- /dev/null
+++ b/frontend/scripts/prepare_nginx_assets.mjs
@@ -0,0 +1,150 @@
+#!/usr/bin/env node
+import {
+ copyFile,
+ lstat,
+ mkdir,
+ readdir,
+ readFile,
+ rm,
+ symlink,
+ writeFile,
+} from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const repoRoot = path.resolve(__dirname, "..", "..");
+const templatesDir = path.join(repoRoot, "backend", "bot", "app", "web", "templates");
+const DEFAULT_OUT_DIR = path.join(repoRoot, "frontend-nginx-dist");
+const outDir = resolveOutDir();
+
+function resolveOutDir() {
+ const argIndex = process.argv.indexOf("--out");
+ if (argIndex === -1) {
+ return DEFAULT_OUT_DIR;
+ }
+ const rawValue = process.argv[argIndex + 1];
+ if (!rawValue) {
+ throw new Error("--out requires a directory path");
+ }
+ return path.resolve(process.cwd(), rawValue);
+}
+
+async function pathExists(filePath) {
+ try {
+ await lstat(filePath);
+ return true;
+ } catch (error) {
+ if (error?.code === "ENOENT") return false;
+ throw error;
+ }
+}
+
+async function copyIfExists(sourceName, targetName = sourceName) {
+ const sourcePath = path.join(templatesDir, sourceName);
+ if (!(await pathExists(sourcePath))) return false;
+ await copyFile(sourcePath, path.join(outDir, targetName));
+ return true;
+}
+
+async function linkOrCopy(targetName, aliasName) {
+ const aliasPath = path.join(outDir, aliasName);
+ try {
+ await symlink(targetName, aliasPath);
+ } catch (error) {
+ if (!["EPERM", "EINVAL", "ENOSYS"].includes(error?.code)) {
+ throw error;
+ }
+ await copyFile(path.join(outDir, targetName), aliasPath);
+ }
+}
+
+function latestMatching(entries, pattern, fallbackName) {
+ const matches = entries.filter((name) => pattern.test(name)).sort();
+ return matches.at(-1) || fallbackName;
+}
+
+async function copyRuntimeAsset({ hashedName, stableName }) {
+ const copied = await copyIfExists(hashedName);
+ if (copied && hashedName !== stableName) {
+ await linkOrCopy(hashedName, stableName);
+ } else if (!copied) {
+ await copyIfExists(stableName);
+ }
+
+ const gzipName = `${hashedName}.gz`;
+ const gzipCopied = await copyIfExists(gzipName);
+ if (gzipCopied && hashedName !== stableName) {
+ await linkOrCopy(gzipName, `${stableName}.gz`);
+ }
+}
+
+function prepareIndexHtml(rawHtml, { cssName, jsName }) {
+ const html = rawHtml
+ .replace(/\r\n/g, "\n")
+ .replace('href="/subscription_webapp.css"', `href="/${cssName}"`);
+ const lines = html.split("\n");
+ const output = lines
+ .map((line) =>
+ line.includes("WEBAPP_JS_SCRIPT")
+ ? ` `
+ : line
+ )
+ .filter(
+ (line) =>
+ !line.includes("WEBAPP_I18N_SCRIPT") &&
+ !line.includes("WEBAPP_CONFIG_SCRIPT") &&
+ !line.includes("WEBAPP_DEV_MOCK_START") &&
+ !line.includes("WEBAPP_DEV_MOCK_END") &&
+ !line.includes('subscription_webapp.js" defer')
+ )
+ .join("\n");
+ return output.endsWith("\n") ? output : `${output}\n`;
+}
+
+async function main() {
+ const entries = await readdir(templatesDir);
+ const mainJsName = latestMatching(
+ entries,
+ /^subscription_webapp\.min\.[0-9a-f]{8}\.js$/,
+ "subscription_webapp.js"
+ );
+ const mainCssName = latestMatching(
+ entries,
+ /^subscription_webapp\.[0-9a-f]{8}\.css$/,
+ "subscription_webapp.css"
+ );
+ const adminJsName = latestMatching(
+ entries,
+ /^subscription_webapp_admin\.min\.[0-9a-f]{8}\.js$/,
+ "subscription_webapp_admin.js"
+ );
+ const adminCssName = latestMatching(
+ entries,
+ /^subscription_webapp_admin\.[0-9a-f]{8}\.css$/,
+ "subscription_webapp_admin.css"
+ );
+
+ await rm(outDir, { recursive: true, force: true });
+ await mkdir(outDir, { recursive: true });
+
+ await Promise.all([
+ copyRuntimeAsset({ hashedName: mainJsName, stableName: "subscription_webapp.js" }),
+ copyRuntimeAsset({ hashedName: mainCssName, stableName: "subscription_webapp.css" }),
+ copyRuntimeAsset({ hashedName: adminJsName, stableName: "subscription_webapp_admin.js" }),
+ copyRuntimeAsset({ hashedName: adminCssName, stableName: "subscription_webapp_admin.css" }),
+ ]);
+
+ const indexTemplate = await readFile(path.join(templatesDir, "subscription_webapp.html"), "utf8");
+ await writeFile(
+ path.join(outDir, "index.html"),
+ prepareIndexHtml(indexTemplate, { cssName: mainCssName, jsName: mainJsName }),
+ "utf8"
+ );
+
+ console.log(
+ `Prepared nginx assets in ${path.relative(repoRoot, outDir)}: ${mainJsName}, ${mainCssName}, ${adminJsName}, ${adminCssName}`
+ );
+}
+
+await main();