Skip to content

Commit

Permalink
Cache optimization steps
Browse files Browse the repository at this point in the history
  • Loading branch information
cyrilwanner committed Jun 10, 2020
1 parent a9e4f4a commit fe5080f
Show file tree
Hide file tree
Showing 5 changed files with 189 additions and 3 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ npm-debug.log*
# project
lib
tmp
.cache
152 changes: 152 additions & 0 deletions src/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import path from 'path';
import os from 'os';
import { promises as fs, constants } from 'fs';
import querystring from 'querystring';
import { getHashDigest } from 'loader-utils';
import { ImageOptions } from './parseQuery';
import { LoaderOptions } from './options';
import { getLoaderVersion } from './util';

/**
* Checks if the given cache folder is valid and writable
*
* @async
* @param {string} cacheFolder Cache folder
* @returns {boolean} Whether the cache folder is valid
*/
const isValidCacheFolder = async (cacheFolder: string): Promise<boolean> => {
// try accessing the parent folder
try {
await fs.access(path.dirname(cacheFolder));
} catch {
return false;
}

// check if the folder already exists
try {
await fs.access(cacheFolder, constants.W_OK);
return true;
} catch {
// otherwise try to create the cache folder
try {
await fs.mkdir(cacheFolder);
return true;
} catch (e) {
return e.code === 'EEXIST';
}
}
};

/**
* Determines the correct cache folder to use
*
* @async
* @param {LoaderOptions} loaderOptions Optimized images loader options
* @returns {string} Cache folder path
*/
const getCacheFolder = async (loaderOptions: LoaderOptions): Promise<string> => {
let cacheFolder = loaderOptions.cacheFolder || path.resolve(__dirname, '..', '.cache');

if (await isValidCacheFolder(cacheFolder)) {
return cacheFolder;
}

if (!loaderOptions.cacheFolder) {
cacheFolder = path.resolve(os.tmpdir(), 'optimized-images-loader');

if (await isValidCacheFolder(cacheFolder)) {
return cacheFolder;
}
}

throw new Error(`Cache folder ${cacheFolder} is not writable or parent folder does not exist`);
};

/**
* Calculates a hash for the given image and query string
*
* @param {Buffer} source Source image
* @param {ImageOptions} imageOptions Image options
* @returns {string} Hash
*/
const getHash = (source: Buffer, imageOptions: ImageOptions): string => {
const query = querystring.stringify(imageOptions as any); // eslint-disable-line

return `${(getHashDigest as (input: Buffer) => string)(source)}-${(getHashDigest as (input: Buffer) => string)(
Buffer.from(query),
)}`;
};

/**
* Retrieves an optimized image from cache if it exists
*
* @async
* @param {Buffer} source Original image
* @param {ImageOptions} imageOptions Image resource query
* @param {LoaderOptions} loaderOptions Optimized images loader options
* @returns {{ data: Buffer | string | string[]; info: { width?: number; height?: number; format?: string } } | null} Cached image or null if not present
*/
export const getCache = async (
source: Buffer,
imageOptions: ImageOptions,
loaderOptions: LoaderOptions,
): Promise<{ data: Buffer | string | string[]; info: { width?: number; height?: number; format?: string } } | null> => {
const cacheFolder = await getCacheFolder(loaderOptions);
const hash = getHash(source, imageOptions);

try {
const options = JSON.parse((await fs.readFile(path.resolve(cacheFolder, `${hash}.json`))).toString());

// make sure the cache file was created for the current version
if (options.version !== (await getLoaderVersion())) {
return null;
}

const data = await fs.readFile(path.resolve(cacheFolder, hash));

if (options.isBuffer) {
return { data, info: options.info };
}

return { data: JSON.parse(data.toString()), info: options.info };
} catch {
return null;
}
};

/**
* Writes an optimized image into the cache
*
* @async
* @param {Buffer} source Original image
* @param {Buffer | string | string[]} result Optimized image
* @param {{ width?: number; height?: number; format?: string }} info Image information
* @param {ImageOptions} imageOptions Image options
* @param {LoaderOptions} loaderOptions Optimized images loader options
*/
export const setCache = async (
source: Buffer,
result: Buffer | string | string[],
{ width, height, format }: { width?: number; height?: number; format?: string },
imageOptions: ImageOptions,
loaderOptions: LoaderOptions,
): Promise<void> => {
const cacheFolder = await getCacheFolder(loaderOptions);
const hash = getHash(source, imageOptions);

if (Buffer.isBuffer(result)) {
await fs.writeFile(path.resolve(cacheFolder, hash), result);
} else {
await fs.writeFile(path.resolve(cacheFolder, hash), JSON.stringify(result));
}

await fs.writeFile(
path.resolve(cacheFolder, `${hash}.json`),
JSON.stringify({
imageOptions,
info: { width, height, format },
isBuffer: Buffer.isBuffer(result),
version: await getLoaderVersion(),
}),
);
};
18 changes: 15 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import processImage from './processImage';
import parseQuery from './parseQuery';
import { LoaderOptions } from './options';
import processLoaders from './processLoaders';
import { getCache, setCache } from './cache';

/**
* Optimized images loader
Expand All @@ -21,11 +22,22 @@ export default function optimizedImagesLoader(this: loader.LoaderContext, source
// parse image options
const imageOptions = parseQuery(this.resourceQuery, loaderOptions);

// process image
const { data, info } = await processImage(source, imageOptions, loaderOptions);
let result: { data: Buffer | string | string[]; info: { width?: number; height?: number; format?: string } };

// try retrieving the image from cache
const cached = await getCache(source, imageOptions, loaderOptions);
if (cached) {
result = cached;
} else {
// process image
result = await processImage(source, imageOptions, loaderOptions);

// cache processed image
setCache(source, result.data, result.info, imageOptions, loaderOptions);
}

// process further loaders
const output = processLoaders(this, data, info, imageOptions, loaderOptions);
const output = processLoaders(this, result.data, result.info, imageOptions, loaderOptions);

callback(null, output);
})();
Expand Down
1 change: 1 addition & 0 deletions src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { WebpOptions } from 'sharp';

export interface LoaderOptions {
optimize?: boolean;
cacheFolder?: string;
mozjpeg?: MozjpegOptions;
oxipng?: OxipngOptions;
webp?: WebpOptions;
Expand Down
20 changes: 20 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import path from 'path';
import { promises as fs } from 'fs';

let version: string;

/**
* Returns the version of optimized-images-loader
*
* @async
* @returns {string} Package version
*/
export const getLoaderVersion = async (): Promise<string> => {
if (!version) {
const packageJson = JSON.parse((await fs.readFile(path.resolve(__dirname, '..', 'package.json'))).toString());

version = packageJson.version;
}

return version;
};

0 comments on commit fe5080f

Please sign in to comment.