diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 84e5ae36d..059aa81fc 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -5,16 +5,18 @@ // Please see LICENSE files in the repository root for full details. import { type FileHandle, open } from "node:fs/promises"; -import { resolve } from "node:path"; -import { promisify } from "node:util"; +import path, { resolve } from "node:path"; 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 { Environment, Manifest, PluginOption } from "vite"; +import type { Manifest, PluginOption } from "vite"; import codegen from "vite-plugin-graphql-codegen"; import { defineConfig } from "vitest/config"; +import { createWriteStream } from "node:fs"; +import { pipeline } from "node:stream/promises"; +import { Readable } from "node:stream"; function i18nHotReload(): PluginOption { return { @@ -33,54 +35,65 @@ 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; + writeBundle: { + // We need to run after Vite's plugins, as it will do some final touches + // to the files in this phase + order: "post", + async handler({ dir }, 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 []; - } + // 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); + 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); + // We pre-compress assets with brotli as it offers the best + // compression ratios compared to even zstd, and gzip as a fallback + return [ + { compressor: zlib.createGzip(), ext: "gz" }, + { + compressor: zlib.createBrotliCompress({ + params: { + [zlib.constants.BROTLI_PARAM_MODE]: + zlib.constants.BROTLI_MODE_TEXT, + [zlib.constants.BROTLI_PARAM_QUALITY]: 11, + [zlib.constants.BROTLI_PARAM_SIZE_HINT]: + uncompressed.length, + }, + }), + ext: "br", + }, + ].map(async ({ compressor, ext }) => { + const output = path.join(dir, `${fileName}.${ext}`); + const readStream = Readable.from(uncompressed); + const writeStream = createWriteStream(output); - this.emitFile({ - type: "asset", - fileName: `${fileName}.${ext}`, - source: compressed, + await pipeline(readStream, compressor, writeStream); }); - }); - }, - ); + }, + ); - await Promise.all(promises); + await Promise.all(promises); + }, }, }; } @@ -95,22 +108,13 @@ declare module "vite" { // 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"); - + async writeBundle({ dir }, bundle): Promise { + const hashes: Record> = {}; for (const [fileName, assetOrChunk] of Object.entries(bundle)) { // Start calculating hash of the asset. We can let that run in the // background @@ -119,7 +123,7 @@ function augmentManifest(): PluginOption { ? assetOrChunk.source : assetOrChunk.code; - envState[fileName] = (async (): Promise => { + hashes[fileName] = (async (): Promise => { const digest = await crypto.subtle.digest( "SHA-384", Buffer.from(source), @@ -127,12 +131,6 @@ function augmentManifest(): PluginOption { 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"); @@ -152,7 +150,7 @@ function augmentManifest(): PluginOption { for (const chunk of Object.values(manifest)) { existing.add(chunk.file); - chunk.integrity = await envState[chunk.file]; + chunk.integrity = await hashes[chunk.file]; for (const css of chunk.css ?? []) needs.add(css); for (const sub of chunk.assets ?? []) needs.add(sub); } @@ -162,7 +160,7 @@ function augmentManifest(): PluginOption { for (const asset of missing) { manifest[asset] = { file: asset, - integrity: await envState[asset], + integrity: await hashes[asset], }; } @@ -199,6 +197,7 @@ export default defineConfig((env) => ({ sourcemap: true, target: browserslistToEsbuild(), cssCodeSplit: true, + reportCompressedSize: false, rollupOptions: { // This uses all the files in the src/entrypoints directory as inputs