import { AssetStorageStrategy, InjectorFn, LocalAssetStorageStrategy, VendureConfig, VendurePlugin } from '@vendure/core'; import express, { NextFunction, Request, Response } from 'express'; import { Server } from 'http'; import proxy from 'http-proxy-middleware'; import path from 'path'; import { SharpAssetPreviewStrategy } from './sharp-asset-preview-strategy'; import { transformImage } from './transform-image'; /** * @description * Specifies the way in which an asset preview image will be resized to fit in the * proscribed dimensions: * * * crop: crops the image to cover both provided dimensions * * resize: Preserving aspect ratio, resizes the image to be as large as possible * while ensuring its dimensions are less than or equal to both those specified. * * @docsCategory AssetServerPlugin */ export type ImageTransformMode = 'crop' | 'resize'; /** * @description * A configuration option for an image size preset for the AssetServerPlugin. * * Presets allow a shorthand way to generate a thumbnail preview of an asset. For example, * the built-in "tiny" preset generates a 50px x 50px cropped preview, which can be accessed * by appending the string `preset=tiny` to the asset url: * * `http://localhost:3000/assets/some-asset.jpg?preset=tiny` * * is equivalent to: * * `http://localhost:3000/assets/some-asset.jpg?w=50&h=50&mode=crop` * * @docsCategory AssetServerPlugin */ export interface ImageTransformPreset { name: string; width: number; height: number; mode: ImageTransformMode; } /** * @description * The configuration options for the AssetServerPlugin. * * @docsCategory AssetServerPlugin */ export interface AssetServerOptions { hostname?: string; /** * @description * The local port that the server will run on. Note that the AssetServerPlugin * includes a proxy server which allows the asset server to be accessed on the same * port as the main Vendure server. */ port: number; /** * @description * The proxy route to the asset server. */ route: string; /** * @description * The local directory to which assets will be uploaded. */ assetUploadDir: string; /** * @description * The max width in pixels of a generated preview image. * * @default 1600 */ previewMaxWidth?: number; /** * @description * The max height in pixels of a generated preview image. * * @default 1600 */ previewMaxHeight?: number; /** * @description * An array of additional {@link ImageTransformPreset} objects. */ presets?: ImageTransformPreset[]; } /** * @description * The `AssetServerPlugin` serves assets (images and other files) from the local file system. It can also perform on-the-fly image transformations * and caches the results for subsequent calls. * * @example * ```ts * const config: VendureConfig = { * // Add an instance of the plugin to the plugins array * plugins: [ * new AssetServerPlugin({ * route: 'assets', * assetUploadDir: path.join(__dirname, 'assets'), * port: 4000, * }), * ], * }; * ``` * * The full configuration is documented at [AssetServerOptions]({{< relref "asset-server-options" >}}) * * ## Image transformation * * Asset preview images can be transformed (resized & cropped) on the fly by appending query parameters to the url: * * `http://localhost:3000/assets/some-asset.jpg?w=500&h=300&mode=resize` * * The above URL will return `some-asset.jpg`, resized to fit in the bounds of a 500px x 300px rectangle. * * ### Preview mode * * The `mode` parameter can be either `crop` or `resize`. See the [ImageTransformMode]({{< relref "image-transform-mode" >}}) docs for details. * * ### Transform presets * * Presets can be defined which allow a single preset name to be used instead of specifying the width, height and mode. Presets are * configured via the AssetServerOptions [presets property]({{< relref "asset-server-options" >}}#presets). * * For example, defining the following preset: * * ```ts * new AssetServerPlugin({ * // ... * presets: [ * { name: 'my-preset', width: 85, height: 85, mode: 'crop' }, * ], * }), * ``` * * means that a request to: * * `http://localhost:3000/assets/some-asset.jpg?preset=my-preset` * * is equivalent to: * * `http://localhost:3000/assets/some-asset.jpg?w=85&h=85&mode=crop` * * The AssetServerPlugin comes pre-configured with the following presets: * * name | width | height | mode * -----|-------|--------|----- * tiny | 50px | 50px | crop * thumb | 150px | 150px | crop * small | 300px | 300px | resize * medium | 500px | 500px | resize * large | 800px | 800px | resize * * @docsCategory AssetServerPlugin */ export class AssetServerPlugin implements VendurePlugin { private server: Server; private assetStorage: AssetStorageStrategy; private readonly cacheDir = 'cache'; private readonly presets: ImageTransformPreset[] = [ { name: 'tiny', width: 50, height: 50, mode: 'crop' }, { name: 'thumb', width: 150, height: 150, mode: 'crop' }, { name: 'small', width: 300, height: 300, mode: 'resize' }, { name: 'medium', width: 500, height: 500, mode: 'resize' }, { name: 'large', width: 800, height: 800, mode: 'resize' }, ]; constructor(private options: AssetServerOptions) { if (options.presets) { for (const preset of options.presets) { const existingIndex = this.presets.findIndex(p => p.name === preset.name); if (-1 < existingIndex) { this.presets.splice(existingIndex, 1, preset); } else { this.presets.push(preset); } } } } configure(config: Required) { this.assetStorage = this.createAssetStorageStrategy(); config.assetOptions.assetPreviewStrategy = new SharpAssetPreviewStrategy({ maxWidth: this.options.previewMaxWidth || 1600, maxHeight: this.options.previewMaxHeight || 1600, }); config.assetOptions.assetStorageStrategy = this.assetStorage; config.middleware.push({ handler: createProxyHandler(this.options, !config.silent), route: this.options.route, }); return config; } onBootstrap(inject: InjectorFn): void | Promise { this.createAssetServer(); } onClose(): Promise { return new Promise(resolve => { this.server.close(() => resolve()); }); } private createAssetStorageStrategy() { const toAbsoluteUrlFn = (request: Request, identifier: string): string => { if (!identifier) { return ''; } return `${request.protocol}://${request.get('host')}/${this.options.route}/${identifier}`; }; return new LocalAssetStorageStrategy(this.options.assetUploadDir, toAbsoluteUrlFn); } /** * Creates the image server instance */ private createAssetServer() { const assetServer = express(); assetServer.use(this.serveStaticFile(), this.generateTransformedImage()); this.server = assetServer.listen(this.options.port); } /** * 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: any, req: Request, res: Response, next: NextFunction) => { if (err && err.status === 404) { if (req.query) { let file: Buffer; try { file = await this.assetStorage.readFileToBuffer(req.path); } catch (err) { res.status(404).send('Resource not found'); return; } const image = await transformImage(file, req.query, this.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.presets && !!this.presets.find(p => p.name === req.query.preset)) { return this.cacheDir + '/' + this.addSuffix(req.path, `_transform_pre_${req.query.preset}`); } } return req.path; } private addSuffix(fileName: string, suffix: string): string { const ext = path.extname(fileName); const baseName = path.basename(fileName, ext); return `${baseName}${suffix}${ext}`; } } export interface ProxyOptions { route: string; port: number; hostname?: string; } /** * Configures the proxy middleware which will be passed to the main Vendure server. This * will proxy all asset requests to the dedicated asset server. */ function createProxyHandler(options: ProxyOptions, logging: boolean) { const route = options.route.charAt(0) === '/' ? options.route : '/' + options.route; const proxyHostname = options.hostname || 'localhost'; return proxy({ // TODO: how do we detect https? target: `http://${proxyHostname}:${options.port}`, pathRewrite: { [`^${route}`]: `/`, }, logLevel: logging ? 'info' : 'silent', }); }