plugin.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. import { Type } from '@vendure/common/lib/shared-types';
  2. import {
  3. AssetStorageStrategy,
  4. createProxyHandler,
  5. Logger,
  6. OnVendureBootstrap,
  7. OnVendureClose,
  8. RuntimeVendureConfig,
  9. VendurePlugin,
  10. } from '@vendure/core';
  11. import { createHash } from 'crypto';
  12. import express, { NextFunction, Request, Response } from 'express';
  13. import fs from 'fs-extra';
  14. import { Server } from 'http';
  15. import path from 'path';
  16. import { loggerCtx } from './constants';
  17. import { defaultAssetStorageStrategyFactory } from './default-asset-storage-strategy-factory';
  18. import { HashedAssetNamingStrategy } from './hashed-asset-naming-strategy';
  19. import { SharpAssetPreviewStrategy } from './sharp-asset-preview-strategy';
  20. import { transformImage } from './transform-image';
  21. import { AssetServerOptions, ImageTransformPreset } from './types';
  22. /**
  23. * @description
  24. * The `AssetServerPlugin` serves assets (images and other files) from the local file system. It can also perform on-the-fly image transformations
  25. * and caches the results for subsequent calls.
  26. *
  27. * ## Installation
  28. *
  29. * `yarn add \@vendure/asset-server-plugin`
  30. *
  31. * or
  32. *
  33. * `npm install \@vendure/asset-server-plugin`
  34. *
  35. * @example
  36. * ```ts
  37. * import { AssetServerPlugin } from '\@vendure/asset-server-plugin';
  38. *
  39. * const config: VendureConfig = {
  40. * // Add an instance of the plugin to the plugins array
  41. * plugins: [
  42. * AssetServerPlugin.init({
  43. * route: 'assets',
  44. * assetUploadDir: path.join(__dirname, 'assets'),
  45. * port: 4000,
  46. * }),
  47. * ],
  48. * };
  49. * ```
  50. *
  51. * The full configuration is documented at [AssetServerOptions]({{< relref "asset-server-options" >}})
  52. *
  53. * ## Image transformation
  54. *
  55. * Asset preview images can be transformed (resized & cropped) on the fly by appending query parameters to the url:
  56. *
  57. * `http://localhost:3000/assets/some-asset.jpg?w=500&h=300&mode=resize`
  58. *
  59. * The above URL will return `some-asset.jpg`, resized to fit in the bounds of a 500px x 300px rectangle.
  60. *
  61. * ### Preview mode
  62. *
  63. * The `mode` parameter can be either `crop` or `resize`. See the [ImageTransformMode]({{< relref "image-transform-mode" >}}) docs for details.
  64. *
  65. * ### Focal point
  66. *
  67. * When cropping an image (`mode=crop`), Vendure will attempt to keep the most "interesting" area of the image in the cropped frame. It does this
  68. * by finding the area of the image with highest entropy (the busiest area of the image). However, sometimes this does not yield a satisfactory
  69. * result - part or all of the main subject may still be cropped out.
  70. *
  71. * This is where specifying the focal point can help. The focal point of the image may be specified by passing the `fpx` and `fpy` query parameters.
  72. * These are normalized coordinates (i.e. a number between 0 and 1), so the `fpx=0&fpy=0` corresponds to the top left of the image.
  73. *
  74. * For example, let's say there is a very wide landscape image which we want to crop to be square. The main subject is a house to the far left of the
  75. * image. The following query would crop it to a square with the house centered:
  76. *
  77. * `http://localhost:3000/assets/landscape.jpg?w=150&h=150&mode=crop&fpx=0.2&fpy=0.7`
  78. *
  79. * ### Transform presets
  80. *
  81. * Presets can be defined which allow a single preset name to be used instead of specifying the width, height and mode. Presets are
  82. * configured via the AssetServerOptions [presets property]({{< relref "asset-server-options" >}}#presets).
  83. *
  84. * For example, defining the following preset:
  85. *
  86. * ```ts
  87. * new AssetServerPlugin({
  88. * // ...
  89. * presets: [
  90. * { name: 'my-preset', width: 85, height: 85, mode: 'crop' },
  91. * ],
  92. * }),
  93. * ```
  94. *
  95. * means that a request to:
  96. *
  97. * `http://localhost:3000/assets/some-asset.jpg?preset=my-preset`
  98. *
  99. * is equivalent to:
  100. *
  101. * `http://localhost:3000/assets/some-asset.jpg?w=85&h=85&mode=crop`
  102. *
  103. * The AssetServerPlugin comes pre-configured with the following presets:
  104. *
  105. * name | width | height | mode
  106. * -----|-------|--------|-----
  107. * tiny | 50px | 50px | crop
  108. * thumb | 150px | 150px | crop
  109. * small | 300px | 300px | resize
  110. * medium | 500px | 500px | resize
  111. * large | 800px | 800px | resize
  112. *
  113. * ### Caching
  114. * By default, the AssetServerPlugin will cache every transformed image, so that the transformation only needs to be performed a single time for
  115. * a given configuration. Caching can be disabled per-request by setting the `?cache=false` query parameter.
  116. *
  117. * @docsCategory AssetServerPlugin
  118. */
  119. @VendurePlugin({
  120. configuration: config => AssetServerPlugin.configure(config),
  121. })
  122. export class AssetServerPlugin implements OnVendureBootstrap, OnVendureClose {
  123. private server: Server;
  124. private static assetStorage: AssetStorageStrategy;
  125. private readonly cacheDir = 'cache';
  126. private presets: ImageTransformPreset[] = [
  127. { name: 'tiny', width: 50, height: 50, mode: 'crop' },
  128. { name: 'thumb', width: 150, height: 150, mode: 'crop' },
  129. { name: 'small', width: 300, height: 300, mode: 'resize' },
  130. { name: 'medium', width: 500, height: 500, mode: 'resize' },
  131. { name: 'large', width: 800, height: 800, mode: 'resize' },
  132. ];
  133. private static options: AssetServerOptions;
  134. /**
  135. * @description
  136. * Set the plugin options.
  137. */
  138. static init(options: AssetServerOptions): Type<AssetServerPlugin> {
  139. AssetServerPlugin.options = options;
  140. return this;
  141. }
  142. /** @internal */
  143. static async configure(config: RuntimeVendureConfig) {
  144. const storageStrategyFactory =
  145. this.options.storageStrategyFactory || defaultAssetStorageStrategyFactory;
  146. this.assetStorage = await storageStrategyFactory(this.options);
  147. config.assetOptions.assetPreviewStrategy = new SharpAssetPreviewStrategy({
  148. maxWidth: this.options.previewMaxWidth || 1600,
  149. maxHeight: this.options.previewMaxHeight || 1600,
  150. });
  151. config.assetOptions.assetStorageStrategy = this.assetStorage;
  152. config.assetOptions.assetNamingStrategy =
  153. this.options.namingStrategy || new HashedAssetNamingStrategy();
  154. config.apiOptions.middleware.push({
  155. handler: createProxyHandler({ ...this.options, label: 'Asset Server' }),
  156. route: this.options.route,
  157. });
  158. return config;
  159. }
  160. /** @internal */
  161. onVendureBootstrap(): void | Promise<void> {
  162. if (AssetServerPlugin.options.presets) {
  163. for (const preset of AssetServerPlugin.options.presets) {
  164. const existingIndex = this.presets.findIndex(p => p.name === preset.name);
  165. if (-1 < existingIndex) {
  166. this.presets.splice(existingIndex, 1, preset);
  167. } else {
  168. this.presets.push(preset);
  169. }
  170. }
  171. }
  172. const cachePath = path.join(AssetServerPlugin.options.assetUploadDir, this.cacheDir);
  173. fs.ensureDirSync(cachePath);
  174. this.createAssetServer();
  175. }
  176. /** @internal */
  177. onVendureClose(): Promise<void> {
  178. return new Promise(resolve => {
  179. this.server.close(() => resolve());
  180. });
  181. }
  182. /**
  183. * Creates the image server instance
  184. */
  185. private createAssetServer() {
  186. const assetServer = express();
  187. assetServer.use(this.serveStaticFile(), this.generateTransformedImage());
  188. this.server = assetServer.listen(AssetServerPlugin.options.port, () => {
  189. const addressInfo = this.server.address();
  190. if (addressInfo && typeof addressInfo !== 'string') {
  191. const { address, port } = addressInfo;
  192. Logger.info(`Asset server listening on ${address}:${port}`, loggerCtx);
  193. }
  194. });
  195. }
  196. /**
  197. * Sends the file requested to the broswer.
  198. */
  199. private serveStaticFile() {
  200. return (req: Request, res: Response) => {
  201. const filePath = path.join(
  202. AssetServerPlugin.options.assetUploadDir,
  203. this.getFileNameFromRequest(req),
  204. );
  205. res.sendFile(filePath);
  206. };
  207. }
  208. /**
  209. * If an exception was thrown by the first handler, then it may be because a transformed image
  210. * is being requested which does not yet exist. In this case, this handler will generate the
  211. * transformed image, save it to cache, and serve the result as a response.
  212. */
  213. private generateTransformedImage() {
  214. return async (err: any, req: Request, res: Response, next: NextFunction) => {
  215. if (err && err.status === 404) {
  216. if (req.query) {
  217. Logger.debug(`Pre-cached Asset not found: ${req.path}`, loggerCtx);
  218. let file: Buffer;
  219. try {
  220. file = await AssetServerPlugin.assetStorage.readFileToBuffer(req.path);
  221. } catch (err) {
  222. res.status(404).send('Resource not found');
  223. return;
  224. }
  225. const image = await transformImage(file, req.query, this.presets || []);
  226. try {
  227. const imageBuffer = await image.toBuffer();
  228. if (!req.query.cache || req.query.cache === 'true') {
  229. const cachedFileName = this.getFileNameFromRequest(req);
  230. await AssetServerPlugin.assetStorage.writeFileFromBuffer(
  231. cachedFileName,
  232. imageBuffer,
  233. );
  234. Logger.debug(`Saved cached asset: ${cachedFileName}`, loggerCtx);
  235. }
  236. res.set('Content-Type', `image/${(await image.metadata()).format}`);
  237. res.send(imageBuffer);
  238. } catch (e) {
  239. Logger.error(e, 'AssetServerPlugin', e.stack);
  240. res.status(500).send(e.message);
  241. }
  242. }
  243. }
  244. next();
  245. };
  246. }
  247. private getFileNameFromRequest(req: Request): string {
  248. const { w, h, mode, preset, fpx, fpy } = req.query;
  249. const focalPoint = fpx && fpy ? `_fpx${fpx}_fpy${fpy}` : '';
  250. let imageParamHash: string | null = null;
  251. if (w || h) {
  252. const width = w || '';
  253. const height = h || '';
  254. imageParamHash = this.md5(`_transform_w${width}_h${height}_m${mode}${focalPoint}`);
  255. } else if (preset) {
  256. if (this.presets && !!this.presets.find(p => p.name === preset)) {
  257. imageParamHash = this.md5(`_transform_pre_${preset}${focalPoint}`);
  258. }
  259. }
  260. if (imageParamHash) {
  261. return path.join(this.cacheDir, this.addSuffix(req.path, imageParamHash));
  262. } else {
  263. return req.path;
  264. }
  265. }
  266. private md5(input: string): string {
  267. return createHash('md5')
  268. .update(input)
  269. .digest('hex');
  270. }
  271. private addSuffix(fileName: string, suffix: string): string {
  272. const ext = path.extname(fileName);
  273. const baseName = path.basename(fileName, ext);
  274. const dirName = path.dirname(fileName);
  275. return path.join(dirName, `${baseName}${suffix}${ext}`);
  276. }
  277. }