44// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
55// Please see LICENSE files in the repository root for full details.
66
7- import { readFile , writeFile } from "node:fs/promises" ;
7+ import { type FileHandle , open } from "node:fs/promises" ;
88import { resolve } from "node:path" ;
9+ import { promisify } from "node:util" ;
10+ import zlib from "node:zlib" ;
911import { tanstackRouter } from "@tanstack/router-plugin/vite" ;
1012import react from "@vitejs/plugin-react" ;
1113import browserslistToEsbuild from "browserslist-to-esbuild" ;
1214import { globSync } from "tinyglobby" ;
13- import type { Manifest , PluginOption } from "vite" ;
14- import compression from "vite-plugin-compression" ;
15+ import type { Environment , Manifest , PluginOption } from "vite" ;
1516import codegen from "vite-plugin-graphql-codegen" ;
16- import manifestSRI from "vite-plugin-manifest-sri" ;
1717import { defineConfig } from "vitest/config" ;
1818
1919function i18nHotReload ( ) : PluginOption {
@@ -31,6 +31,154 @@ function i18nHotReload(): PluginOption {
3131 } ;
3232}
3333
34+ // Pre-compress the assets, so that the server can serve them directly
35+ function compression ( ) : PluginOption {
36+ const gzip = promisify ( zlib . gzip ) ;
37+ const brotliCompress = promisify ( zlib . brotliCompress ) ;
38+
39+ return {
40+ name : "asset-compression" ,
41+ apply : "build" ,
42+ enforce : "post" ,
43+
44+ async generateBundle ( _outputOptions , bundle ) {
45+ const promises = Object . entries ( bundle ) . flatMap (
46+ ( [ fileName , assetOrChunk ] ) => {
47+ const source =
48+ assetOrChunk . type === "asset"
49+ ? assetOrChunk . source
50+ : assetOrChunk . code ;
51+
52+ // Don't compress empty files, only compress CSS, JS and JSON files
53+ if (
54+ ! source ||
55+ ! (
56+ fileName . endsWith ( ".js" ) ||
57+ fileName . endsWith ( ".css" ) ||
58+ fileName . endsWith ( ".json" )
59+ )
60+ ) {
61+ return [ ] ;
62+ }
63+
64+ const uncompressed = Buffer . from ( source ) ;
65+
66+ // We pre-compress assets with brotli as it offers the best
67+ // compression ratios compared to even zstd, and gzip as a fallback
68+ return [
69+ { compress : gzip , ext : "gz" } ,
70+ { compress : brotliCompress , ext : "br" } ,
71+ ] . map ( async ( { compress, ext } ) => {
72+ const compressed = await compress ( uncompressed ) ;
73+
74+ this . emitFile ( {
75+ type : "asset" ,
76+ fileName : `${ fileName } .${ ext } ` ,
77+ source : compressed ,
78+ } ) ;
79+ } ) ;
80+ } ,
81+ ) ;
82+
83+ await Promise . all ( promises ) ;
84+ } ,
85+ } ;
86+ }
87+
88+ declare module "vite" {
89+ interface ManifestChunk {
90+ integrity : string ;
91+ }
92+ }
93+
94+ // Custom plugin to make sure that each asset has an entry in the manifest
95+ // This is needed so that the preloading & asset integrity generation works
96+ // It also calculates integrity hashes for the assets
97+ function augmentManifest ( ) : PluginOption {
98+ // Store a per-environment state, in case the build is run multiple times, like in watch mode
99+ const state = new Map < Environment , Record < string , Promise < string > > > ( ) ;
100+ return {
101+ name : "augment-manifest" ,
102+ apply : "build" ,
103+ enforce : "post" ,
104+
105+ perEnvironmentStartEndDuringDev : true ,
106+ buildStart ( ) {
107+ state . set ( this . environment , { } ) ;
108+ } ,
109+
110+ generateBundle ( _outputOptions , bundle ) {
111+ const envState = state . get ( this . environment ) ;
112+ if ( ! envState ) throw new Error ( "No state for environment" ) ;
113+
114+ for ( const [ fileName , assetOrChunk ] of Object . entries ( bundle ) ) {
115+ // Start calculating hash of the asset. We can let that run in the
116+ // background
117+ const source =
118+ assetOrChunk . type === "asset"
119+ ? assetOrChunk . source
120+ : assetOrChunk . code ;
121+
122+ envState [ fileName ] = ( async ( ) : Promise < string > => {
123+ const digest = await crypto . subtle . digest (
124+ "SHA-384" ,
125+ Buffer . from ( source ) ,
126+ ) ;
127+ return `sha384-${ Buffer . from ( digest ) . toString ( "base64" ) } ` ;
128+ } ) ( ) ;
129+ }
130+ } ,
131+
132+ async writeBundle ( { dir } ) : Promise < void > {
133+ const envState = state . get ( this . environment ) ;
134+ if ( ! envState ) throw new Error ( "No state for environment" ) ;
135+ state . delete ( this . environment ) ;
136+
137+ const manifestPath = resolve ( dir , "manifest.json" ) ;
138+
139+ let manifestHandle : FileHandle ;
140+ try {
141+ manifestHandle = await open ( manifestPath , "r+" ) ;
142+ } catch ( error ) {
143+ // Manifest does not exist, nothing to do but still warn about
144+ this . warn ( `Failed to open manifest at ${ manifestPath } : ${ error } ` ) ;
145+ return ;
146+ }
147+ const rawManifest = await manifestHandle . readFile ( "utf-8" ) ;
148+ const manifest = JSON . parse ( rawManifest ) as Manifest ;
149+
150+ const existing : Set < string > = new Set ( ) ;
151+ const needs : Set < string > = new Set ( ) ;
152+
153+ for ( const chunk of Object . values ( manifest ) ) {
154+ existing . add ( chunk . file ) ;
155+ chunk . integrity = await envState [ chunk . file ] ;
156+ for ( const css of chunk . css ?? [ ] ) needs . add ( css ) ;
157+ for ( const sub of chunk . assets ?? [ ] ) needs . add ( sub ) ;
158+ }
159+
160+ const missing = Array . from ( needs ) . filter ( ( a ) => ! existing . has ( a ) ) ;
161+
162+ for ( const asset of missing ) {
163+ manifest [ asset ] = {
164+ file : asset ,
165+ integrity : await envState [ asset ] ,
166+ } ;
167+ }
168+
169+ // Overwrite the manifest with the augmented entries
170+ // XXX: you'd think that doing `manifestHandle.writeFile` would work, as
171+ // the docs says that it 'overwrites the file if it exists'. Turns out, it
172+ // reuses the previous position from `readFile`, so that would append on
173+ // the existing, so we have to use `write` with an explicit position.
174+ // Truncating the file just in case the output is smaller than before.
175+ await manifestHandle . truncate ( 0 ) ;
176+ await manifestHandle . write ( JSON . stringify ( manifest , null , 2 ) , 0 , "utf-8" ) ;
177+ await manifestHandle . close ( ) ;
178+ } ,
179+ } ;
180+ }
181+
34182export default defineConfig ( ( env ) => ( {
35183 base : "./" ,
36184
@@ -69,67 +217,9 @@ export default defineConfig((env) => ({
69217
70218 react ( ) ,
71219
72- // Custom plugin to make sure that each asset has an entry in the manifest
73- // This is needed so that the preloading & asset integrity generation works
74- {
75- name : "manifest-missing-assets" ,
76-
77- apply : "build" ,
78- enforce : "post" ,
79- writeBundle : {
80- // This needs to be executed sequentially before the manifestSRI plugin
81- sequential : true ,
82- order : "pre" ,
83- async handler ( { dir } ) : Promise < void > {
84- const manifestPath = resolve ( dir , "manifest.json" ) ;
85-
86- const manifest : Manifest | undefined = await readFile (
87- manifestPath ,
88- "utf-8" ,
89- ) . then ( JSON . parse , ( ) => undefined ) ;
90-
91- if ( manifest ) {
92- const existing : Set < string > = new Set ( ) ;
93- const needs : Set < string > = new Set ( ) ;
94-
95- for ( const chunk of Object . values ( manifest ) ) {
96- existing . add ( chunk . file ) ;
97- for ( const css of chunk . css ?? [ ] ) needs . add ( css ) ;
98- for ( const sub of chunk . assets ?? [ ] ) needs . add ( sub ) ;
99- }
100-
101- const missing = Array . from ( needs ) . filter ( ( a ) => ! existing . has ( a ) ) ;
102-
103- if ( missing . length > 0 ) {
104- for ( const asset of missing ) {
105- manifest [ asset ] = {
106- file : asset ,
107- integrity : "" ,
108- } ;
109- }
110-
111- await writeFile ( manifestPath , JSON . stringify ( manifest , null , 2 ) ) ;
112- }
113- }
114- } ,
115- } ,
116- } ,
117-
118- manifestSRI ( ) ,
220+ augmentManifest ( ) ,
119221
120- // Pre-compress the assets, so that the server can serve them directly
121- compression ( {
122- algorithm : "gzip" ,
123- ext : ".gz" ,
124- } ) ,
125- compression ( {
126- algorithm : "brotliCompress" ,
127- ext : ".br" ,
128- } ) ,
129- compression ( {
130- algorithm : "deflate" ,
131- ext : ".zz" ,
132- } ) ,
222+ compression ( ) ,
133223
134224 i18nHotReload ( ) ,
135225 ] ,
0 commit comments