123 lines
4.4 KiB
JavaScript
123 lines
4.4 KiB
JavaScript
import { constants, brotliCompress as brotliCompressCallback, gzip as gzipCallback } from "node:zlib";
|
|
import { promisify } from "node:util";
|
|
import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
const brotliCompress = promisify(brotliCompressCallback);
|
|
const gzip = promisify(gzipCallback);
|
|
const COMPRESSIBLE_EXTENSIONS = new Set([".css", ".html", ".js", ".json", ".mjs", ".svg", ".txt", ".webmanifest", ".xml"]);
|
|
const MINIMUM_INPUT_BYTES = 1_024;
|
|
const MAX_COMPRESSION_WORKERS = 4;
|
|
|
|
export async function precompressWebAssets(rootDirectory) {
|
|
const files = await collectCompressibleFiles(rootDirectory);
|
|
let nextIndex = 0;
|
|
const report = { considered: files.length, brotliWritten: 0, gzipWritten: 0 };
|
|
|
|
await Promise.all(
|
|
Array.from({ length: Math.min(MAX_COMPRESSION_WORKERS, files.length) }, async () => {
|
|
while (nextIndex < files.length) {
|
|
const filePath = files[nextIndex++];
|
|
const input = await readFile(filePath);
|
|
const [brotli, gzipped] = await Promise.all([
|
|
brotliCompress(input, {
|
|
params: {
|
|
[constants.BROTLI_PARAM_QUALITY]: 7,
|
|
[constants.BROTLI_PARAM_MODE]: constants.BROTLI_MODE_TEXT,
|
|
},
|
|
}),
|
|
gzip(input, { level: 9 }),
|
|
]);
|
|
if (brotli.length < input.length) {
|
|
await writeFile(`${filePath}.br`, brotli);
|
|
report.brotliWritten += 1;
|
|
}
|
|
if (gzipped.length < input.length) {
|
|
await writeFile(`${filePath}.gz`, gzipped);
|
|
report.gzipWritten += 1;
|
|
}
|
|
}
|
|
})
|
|
);
|
|
|
|
return report;
|
|
}
|
|
|
|
/**
|
|
* React Router can emit an unreferenced `server-build-*.css` copy of the root
|
|
* stylesheet into the public client directory. Keeping and precompressing the
|
|
* duplicate adds image bytes and build CPU without serving a request. Delete
|
|
* it only when another byte-identical stylesheet exists and no emitted JS,
|
|
* HTML, or JSON asset references its filename.
|
|
*/
|
|
export async function pruneUnreferencedDuplicateCss(rootDirectory) {
|
|
let entries;
|
|
try {
|
|
entries = await readdir(rootDirectory, { withFileTypes: true });
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT") return { removed: 0, bytesRemoved: 0 };
|
|
throw error;
|
|
}
|
|
|
|
const cssEntries = entries.filter((entry) => entry.isFile() && entry.name.endsWith(".css"));
|
|
const candidates = cssEntries.filter((entry) => entry.name.startsWith("server-build-"));
|
|
if (candidates.length === 0) return { removed: 0, bytesRemoved: 0 };
|
|
|
|
const referenceEntries = entries.filter(
|
|
(entry) => entry.isFile() && [".html", ".js", ".json", ".mjs"].includes(path.extname(entry.name).toLowerCase()),
|
|
);
|
|
const referenceSources = await Promise.all(referenceEntries.map((entry) => readFile(path.join(rootDirectory, entry.name), "utf8")));
|
|
const cssContents = new Map(
|
|
await Promise.all(cssEntries.map(async (entry) => [entry.name, await readFile(path.join(rootDirectory, entry.name))])),
|
|
);
|
|
|
|
let removed = 0;
|
|
let bytesRemoved = 0;
|
|
for (const candidate of candidates) {
|
|
if (referenceSources.some((source) => source.includes(candidate.name))) continue;
|
|
const candidateContents = cssContents.get(candidate.name);
|
|
const duplicateExists = cssEntries.some(
|
|
(entry) => entry.name !== candidate.name && candidateContents?.equals(cssContents.get(entry.name)),
|
|
);
|
|
if (!duplicateExists || !candidateContents) continue;
|
|
await unlink(path.join(rootDirectory, candidate.name));
|
|
removed += 1;
|
|
bytesRemoved += candidateContents.length;
|
|
}
|
|
|
|
return { removed, bytesRemoved };
|
|
}
|
|
|
|
async function collectCompressibleFiles(rootDirectory) {
|
|
const files = [];
|
|
await visit(rootDirectory, files);
|
|
return files;
|
|
}
|
|
|
|
async function visit(directory, files) {
|
|
let entries;
|
|
try {
|
|
entries = await readdir(directory, { withFileTypes: true });
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT") return;
|
|
throw error;
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
await visit(fullPath, files);
|
|
} else if (
|
|
entry.isFile() &&
|
|
!entry.name.endsWith(".br") &&
|
|
!entry.name.endsWith(".gz") &&
|
|
COMPRESSIBLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())
|
|
) {
|
|
const fileStat = await stat(fullPath);
|
|
if (fileStat.size >= MINIMUM_INPUT_BYTES) {
|
|
files.push(fullPath);
|
|
}
|
|
}
|
|
}
|
|
}
|