diff --git a/crates/templates/src/functions.rs b/crates/templates/src/functions.rs index 35bac44a9..f860580d8 100644 --- a/crates/templates/src/functions.rs +++ b/crates/templates/src/functions.rs @@ -505,7 +505,7 @@ impl Object for IncludeAsset { if tracker.mark_included(&src) { writeln!( output, - r#""# + r#""# ) .unwrap(); } @@ -516,7 +516,7 @@ impl Object for IncludeAsset { if tracker.mark_included(&src) { writeln!( output, - r#""# + r#""# ) .unwrap(); } @@ -524,14 +524,9 @@ impl Object for IncludeAsset { mas_spa::FileType::Json => { // When a JSON is included at the top level (a translation), we preload it - let integrity = main.integrity_attr(); let src = main.src(assets_base); if tracker.mark_preloaded(&src) { - writeln!( - output, - r#""#, - ) - .unwrap(); + writeln!(output, r#""#,).unwrap(); } } @@ -546,24 +541,25 @@ impl Object for IncludeAsset { } for asset in imported { - let integrity = asset.integrity_attr(); let src = asset.src(assets_base); match asset.file_type() { mas_spa::FileType::Stylesheet => { // Imported stylesheets are inserted directly, not just preloaded if tracker.mark_included(&src) { + let integrity = asset.integrity_attr(); writeln!( output, - r#""# + r#""# ) .unwrap(); } } mas_spa::FileType::Script => { if tracker.mark_preloaded(&src) { + let integrity = asset.integrity_attr(); writeln!( output, - r#""#, + r#""#, ) .unwrap(); } @@ -572,7 +568,7 @@ impl Object for IncludeAsset { if tracker.mark_preloaded(&src) { writeln!( output, - r#""#, + r#""#, ) .unwrap(); } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 84e5ae36d..130dc8d61 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -4,15 +4,17 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. +import { createWriteStream } from "node:fs"; 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 { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; 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"; @@ -33,54 +35,66 @@ 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, + // 10 yields better results and is quicker than 11 + [zlib.constants.BROTLI_PARAM_QUALITY]: 10, + [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 +109,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 +124,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 +132,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 +151,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 +161,7 @@ function augmentManifest(): PluginOption { for (const asset of missing) { manifest[asset] = { file: asset, - integrity: await envState[asset], + integrity: await hashes[asset], }; } @@ -199,6 +198,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