My Incorrect Assumption About Content Hashes

May 08, 2024

For this site I use Vite for my styles, fonts, and such. Vite is wonderful, since it also versions your assets (by suffixing files with a content hash).

Vite generates a manifest file which is then used to resolve these files via the @vite directive in your Blade files.

However, when the site goes down with php artisan down I assume that the Vite manifest is missing and various files must be resolved via a fallback function.

Why? For example, in the CSS of my errors.blade.php template file, I include some custom CSS, but I still need to point to my custom (self-hosted) font files. These do not have a fixed path due to the versioning suffix.

So during downtime, these files need to be found… but may not be able to be resolved using the @vite directive! (It could be missing.)

So, because of this, for this Blade file only, I use a special method to figure out where the processed Vite file is located:

(...)

@font-face {
    font-family: 'inter-latin1';
    src: url('{{ resolve_asset_fallback('Inter-Bold-Latin1.woff2') }}') format('woff2');
    font-weight: bold;
    font-style: normal;
    font-display: swap;
}

As you can probably guess, resolve_asset_fallback() attempts to resolve Inter-Bold-Latin1.woff2 to a hashed filename.

For each deployment, the list of files in the public directory is iterated through, and the original filename is extracted. This gets me a convenient map like this:

$files = [
   'IBMPlexSerif-Regular-Latin1.woff2' => '/build/assets/IBMPlexSerif-Regular-Latin1-BSI4W3hL.woff2'
   'IBMPlexSerif-Bold-Latin1.woff2' => '/build/assets/IBMPlexSerif-Bold-Latin1-CQoZdUZs.woff2'
   // etc.
];

This (cached) map is then used by resolve_asset_fallback() to replace the identifier with the correct full path to the font or CSS files.

Looking at the files that include the hashed suffix, I originally had a simple function that would loop through the files, and split the filename on the last hyphen. After that, the extension would be (re-)added.

This simple approach looked like this:

  • Start with the hashed filename:
    "IBMPlexSerif-Regular-Latin1-BSI4W3hL.woff2"
  • Split on hyphens:
    ["IBMPlexSerif", "Regular", "Latin", "BSI4W3hL.woff2"]
  • Remove the final element (splice it out), merge again:
    "IBMPlexSerif-Regular-Latin1"
  • Suffix the extension: the end result is:
    "IBMPlexSerif-Regular-Latin1.woff2"

The original function looked like this:

public function mapFiles($path): Collection                                         
{                                                                                   
    return collect(File::allFiles(public_path($path)))                              
        ->flatMap(function (SplFileInfo $file) use ($path) {                        
            $originalFileName = $file->getFilename();                               
            $extension = $file->getExtension();                                     
                                                                                    
            $fileSegments = explode('-', $originalFileName);                        
            array_splice($fileSegments, count($fileSegments) - 1, 1);               
            $fileName = implode('-', $fileSegments);                                
                                                                                    
            return ["{$fileName}.{$extension}" => "/{$path}/" . $originalFileName]; 
        });                                                                         
}                                                                                   

Unfortunately, it turns out that these Vite hashes can contain hyphens. Oops. I did not know that.

ERROR: 'Inter-Bold-Latin1.woff2' could not be resolved.

So earlier today, a file could not be resolved on my fallback page, which did happen to contain some Inter Bold.

I went to investigate, and sure enough, the mapping for Inter Bold looked like this. To illustrate:

// INCORRECT
['Inter-Bold-Latin1-D.woff2' => 'Inter-Bold-Latin1-D-Yvj0aD.woff2']

// EXPECTED
['Inter-Bold-Latin1.woff2' => 'Inter-Bold-Latin1-D-Yvj0aD.woff2']

Ah, yes… the 8-character hash (D-Yvj0aD) contained a hyphen, and that was causing this problem.

I needed to come up with a different solution.

Fortunately, the solution is fairly easy, because I know two things. (1) I know that each hash has a fixed size. (2) I also know what the extension is.

So, I can simply trim a certain amount of characters from the tail of the processed filename in order to get the original filename.

Here’s what my new mapping function looks like, and what it probably should have looked like from the beginning:

public function mapFiles($path): Collection                                                                       
{                                                                                                                 
    return collect(File::allFiles(public_path($path)))                                                            
        ->flatMap(function (SplFileInfo $file) use ($path) {                                                      
            $hashedFileName = $file->getFilename();                                                               
            $extension = $file->getExtension();                                                                   
                                                                                                                  
            // We need to offset, for example for the following string:                                           
            //                                                                                                    
            // Inter-Bold-Latin1-D-Yvj0aD.woff2                                                                   
            //                  ^^^^^^^^^           (1) vite hash (note there's a hyphen in this example!)          
            //                            ^^^^^     (2) file extension (length depends on original file)          
            //                                                                                                    
            // To resolve the original filename:                                                                  
            // Inter-Bold-Latin1.woff2                                                                             
                                                                                                                  
            // (1) The hash has a fixed length, but there's also a hyphen preceding it.                             
            $hashLength = self::VITE_HASH_LENGTH + 1;                                                             
                                                                                                                  
            // (2) The extension length is variable, but there's also a dot preceding it.                         
            $extensionLength = strlen($extension) + 1;                                                            
                                                                                                                  
            // Get the original filename by trimming the end of the string.                                       
            $originalFileName = substr($hashedFileName, 0, -($hashLength + $extensionLength));                    
                                                                                                                  
            return ["{$originalFileName}.{$extension}" => "/{$path}/{$hashedFileName}"];                        
        });
}

This nicely fixed the issue! The test that I had written wasn’t exhaustive enough to actually identify this particular edge case… oh well, lesson learned!

Tagged as: Programming