plugin.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. import { AssetStorageStrategy, InjectorFn, 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 { DefaultAssetPreviewStrategy } from './default-asset-preview-strategy';
  7. import { DefaultAssetStorageStrategy } from './default-asset-storage-strategy';
  8. import { transformImage } from './transform-image';
  9. /**
  10. * @description
  11. * Specifies the way in which an asset preview image will be resized to fit in the
  12. * proscribed dimensions:
  13. *
  14. * * crop: crops the image to cover both provided dimensions
  15. * * resize: Preserving aspect ratio, resizes the image to be as large as possible
  16. * while ensuring its dimensions are less than or equal to both those specified.
  17. *
  18. * @docsCategory plugin
  19. */
  20. export type ImageTransformMode = 'crop' | 'resize';
  21. /**
  22. * @description
  23. * A configuration option for an image size preset for the DefaultAssetServerPlugin.
  24. *
  25. * Presets allow a shorthand way to generate a thumbnail preview of an asset. For example,
  26. * the built-in "tiny" preset generates a 50px x 50px cropped preview, which can be accessed
  27. * by appending the string `preset=tiny` to the asset url:
  28. *
  29. * `http://localhost:3000/assets/some-asset.jpg?preset=tiny`
  30. *
  31. * is equivalent to:
  32. *
  33. * `http://localhost:3000/assets/some-asset.jpg?w=50&h=50&mode=crop`
  34. *
  35. * @docsCategory plugin
  36. */
  37. export interface ImageTransformPreset {
  38. name: string;
  39. width: number;
  40. height: number;
  41. mode: ImageTransformMode;
  42. }
  43. /**
  44. * @description
  45. * The configuration options for the DefaultAssetServerPlugin.
  46. *
  47. * @docsCategory plugin
  48. */
  49. export interface DefaultAssetServerOptions {
  50. hostname?: string;
  51. /**
  52. * @description
  53. * The local port that the server will run on. Note that the DefaultAssetServerPlugin
  54. * includes a proxy server which allows the asset server to be accessed on the same
  55. * port as the main Vendure server.
  56. */
  57. port: number;
  58. /**
  59. * @description
  60. * The proxy route to the asset server.
  61. */
  62. route: string;
  63. /**
  64. * @description
  65. * The local directory to which assets will be uploaded.
  66. */
  67. assetUploadDir: string;
  68. /**
  69. * @description
  70. * The max width in pixels of a generated preview image.
  71. *
  72. * @default 1600
  73. */
  74. previewMaxWidth?: number;
  75. /**
  76. * @description
  77. * The max height in pixels of a generated preview image.
  78. *
  79. * @default 1600
  80. */
  81. previewMaxHeight?: number;
  82. /**
  83. * @description
  84. * An array of additional {@link ImageTransformPreset} objects.
  85. */
  86. presets?: ImageTransformPreset[];
  87. }
  88. /**
  89. * The DefaultAssetServerPlugin instantiates a static Express server which is used to
  90. * serve the assets. It can also perform on-the-fly image transformations and caches the
  91. * results for subsequent calls.
  92. */
  93. export class DefaultAssetServerPlugin implements VendurePlugin {
  94. private server: Server;
  95. private assetStorage: AssetStorageStrategy;
  96. private readonly cacheDir = 'cache';
  97. private readonly presets: ImageTransformPreset[] = [
  98. { name: 'tiny', width: 50, height: 50, mode: 'crop' },
  99. { name: 'thumb', width: 150, height: 150, mode: 'crop' },
  100. { name: 'small', width: 300, height: 300, mode: 'resize' },
  101. { name: 'medium', width: 500, height: 500, mode: 'resize' },
  102. { name: 'large', width: 800, height: 800, mode: 'resize' },
  103. ];
  104. constructor(private options: DefaultAssetServerOptions) {
  105. if (options.presets) {
  106. for (const preset of options.presets) {
  107. const existingIndex = this.presets.findIndex(p => p.name === preset.name);
  108. if (-1 < existingIndex) {
  109. this.presets.splice(existingIndex, 1, preset);
  110. } else {
  111. this.presets.push(preset);
  112. }
  113. }
  114. }
  115. }
  116. configure(config: Required<VendureConfig>) {
  117. this.assetStorage = new DefaultAssetStorageStrategy(this.options.assetUploadDir, this.options.route);
  118. config.assetOptions.assetPreviewStrategy = new DefaultAssetPreviewStrategy({
  119. maxWidth: this.options.previewMaxWidth || 1600,
  120. maxHeight: this.options.previewMaxHeight || 1600,
  121. });
  122. config.assetOptions.assetStorageStrategy = this.assetStorage;
  123. config.middleware.push({
  124. handler: createProxyHandler(this.options, !config.silent),
  125. route: this.options.route,
  126. });
  127. return config;
  128. }
  129. onBootstrap(inject: InjectorFn): void | Promise<void> {
  130. this.createAssetServer();
  131. }
  132. onClose(): Promise<void> {
  133. return new Promise(resolve => { this.server.close(() => resolve()); });
  134. }
  135. /**
  136. * Creates the image server instance
  137. */
  138. private createAssetServer() {
  139. const assetServer = express();
  140. assetServer.use(this.serveStaticFile(), this.generateTransformedImage());
  141. this.server = assetServer.listen(this.options.port);
  142. }
  143. /**
  144. * Sends the file requested to the broswer.
  145. */
  146. private serveStaticFile() {
  147. return (req: Request, res: Response) => {
  148. const filePath = path.join(this.options.assetUploadDir, this.getFileNameFromRequest(req));
  149. res.sendFile(filePath);
  150. };
  151. }
  152. /**
  153. * If an exception was thrown by the first handler, then it may be because a transformed image
  154. * is being requested which does not yet exist. In this case, this handler will generate the
  155. * transformed image, save it to cache, and serve the result as a response.
  156. */
  157. private generateTransformedImage() {
  158. return async (err: any, req: Request, res: Response, next: NextFunction) => {
  159. if (err && err.status === 404) {
  160. if (req.query) {
  161. let file: Buffer;
  162. try {
  163. file = await this.assetStorage.readFileToBuffer(req.path);
  164. } catch (err) {
  165. res.status(404).send('Resource not found');
  166. return;
  167. }
  168. const image = await transformImage(file, req.query, this.presets || []);
  169. const imageBuffer = await image.toBuffer();
  170. const cachedFileName = this.getFileNameFromRequest(req);
  171. await this.assetStorage.writeFileFromBuffer(cachedFileName, imageBuffer);
  172. res.set('Content-Type', `image/${(await image.metadata()).format}`);
  173. res.send(imageBuffer);
  174. }
  175. }
  176. next();
  177. };
  178. }
  179. private getFileNameFromRequest(req: Request): string {
  180. if (req.query.w || req.query.h) {
  181. const width = req.query.w || '';
  182. const height = req.query.h || '';
  183. const mode = req.query.mode || '';
  184. return this.cacheDir + '/' + this.addSuffix(req.path, `_transform_w${width}_h${height}_m${mode}`);
  185. } else if (req.query.preset) {
  186. if (this.presets && !!this.presets.find(p => p.name === req.query.preset)) {
  187. return this.cacheDir + '/' + this.addSuffix(req.path, `_transform_pre_${req.query.preset}`);
  188. }
  189. }
  190. return req.path;
  191. }
  192. private addSuffix(fileName: string, suffix: string): string {
  193. const ext = path.extname(fileName);
  194. const baseName = path.basename(fileName, ext);
  195. return `${baseName}${suffix}${ext}`;
  196. }
  197. }
  198. export interface ProxyOptions {
  199. route: string;
  200. port: number;
  201. hostname?: string;
  202. }
  203. /**
  204. * Configures the proxy middleware which will be passed to the main Vendure server. This
  205. * will proxy all asset requests to the dedicated asset server.
  206. */
  207. function createProxyHandler(options: ProxyOptions, logging: boolean) {
  208. const route = options.route.charAt(0) === '/' ? options.route : '/' + options.route;
  209. const proxyHostname = options.hostname || 'localhost';
  210. return proxy({
  211. // TODO: how do we detect https?
  212. target: `http://${proxyHostname}:${options.port}`,
  213. pathRewrite: {
  214. [`^${route}`]: `/`,
  215. },
  216. logLevel: logging ? 'info' : 'silent',
  217. });
  218. }