plugin.ts 11 KB

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