diff --git a/crates/cli/src/server.rs b/crates/cli/src/server.rs index 58d3672e0..25641c858 100644 --- a/crates/cli/src/server.rs +++ b/crates/cli/src/server.rs @@ -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| { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7062bb6a4..87687e8d9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 3918296db..4c3b290f3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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": { diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index cd9b36b78..84e5ae36d 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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>>(); + 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 => { + const digest = await crypto.subtle.digest( + "SHA-384", + Buffer.from(source), + ); + return `sha384-${Buffer.from(digest).toString("base64")}`; + })(); + } + }, + + async writeBundle({ dir }): Promise { + 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 = new Set(); + const needs: Set = 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 { - const manifestPath = resolve(dir, "manifest.json"); - - const manifest: Manifest | undefined = await readFile( - manifestPath, - "utf-8", - ).then(JSON.parse, () => undefined); - - if (manifest) { - const existing: Set = new Set(); - const needs: Set = 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(), ],