plugin.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. import { AssetStorageStrategy, createProxyHandler, InjectorFn, LocalAssetStorageStrategy, VendureConfig, VendurePlugin } from '@vendure/core';
  2. import express, { NextFunction, Request, Response } from 'express';
  3. import { Server } from 'http';
  4. import path from 'path';
  5. import { SharpAssetPreviewStrategy } from './sharp-asset-preview-strategy';
  6. import { transformImage } from './transform-image';
  7. /**
  8. * @description
  9. * Specifies the way in which an asset preview image will be resized to fit in the
  10. * proscribed dimensions:
  11. *
  12. * * crop: crops the image to cover both provided dimensions
  13. * * resize: Preserving aspect ratio, resizes the image to be as large as possible
  14. * while ensuring its dimensions are less than or equal to both those specified.
  15. *
  16. * @docsCategory AssetServerPlugin
  17. */
  18. export type ImageTransformMode = 'crop' | 'resize';
  19. /**
  20. * @description
  21. * A configuration option for an image size preset for the AssetServerPlugin.
  22. *
  23. * Presets allow a shorthand way to generate a thumbnail preview of an asset. For example,
  24. * the built-in "tiny" preset generates a 50px x 50px cropped preview, which can be accessed
  25. * by appending the string `preset=tiny` to the asset url:
  26. *
  27. * `http://localhost:3000/assets/some-asset.jpg?preset=tiny`
  28. *
  29. * is equivalent to:
  30. *
  31. * `http://localhost:3000/assets/some-asset.jpg?w=50&h=50&mode=crop`
  32. *
  33. * @docsCategory AssetServerPlugin
  34. */
  35. export interface ImageTransformPreset {
  36. name: string;
  37. width: number;
  38. height: number;
  39. mode: ImageTransformMode;
  40. }
  41. /**
  42. * @description
  43. * The configuration options for the AssetServerPlugin.
  44. *
  45. * @docsCategory AssetServerPlugin
  46. */
  47. export interface AssetServerOptions {
  48. hostname?: string;
  49. /**
  50. * @description
  51. * The local port that the server will run on. Note that the AssetServerPlugin
  52. * includes a proxy server which allows the asset server to be accessed on the same
  53. * port as the main Vendure server.
  54. */
  55. port: number;
  56. /**
  57. * @description
  58. * The proxy route to the asset server.
  59. */
  60. route: string;
  61. /**
  62. * @description
  63. * The local directory to which assets will be uploaded.
  64. */
  65. assetUploadDir: string;
  66. /**
  67. * @description
  68. * The complete URL prefix of the asset files. For example, "https://demo.vendure.io/assets/"
  69. *
  70. * If not provided, the plugin will attempt to guess based off the incoming
  71. * request and the configured route. However, in all but the simplest cases,
  72. * this guess may not yield correct results.
  73. */
  74. assetUrlPrefix?: string;
  75. /**
  76. * @description
  77. * The max width in pixels of a generated preview image.
  78. *
  79. * @default 1600
  80. */
  81. previewMaxWidth?: number;
  82. /**
  83. * @description
  84. * The max height in pixels of a generated preview image.
  85. *
  86. * @default 1600
  87. */
  88. previewMaxHeight?: number;
  89. /**
  90. * @description
  91. * An array of additional {@link ImageTransformPreset} objects.
  92. */
  93. presets?: ImageTransformPreset[];
  94. }
  95. /**
  96. * @description
  97. * The `AssetServerPlugin` serves assets (images and other files) from the local file system. It can also perform on-the-fly image transformations
  98. * and caches the results for subsequent calls.
  99. *
  100. * ## Installation
  101. *
  102. * `yarn add \@vendure/asset-server-plugin`
  103. *
  104. * or
  105. *
  106. * `npm install \@vendure/asset-server-plugin`
  107. *
  108. * @example
  109. * ```ts
  110. * import { AssetServerPlugin } from '\@vendure/asset-server-plugin';
  111. *
  112. * const config: VendureConfig = {
  113. * // Add an instance of the plugin to the plugins array
  114. * plugins: [
  115. * new AssetServerPlugin({
  116. * route: 'assets',
  117. * assetUploadDir: path.join(__dirname, 'assets'),
  118. * port: 4000,
  119. * }),
  120. * ],
  121. * };
  122. * ```
  123. *
  124. * The full configuration is documented at [AssetServerOptions]({{< relref "asset-server-options" >}})
  125. *
  126. * ## Image transformation
  127. *
  128. * Asset preview images can be transformed (resized & cropped) on the fly by appending query parameters to the url:
  129. *
  130. * `http://localhost:3000/assets/some-asset.jpg?w=500&h=300&mode=resize`
  131. *
  132. * The above URL will return `some-asset.jpg`, resized to fit in the bounds of a 500px x 300px rectangle.
  133. *
  134. * ### Preview mode
  135. *
  136. * The `mode` parameter can be either `crop` or `resize`. See the [ImageTransformMode]({{< relref "image-transform-mode" >}}) docs for details.
  137. *
  138. * ### Transform presets
  139. *
  140. * Presets can be defined which allow a single preset name to be used instead of specifying the width, height and mode. Presets are
  141. * configured via the AssetServerOptions [presets property]({{< relref "asset-server-options" >}}#presets).
  142. *
  143. * For example, defining the following preset:
  144. *
  145. * ```ts
  146. * new AssetServerPlugin({
  147. * // ...
  148. * presets: [
  149. * { name: 'my-preset', width: 85, height: 85, mode: 'crop' },
  150. * ],
  151. * }),
  152. * ```
  153. *
  154. * means that a request to:
  155. *
  156. * `http://localhost:3000/assets/some-asset.jpg?preset=my-preset`
  157. *
  158. * is equivalent to:
  159. *
  160. * `http://localhost:3000/assets/some-asset.jpg?w=85&h=85&mode=crop`
  161. *
  162. * The AssetServerPlugin comes pre-configured with the following presets:
  163. *
  164. * name | width | height | mode
  165. * -----|-------|--------|-----
  166. * tiny | 50px | 50px | crop
  167. * thumb | 150px | 150px | crop
  168. * small | 300px | 300px | resize
  169. * medium | 500px | 500px | resize
  170. * large | 800px | 800px | resize
  171. *
  172. * @docsCategory AssetServerPlugin
  173. */
  174. export class AssetServerPlugin implements VendurePlugin {
  175. private server: Server;
  176. private assetStorage: AssetStorageStrategy;
  177. private readonly cacheDir = 'cache';
  178. private readonly presets: ImageTransformPreset[] = [
  179. { name: 'tiny', width: 50, height: 50, mode: 'crop' },
  180. { name: 'thumb', width: 150, height: 150, mode: 'crop' },
  181. { name: 'small', width: 300, height: 300, mode: 'resize' },
  182. { name: 'medium', width: 500, height: 500, mode: 'resize' },
  183. { name: 'large', width: 800, height: 800, mode: 'resize' },
  184. ];
  185. constructor(private options: AssetServerOptions) {
  186. if (options.presets) {
  187. for (const preset of options.presets) {
  188. const existingIndex = this.presets.findIndex(p => p.name === preset.name);
  189. if (-1 < existingIndex) {
  190. this.presets.splice(existingIndex, 1, preset);
  191. } else {
  192. this.presets.push(preset);
  193. }
  194. }
  195. }
  196. }
  197. /** @internal */
  198. configure(config: Required<VendureConfig>) {
  199. this.assetStorage = this.createAssetStorageStrategy();
  200. config.assetOptions.assetPreviewStrategy = new SharpAssetPreviewStrategy({
  201. maxWidth: this.options.previewMaxWidth || 1600,
  202. maxHeight: this.options.previewMaxHeight || 1600,
  203. });
  204. config.assetOptions.assetStorageStrategy = this.assetStorage;
  205. config.middleware.push({
  206. handler: createProxyHandler({ ...this.options, label: 'Asset Server' }),
  207. route: this.options.route,
  208. });
  209. return config;
  210. }
  211. /** @internal */
  212. onBootstrap(inject: InjectorFn): void | Promise<void> {
  213. this.createAssetServer();
  214. }
  215. /** @internal */
  216. onClose(): Promise<void> {
  217. return new Promise(resolve => {
  218. this.server.close(() => resolve());
  219. });
  220. }
  221. private createAssetStorageStrategy() {
  222. const toAbsoluteUrlFn = (request: Request, identifier: string): string => {
  223. if (!identifier) {
  224. return '';
  225. }
  226. const prefix = this.options.assetUrlPrefix || `${request.protocol}://${request.get('host')}/${this.options.route}/`;
  227. return identifier.startsWith(prefix) ? identifier : `${prefix}${identifier}`;
  228. };
  229. return new LocalAssetStorageStrategy(this.options.assetUploadDir, toAbsoluteUrlFn);
  230. }
  231. /**
  232. * Creates the image server instance
  233. */
  234. private createAssetServer() {
  235. const assetServer = express();
  236. assetServer.use(this.serveStaticFile(), this.generateTransformedImage());
  237. this.server = assetServer.listen(this.options.port);
  238. }
  239. /**
  240. * Sends the file requested to the broswer.
  241. */
  242. private serveStaticFile() {
  243. return (req: Request, res: Response) => {
  244. const filePath = path.join(this.options.assetUploadDir, this.getFileNameFromRequest(req));
  245. res.sendFile(filePath);
  246. };
  247. }
  248. /**
  249. * If an exception was thrown by the first handler, then it may be because a transformed image
  250. * is being requested which does not yet exist. In this case, this handler will generate the
  251. * transformed image, save it to cache, and serve the result as a response.
  252. */
  253. private generateTransformedImage() {
  254. return async (err: any, req: Request, res: Response, next: NextFunction) => {
  255. if (err && err.status === 404) {
  256. if (req.query) {
  257. let file: Buffer;
  258. try {
  259. file = await this.assetStorage.readFileToBuffer(req.path);
  260. } catch (err) {
  261. res.status(404).send('Resource not found');
  262. return;
  263. }
  264. const image = await transformImage(file, req.query, this.presets || []);
  265. const imageBuffer = await image.toBuffer();
  266. const cachedFileName = this.getFileNameFromRequest(req);
  267. await this.assetStorage.writeFileFromBuffer(cachedFileName, imageBuffer);
  268. res.set('Content-Type', `image/${(await image.metadata()).format}`);
  269. res.send(imageBuffer);
  270. }
  271. }
  272. next();
  273. };
  274. }
  275. private getFileNameFromRequest(req: Request): string {
  276. if (req.query.w || req.query.h) {
  277. const width = req.query.w || '';
  278. const height = req.query.h || '';
  279. const mode = req.query.mode || '';
  280. return this.cacheDir + '/' + this.addSuffix(req.path, `_transform_w${width}_h${height}_m${mode}`);
  281. } else if (req.query.preset) {
  282. if (this.presets && !!this.presets.find(p => p.name === req.query.preset)) {
  283. return this.cacheDir + '/' + this.addSuffix(req.path, `_transform_pre_${req.query.preset}`);
  284. }
  285. }
  286. return req.path;
  287. }
  288. private addSuffix(fileName: string, suffix: string): string {
  289. const ext = path.extname(fileName);
  290. const baseName = path.basename(fileName, ext);
  291. return `${baseName}${suffix}${ext}`;
  292. }
  293. }