plugin.ts 10 KB

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