Skip to content

Commit

Permalink
feat(server): On-the-fly image transformations
Browse files Browse the repository at this point in the history
Relates to #22.
  • Loading branch information
michaelbromley committed Sep 14, 2018
1 parent d98cc5b commit c123019
Show file tree
Hide file tree
Showing 5 changed files with 129 additions and 20 deletions.
9 changes: 7 additions & 2 deletions server/dev-config.ts
Expand Up @@ -38,8 +38,13 @@ export const devConfig: VendureConfig = {
assetUploadDir: path.join(__dirname, 'assets'),
port: 4000,
hostname: 'http://localhost',
previewMaxHeight: 200,
previewMaxWidth: 200,
previewMaxHeight: 1600,
previewMaxWidth: 1600,
presets: [
{ name: 'tiny', width: 50, height: 50, mode: 'crop' },
{ name: 'thumb', width: 150, height: 150, mode: 'crop' },
{ name: 'medium', width: 500, height: 500, mode: 'resize' },
],
}),
],
};
@@ -1,11 +1,24 @@
import * as express from 'express';
import { NextFunction, Request, Response } from 'express';
import * as proxy from 'http-proxy-middleware';
import * as path from 'path';

import { AssetStorageStrategy } from '../../config/asset-storage-strategy/asset-storage-strategy';
import { VendureConfig } from '../../config/vendure-config';
import { VendurePlugin } from '../../config/vendure-plugin/vendure-plugin';

import { DefaultAssetPreviewStrategy } from './default-asset-preview-strategy';
import { DefaultAssetStorageStrategy } from './default-asset-storage-strategy';
import { transformImage } from './transform-image';

export type ImageTransformMode = 'crop' | 'resize';

export interface ImageTransformPreset {
name: string;
width: number;
height: number;
mode: ImageTransformMode;
}

