Sfoglia il codice sorgente

feat(server): On-the-fly image transformations

Relates to #22.
Michael Bromley 7 anni fa
parent
commit
c1230198af

+ 7 - 2
server/dev-config.ts

@@ -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' },
+            ],
         }),
     ],
 };

+ 76 - 4
server/src/plugin/default-asset-server/default-asset-server-plugin.ts

@@ -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;
@@ -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,
@@ -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;
@@ -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}`;
+    }
 }

+ 9 - 9
server/src/plugin/default-asset-server/default-asset-storage-strategy.ts

@@ -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> {
@@ -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 {

+ 32 - 0
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;
+}

+ 5 - 5
server/src/service/asset.service.ts

@@ -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,
         );
@@ -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);
     }