I ran into an interesting problem while trying to create a static page to host a small graphics project on my Gatsby website.
For some context, I’m using Three.js to render a snow globe on a web canvas. Writing the code for the snow globe itself was relatively easy, but getting Gatsby to do exactly what I wanted proved to be the real challenge.
Here’s why:
I export my files in the GLTF model format, which consists of a primary .gltf
file along with associated files (like
texture assets or, in my case, a .bin
file). The Three.js GLTF model loader loads the GLTF file by taking the URL of
the primary .gltf
file and resolving the paths to any associated files relative to that file.
The problem is that Gatsby’s static asset pipeline doesn’t work well with these path dependencies. By default, Gatsby
stores static files in paths that include a hash generated by MD5-ing the contents of the file. So, for example, instead
of cats.png
, you might get a URL like cat-<hash>.png
.
This hashing mechanism has several benefits: for instance, if an asset changes, its path will change as well, ensuring that the new asset is fetched immediately without waiting for cache invalidation.
However, this hash-based system doesn’t play well with GLTF files, as they rely on relative paths between the .gltf
file and its associated assets. So, what can we do to resolve this?
Here’s the solution I came up with.
At a high level:
- Create a special folder (named
assets
) to tell Gatsby to treat these files differently so they don’t get hash-appended. - Generate a hash for the assets so that when they change, their paths also change.
- Store the assets in paths that include both the hash folder and the original file path relative to the assets.
Here’s how this looks in my gatsby-node.ts
config:
async function scanForAssetFolders(
baseDir: string
): Promise<{[path: string]: AssetFolderInfo}> {
const foldersToScan = [baseDir];
const assetFolders: {[path: string]: AssetFolderInfo} = {};
while (foldersToScan.length > 0) {
const folder = foldersToScan.pop()!;
for (const filename of await fs.readdir(folder)) {
const filepath = path.resolve(folder, filename);
const filestat = await fs.stat(filepath);
if (!filestat.isDirectory()) {
continue;
}
if (filename === 'assets') {
const mtimeStr = filestat.mtime.getSeconds().toString();
const hash = crypto.createHash('md5')
.update(filepath)
.update(mtimeStr)
.digest('hex');
assetFolders[filepath] = {hash};
continue;
}
foldersToScan.push(filepath);
}
}
return assetFolders;
}
export const onCreateWebpackConfig: GatsbyNode["onCreateWebpackConfig"] = async ({
actions,
getConfig
}) => {
const config = getConfig();
const assetFolders = await scanForAssetFolders(path.resolve('src'));
for (const rule of config.module.rules) {
rule.exclude = /\/assets\//;
}
config.module.rules.push({
test: /\/assets\//,
use: {
loader: 'file-loader',
options: {
outputPath: (url: string, resourcePath: string) => {
const splitIdx = resourcePath.lastIndexOf('/assets/') + 7;
const assetFolderPath = resourcePath.slice(0, splitIdx);
const assetFilePath = resourcePath.slice(splitIdx + 1);
const hash = assetFolders[assetFolderPath].hash;
return path.join('assets', hash, assetFilePath);
}
}
}
});
actions.replaceWebpackConfig(config);
};
So far, this solution works pretty well! I’ll likely iterate on it further, but I’m happy with how it’s functioning for now.