export interface DefaultAssetServerOptions {
hostname: string;
Expand All @@ -14,22 +27,28 @@ export interface DefaultAssetServerOptions {
assetUploadDir: string;
previewMaxWidth: number;
previewMaxHeight: number;
presets?: ImageTransformPreset[];
}

/**
* The DefaultAssetServerPlugin instantiates a static Express server which is used to
* serve the assets.
* serve the assets. It can also perform on-the-fly image transformations and caches the
* results for subsequent calls.
*/
export class DefaultAssetServerPlugin implements VendurePlugin {
private assetStorage: AssetStorageStrategy;
private readonly cacheDir = 'cache';

constructor(private options: DefaultAssetServerOptions) {}

init(config: Required<VendureConfig>) {
this.createAssetServer();
this.assetStorage = new DefaultAssetStorageStrategy(this.options.assetUploadDir, this.options.route);
config.assetPreviewStrategy = new DefaultAssetPreviewStrategy({
maxWidth: this.options.previewMaxWidth,
maxHeight: this.options.previewMaxHeight,
});
config.assetStorageStrategy = new DefaultAssetStorageStrategy(this.options.assetUploadDir);
config.assetStorageStrategy = this.assetStorage;
config.middleware.push({
handler: this.createProxyHandler(),
route: this.options.route,
Expand All @@ -42,12 +61,59 @@ export class DefaultAssetServerPlugin implements VendurePlugin {
*/
private createAssetServer() {
const assetServer = express();
assetServer.use(express.static(this.options.assetUploadDir));
assetServer.use(this.serveStaticFile(), this.generateTransformedImage());
assetServer.listen(this.options.port);
}

/**
* Configures the proxy middleware which will be passed to the main Vendure server.
* Sends the file requested to the broswer.
*/
private serveStaticFile() {
return (req: Request, res: Response) => {
const filePath = path.join(this.options.assetUploadDir, this.getFileNameFromRequest(req));
res.sendFile(filePath);
};
}

/**
* If an exception was thrown by the first handler, then it may be because a transformed image
* is being requested which does not yet exist. In this case, this handler will generate the
* transformed image, save it to cache, and serve the result as a response.
*/
private generateTransformedImage() {
return async (err, req: Request, res: Response, next: NextFunction) => {
if (err && err.status === 404) {
if (req.query) {
const file = await this.assetStorage.readFileToBuffer(req.path);
const image = await transformImage(file, req.query, this.options.presets || []);
const imageBuffer = await image.toBuffer();
const cachedFileName = this.getFileNameFromRequest(req);
await this.assetStorage.writeFileFromBuffer(cachedFileName, imageBuffer);
res.set('Content-Type', `image/${(await image.metadata()).format}`);
res.send(imageBuffer);
}
}
next();
};
}

private getFileNameFromRequest(req: Request): string {
if (req.query.w || req.query.h) {
const width = req.query.w || '';
const height = req.query.h || '';
const mode = req.query.mode || '';
return this.cacheDir + '/' + this.addSuffix(req.path, `_transform_w${width}_h${height}_m${mode}`);
} else if (req.query.preset) {
if (this.options.presets && !!this.options.presets.find(p => p.name === req.query.preset)) {
return this.cacheDir + '/' + this.addSuffix(req.path, `_transform_pre_${req.query.preset}`);
}
}
return req.path;
}

/**
* Configures the proxy middleware which will be passed to the main Vendure server. This
* will proxy all asset requests to the dedicated asset server.
*/
private createProxyHandler() {
const route = this.options.route.charAt(0) === '/' ? this.options.route : '/' + this.options.route;
Expand All @@ -58,4 +124,10 @@ export class DefaultAssetServerPlugin implements VendurePlugin {
},
});
}

private addSuffix(fileName: string, suffix: string): string {
const ext = path.extname(fileName);
const baseName = path.basename(fileName, ext);
return `${baseName}${suffix}${ext}`;
}
}
Expand Up @@ -10,10 +10,8 @@ import { AssetStorageStrategy } from '../../config/asset-storage-strategy/asset-
* A persistence strategy which saves files to the local file system.
*/
export class DefaultAssetStorageStrategy implements AssetStorageStrategy {
private uploadPath: string;

constructor(uploadDir: string = 'assets') {
this.setAbsoluteUploadPath(uploadDir);
constructor(private readonly uploadPath: string, private readonly route: string) {
this.ensureUploadPathExists(this.uploadPath);
}

writeFileFromStream(fileName: string, data: Stream): Promise<string> {
Expand Down Expand Up @@ -42,19 +40,21 @@ export class DefaultAssetStorageStrategy implements AssetStorageStrategy {
}

toAbsoluteUrl(request: Request, identifier: string): string {
return `${request.protocol}://${request.get('host')}/${identifier}`;
return `${request.protocol}://${request.get('host')}/${this.route}/${identifier}`;
}

private setAbsoluteUploadPath(uploadDir: string): string {
this.uploadPath = uploadDir;
private ensureUploadPathExists(uploadDir: string): void {
if (!fs.existsSync(this.uploadPath)) {
fs.mkdirSync(this.uploadPath);
}
return this.uploadPath;
const cachePath = path.join(this.uploadPath, 'cache');
if (!fs.existsSync(cachePath)) {
fs.mkdirSync(cachePath);
}
}

private filePathToIdentifier(filePath: string): string {
return `${path.basename(this.uploadPath)}/${path.basename(filePath)}`;
return `${path.basename(filePath)}`;
}

private identifierToFilePath(identifier: string): string {
Expand Down
32 changes: 32 additions & 0 deletions server/src/plugin/default-asset-server/transform-image.ts
@@ -0,0 +1,32 @@
import { Request } from 'express';
import * as sharp from 'sharp';

import { ImageTransformMode, ImageTransformPreset } from './default-asset-server-plugin';

/**
* Applies transforms to the gifen image according to the query params passed.
*/
export async function transformImage(
originalImage: Buffer,
queryParams: Record<string, string>,
presets: ImageTransformPreset[],
): Promise<sharp.SharpInstance> {
let width = +queryParams.w || undefined;
let height = +queryParams.h || undefined;
let mode = queryParams.mode || 'crop';
if (queryParams.preset) {
const matchingPreset = presets.find(p => p.name === queryParams.preset);
if (matchingPreset) {
width = matchingPreset.width;
height = matchingPreset.height;
mode = matchingPreset.mode;
}
}
const image = sharp(originalImage).resize(width, height);
if (mode === 'crop') {
image.crop(sharp.strategy.entropy);
} else {
image.max();
}
return image;
}
10 changes: 5 additions & 5 deletions server/src/service/asset.service.ts
Expand Up @@ -34,10 +34,10 @@ export class AssetService {
const { assetPreviewStrategy, assetStorageStrategy } = this.configService;
const normalizedFileName = this.normalizeFileName(filename);

const sourceFile = await assetStorageStrategy.writeFileFromStream(normalizedFileName, stream);
const image = await assetStorageStrategy.readFileToBuffer(sourceFile);
const sourceFileName = await assetStorageStrategy.writeFileFromStream(normalizedFileName, stream);
const image = await assetStorageStrategy.readFileToBuffer(sourceFileName);
const preview = await assetPreviewStrategy.generatePreviewImage(mimetype, image);
const previewFile = await assetStorageStrategy.writeFileFromBuffer(
const previewFileName = await assetStorageStrategy.writeFileFromBuffer(
this.addSuffix(normalizedFileName, '__preview'),
preview,
);
Expand All @@ -46,8 +46,8 @@ export class AssetService {
type: AssetType.IMAGE,
name: filename,
mimetype,
source: sourceFile,
preview: previewFile,
source: sourceFileName,
preview: previewFileName,
});
return this.connection.manager.save(asset);
}
Expand Down

0 comments on commit c123019

Please sign in to comment.