Handle pre-compressed assets better (#5370)

This commit is contained in:
Quentin Gliech
2025-12-18 17:00:30 +01:00
committed by GitHub
4 changed files with 156 additions and 137 deletions
+2 -2
View File
@@ -248,9 +248,9 @@ pub fn build_router(
mas_config::HttpResource::Assets { path } => {
let static_service = ServeDir::new(path)
.append_index_html_on_directories(false)
// The vite build pre-compresses assets with brotli and gzip
.precompressed_br()
.precompressed_gzip()
.precompressed_deflate();
.precompressed_gzip();
let add_cache_headers = axum::middleware::map_response(
async |mut res: Response<ServeFileSystemResponseBody>| {
-69
View File
@@ -67,9 +67,7 @@
"tinyglobby": "^0.2.15",
"typescript": "^5.9.3",
"vite": "7.3.0",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-graphql-codegen": "^3.7.0",
"vite-plugin-manifest-sri": "^0.2.0",
"vitest": "^4.0.15"
}
},
@@ -8088,21 +8086,6 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -8275,13 +8258,6 @@
"csstype": "^3.0.10"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/graphql": {
"version": "16.12.0",
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz",
@@ -9886,19 +9862,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/jsonfile": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/knip": {
"version": "5.74.0",
"resolved": "https://registry.npmjs.org/knip/-/knip-5.74.0.tgz",
@@ -13062,16 +13025,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/unixify": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unixify/-/unixify-1.0.0.tgz",
@@ -13344,21 +13297,6 @@
}
}
},
"node_modules/vite-plugin-compression": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/vite-plugin-compression/-/vite-plugin-compression-0.5.1.tgz",
"integrity": "sha512-5QJKBDc+gNYVqL/skgFAP81Yuzo9R+EAf19d+EtsMF/i8kFUpNi3J/H01QD3Oo8zBQn+NzoCIFkpPLynoOzaJg==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "^4.1.2",
"debug": "^4.3.3",
"fs-extra": "^10.0.0"
},
"peerDependencies": {
"vite": ">=2.0.0"
}
},
"node_modules/vite-plugin-graphql-codegen": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/vite-plugin-graphql-codegen/-/vite-plugin-graphql-codegen-3.7.0.tgz",
@@ -13371,13 +13309,6 @@
"vite": ">=2.7.0 <8.0.0"
}
},
"node_modules/vite-plugin-manifest-sri": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/vite-plugin-manifest-sri/-/vite-plugin-manifest-sri-0.2.0.tgz",
"integrity": "sha512-Zt5jt19xTIJ91LOuQTCtNG7rTFc5OziAjBz2H5NdCGqaOD1nxrWExLhcKW+W4/q8/jOPCg/n5ncYEQmqCxiGQQ==",
"dev": true,
"license": "MIT"
},
"node_modules/vitest": {
"version": "4.0.15",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.15.tgz",
-2
View File
@@ -77,9 +77,7 @@
"tinyglobby": "^0.2.15",
"typescript": "^5.9.3",
"vite": "7.3.0",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-graphql-codegen": "^3.7.0",
"vite-plugin-manifest-sri": "^0.2.0",
"vitest": "^4.0.15"
},
"msw": {
+154 -64
View File
@@ -4,16 +4,16 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
import { readFile, writeFile } from "node:fs/promises";
import { type FileHandle, open } from "node:fs/promises";
import { resolve } from "node:path";
import { promisify } from "node:util";
import zlib from "node:zlib";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react";
import browserslistToEsbuild from "browserslist-to-esbuild";
import { globSync } from "tinyglobby";
import type { Manifest, PluginOption } from "vite";
import compression from "vite-plugin-compression";
import type { Environment, Manifest, PluginOption } from "vite";
import codegen from "vite-plugin-graphql-codegen";
import manifestSRI from "vite-plugin-manifest-sri";
import { defineConfig } from "vitest/config";
function i18nHotReload(): PluginOption {
@@ -31,6 +31,154 @@ function i18nHotReload(): PluginOption {
};
}
// Pre-compress the assets, so that the server can serve them directly
function compression(): PluginOption {
const gzip = promisify(zlib.gzip);
const brotliCompress = promisify(zlib.brotliCompress);
return {
name: "asset-compression",
apply: "build",
enforce: "post",
async generateBundle(_outputOptions, bundle) {
const promises = Object.entries(bundle).flatMap(
([fileName, assetOrChunk]) => {
const source =
assetOrChunk.type === "asset"
? assetOrChunk.source
: assetOrChunk.code;
// Don't compress empty files, only compress CSS, JS and JSON files
if (
!source ||
!(
fileName.endsWith(".js") ||
fileName.endsWith(".css") ||
fileName.endsWith(".json")
)
) {
return [];
}
const uncompressed = Buffer.from(source);
// We pre-compress assets with brotli as it offers the best
// compression ratios compared to even zstd, and gzip as a fallback
return [
{ compress: gzip, ext: "gz" },
{ compress: brotliCompress, ext: "br" },
].map(async ({ compress, ext }) => {
const compressed = await compress(uncompressed);
this.emitFile({
type: "asset",
fileName: `${fileName}.${ext}`,
source: compressed,
});
});
},
);
await Promise.all(promises);
},
};
}
declare module "vite" {
interface ManifestChunk {
integrity: string;
}
}
// Custom plugin to make sure that each asset has an entry in the manifest
// This is needed so that the preloading & asset integrity generation works
// It also calculates integrity hashes for the assets
function augmentManifest(): PluginOption {
// Store a per-environment state, in case the build is run multiple times, like in watch mode
const state = new Map<Environment, Record<string, Promise<string>>>();
return {
name: "augment-manifest",
apply: "build",
enforce: "post",
perEnvironmentStartEndDuringDev: true,
buildStart() {
state.set(this.environment, {});
},
generateBundle(_outputOptions, bundle) {
const envState = state.get(this.environment);
if (!envState) throw new Error("No state for environment");
for (const [fileName, assetOrChunk] of Object.entries(bundle)) {
// Start calculating hash of the asset. We can let that run in the
// background
const source =
assetOrChunk.type === "asset"
? assetOrChunk.source
: assetOrChunk.code;
envState[fileName] = (async (): Promise<string> => {
const digest = await crypto.subtle.digest(
"SHA-384",
Buffer.from(source),
);
return `sha384-${Buffer.from(digest).toString("base64")}`;
})();
}
},
async writeBundle({ dir }): Promise<void> {
const envState = state.get(this.environment);
if (!envState) throw new Error("No state for environment");
state.delete(this.environment);
const manifestPath = resolve(dir, "manifest.json");
let manifestHandle: FileHandle;
try {
manifestHandle = await open(manifestPath, "r+");
} catch (error) {
// Manifest does not exist, nothing to do but still warn about
this.warn(`Failed to open manifest at ${manifestPath}: ${error}`);
return;
}
const rawManifest = await manifestHandle.readFile("utf-8");
const manifest = JSON.parse(rawManifest) as Manifest;
const existing: Set<string> = new Set();
const needs: Set<string> = new Set();
for (const chunk of Object.values(manifest)) {
existing.add(chunk.file);
chunk.integrity = await envState[chunk.file];
for (const css of chunk.css ?? []) needs.add(css);
for (const sub of chunk.assets ?? []) needs.add(sub);
}
const missing = Array.from(needs).filter((a) => !existing.has(a));
for (const asset of missing) {
manifest[asset] = {
file: asset,
integrity: await envState[asset],
};
}
// Overwrite the manifest with the augmented entries
// XXX: you'd think that doing `manifestHandle.writeFile` would work, as
// the docs says that it 'overwrites the file if it exists'. Turns out, it
// reuses the previous position from `readFile`, so that would append on
// the existing, so we have to use `write` with an explicit position.
// Truncating the file just in case the output is smaller than before.
await manifestHandle.truncate(0);
await manifestHandle.write(JSON.stringify(manifest, null, 2), 0, "utf-8");
await manifestHandle.close();
},
};
}
export default defineConfig((env) => ({
base: "./",
@@ -69,67 +217,9 @@ export default defineConfig((env) => ({
react(),
// Custom plugin to make sure that each asset has an entry in the manifest
// This is needed so that the preloading & asset integrity generation works
{
name: "manifest-missing-assets",
augmentManifest(),
apply: "build",
enforce: "post",
writeBundle: {
// This needs to be executed sequentially before the manifestSRI plugin
sequential: true,
order: "pre",
async handler({ dir }): Promise<void> {
const manifestPath = resolve(dir, "manifest.json");
const manifest: Manifest | undefined = await readFile(
manifestPath,
"utf-8",
).then(JSON.parse, () => undefined);
if (manifest) {
const existing: Set<string> = new Set();
const needs: Set<string> = new Set();
for (const chunk of Object.values(manifest)) {
existing.add(chunk.file);
for (const css of chunk.css ?? []) needs.add(css);
for (const sub of chunk.assets ?? []) needs.add(sub);
}
const missing = Array.from(needs).filter((a) => !existing.has(a));
if (missing.length > 0) {
for (const asset of missing) {
manifest[asset] = {
file: asset,
integrity: "",
};
}
await writeFile(manifestPath, JSON.stringify(manifest, null, 2));
}
}
},
},
},
manifestSRI(),
// Pre-compress the assets, so that the server can serve them directly
compression({
algorithm: "gzip",
ext: ".gz",
}),
compression({
algorithm: "brotliCompress",
ext: ".br",
}),
compression({
algorithm: "deflate",
ext: ".zz",
}),
compression(),
i18nHotReload(),
],