perf: optimize webapp asset delivery, split admin webapp bundle
This commit is contained in:
@@ -3,29 +3,36 @@ import { createHash } from "node:crypto";
|
||||
import { readFile, readdir, unlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { brotliCompressSync, constants as zlibConstants, gzipSync } from "node:zlib";
|
||||
|
||||
import { transform } from "esbuild";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, "..", "..");
|
||||
const sourcePath = path.join(
|
||||
repoRoot,
|
||||
"backend",
|
||||
"bot",
|
||||
"app",
|
||||
"web",
|
||||
"templates",
|
||||
"subscription_webapp.js"
|
||||
);
|
||||
const sourceCssPath = path.join(
|
||||
repoRoot,
|
||||
"backend",
|
||||
"bot",
|
||||
"app",
|
||||
"web",
|
||||
"templates",
|
||||
"subscription_webapp.css"
|
||||
);
|
||||
const JS_MINIFY_TARGET = "es2020";
|
||||
const templatesDir = path.join(repoRoot, "backend", "bot", "app", "web", "templates");
|
||||
const JS_BUILDS = [
|
||||
{
|
||||
sourcePath: path.join(templatesDir, "subscription_webapp.js"),
|
||||
outputPrefix: "subscription_webapp.min",
|
||||
stripDevMock: true,
|
||||
stripFallbackI18nPayload: true,
|
||||
},
|
||||
{
|
||||
sourcePath: path.join(templatesDir, "subscription_webapp_admin.js"),
|
||||
outputPrefix: "subscription_webapp_admin.min",
|
||||
},
|
||||
];
|
||||
const CSS_BUILDS = [
|
||||
{
|
||||
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) {
|
||||
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 });
|
||||
await Promise.all(
|
||||
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)))
|
||||
);
|
||||
}
|
||||
|
||||
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 withoutMocks = stripMarkedBlock(
|
||||
normalizeLineEndings(rawSource),
|
||||
"/* WEBAPP_DEV_MOCK_START */",
|
||||
"/* WEBAPP_DEV_MOCK_END */"
|
||||
);
|
||||
const strippedSource = stripFallbackI18n(withoutMocks);
|
||||
let strippedSource = normalizeLineEndings(rawSource);
|
||||
if (stripDevMock) {
|
||||
strippedSource = stripMarkedBlock(
|
||||
strippedSource,
|
||||
"/* WEBAPP_DEV_MOCK_START */",
|
||||
"/* WEBAPP_DEV_MOCK_END */"
|
||||
);
|
||||
}
|
||||
if (stripFallbackI18nPayload) {
|
||||
strippedSource = stripFallbackI18n(strippedSource);
|
||||
}
|
||||
const result = await transform(strippedSource, {
|
||||
charset: "utf8",
|
||||
legalComments: "none",
|
||||
loader: "js",
|
||||
minify: true,
|
||||
target: "es2018",
|
||||
target: JS_MINIFY_TARGET,
|
||||
});
|
||||
|
||||
const code = `${result.code.replace(/[ \t]+$/gm, "").trimEnd()}\n`;
|
||||
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(
|
||||
path.dirname(sourcePath),
|
||||
/^subscription_webapp\.min\.[0-9a-f]{8}\.js$/,
|
||||
path.basename(outputPath)
|
||||
new RegExp(`^${escapedPrefix}\\.[0-9a-f]{8}\\.js(?:\\.(?:br|gz))?$`),
|
||||
[outputName, `${outputName}.br`, `${outputName}.gz`]
|
||||
);
|
||||
await writeFile(outputPath, code, "utf8");
|
||||
const compressedJs = await writePrecompressedAssets(outputPath, code);
|
||||
console.log(
|
||||
`Wrote ${path.relative(repoRoot, outputPath)} (${Buffer.byteLength(code, "utf8")} bytes)`
|
||||
);
|
||||
|
||||
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)`
|
||||
`Wrote ${path.relative(repoRoot, outputPath)} (${Buffer.byteLength(code, "utf8")} bytes, gzip ${compressedJs.gzip}, br ${compressedJs.brotli})`
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user