dashboard.plugin.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. import { MiddlewareConsumer, NestModule } from '@nestjs/common';
  2. import { Type } from '@vendure/common/lib/shared-types';
  3. import {
  4. createProxyHandler,
  5. Logger,
  6. PluginCommonModule,
  7. ProcessContext,
  8. registerPluginStartupMessage,
  9. SettingsStoreScopes,
  10. VendurePlugin,
  11. } from '@vendure/core';
  12. import express from 'express';
  13. import { rateLimit } from 'express-rate-limit';
  14. import * as fs from 'node:fs';
  15. import * as http from 'node:http';
  16. import * as path from 'node:path';
  17. import { adminApiExtensions } from './api/api-extensions.js';
  18. import { MetricsResolver } from './api/metrics.resolver.js';
  19. import { loggerCtx, manageDashboardGlobalViews } from './constants.js';
  20. import { MetricsService } from './service/metrics.service.js';
  21. /**
  22. * @description
  23. * Configuration options for the {@link DashboardPlugin}.
  24. *
  25. * @docsCategory core plugins/DashboardPlugin
  26. */
  27. export interface DashboardPluginOptions {
  28. /**
  29. * @description
  30. * The route to the Dashboard UI.
  31. *
  32. * @default 'dashboard'
  33. */
  34. route: string;
  35. /**
  36. * @description
  37. * The path to the dashboard UI app dist directory.
  38. */
  39. appDir: string;
  40. /**
  41. * @description
  42. * The port on which to check for a running Vite dev server.
  43. * If a Vite dev server is detected on this port, requests will be proxied to it
  44. * instead of serving static files from appDir.
  45. *
  46. * @default 5173
  47. */
  48. viteDevServerPort?: number;
  49. }
  50. /**
  51. * @description
  52. * This plugin serves the static files of the Vendure Dashboard and provides the
  53. * GraphQL extensions needed for the order metrics on the dashboard index page.
  54. *
  55. * ## Installation
  56. *
  57. * `npm install \@vendure/dashboard`
  58. *
  59. * ## Usage
  60. *
  61. * First you need to set up compilation of the Dashboard, using the Vite configuration
  62. * described in the [Dashboard Getting Started Guide](/guides/extending-the-dashboard/getting-started/)
  63. *
  64. * ## Development vs Production
  65. *
  66. * When developing, you can run `npx vite` (or `npm run dev`) to start the Vite development server.
  67. * The plugin will automatically detect if Vite is running on the default port (5173) and proxy
  68. * requests to it instead of serving static files. This enables hot module replacement and faster
  69. * development iterations.
  70. *
  71. * For production, run `npx vite build` to build the dashboard app. The built app files will be
  72. * output to the location specified by `build.outDir` in your Vite config file. This should then
  73. * be passed to the `appDir` init option, as in the example below:
  74. *
  75. * @example
  76. * ```ts
  77. * import { DashboardPlugin } from '\@vendure/dashboard/plugin';
  78. *
  79. * const config: VendureConfig = {
  80. * // Add an instance of the plugin to the plugins array
  81. * plugins: [
  82. * DashboardPlugin.init({
  83. * route: 'dashboard',
  84. * appDir: './dist/dashboard',
  85. * // Optional: customize Vite dev server port (defaults to 5173)
  86. * viteDevServerPort: 3000,
  87. * }),
  88. * ],
  89. * };
  90. * ```
  91. *
  92. * ## Metrics
  93. *
  94. * This plugin defines a `metricSummary` query which is used by the Dashboard UI to
  95. * display the order metrics on the dashboard.
  96. *
  97. * If you are building a stand-alone version of the Dashboard UI app, and therefore
  98. * don't need this plugin to serve the Dashboard UI, you can still use the
  99. * `metricSummary` query by adding the `DashboardPlugin` to the `plugins` array,
  100. * but without calling the `init()` method:
  101. *
  102. * @example
  103. * ```ts
  104. * import { DashboardPlugin } from '\@vendure/dashboard-plugin';
  105. *
  106. * const config: VendureConfig = {
  107. * plugins: [
  108. * DashboardPlugin, // <-- no call to .init()
  109. * ],
  110. * // ...
  111. * };
  112. * ```
  113. *
  114. * @docsCategory core plugins/DashboardPlugin
  115. */
  116. @VendurePlugin({
  117. imports: [PluginCommonModule],
  118. adminApiExtensions: {
  119. schema: adminApiExtensions,
  120. resolvers: [MetricsResolver],
  121. },
  122. providers: [MetricsService],
  123. configuration: config => {
  124. config.authOptions.customPermissions.push(manageDashboardGlobalViews);
  125. config.settingsStoreFields['vendure.dashboard'] = [
  126. {
  127. name: 'userSettings',
  128. scope: SettingsStoreScopes.user,
  129. },
  130. {
  131. name: 'globalSavedViews',
  132. scope: SettingsStoreScopes.global,
  133. requiresPermission: {
  134. read: manageDashboardGlobalViews.Read,
  135. write: manageDashboardGlobalViews.Write,
  136. },
  137. },
  138. {
  139. name: 'userSavedViews',
  140. scope: SettingsStoreScopes.user,
  141. },
  142. ];
  143. return config;
  144. },
  145. compatibility: '^3.0.0',
  146. })
  147. export class DashboardPlugin implements NestModule {
  148. private static options: DashboardPluginOptions | undefined;
  149. private readonly rateLimitRequests = process.env.NODE_ENV === 'production' ? 500 : 100_000;
  150. constructor(private readonly processContext: ProcessContext) {}
  151. /**
  152. * @description
  153. * Set the plugin options
  154. */
  155. static init(options: DashboardPluginOptions): Type<DashboardPlugin> {
  156. this.options = options;
  157. return DashboardPlugin;
  158. }
  159. configure(consumer: MiddlewareConsumer) {
  160. if (this.processContext.isWorker) {
  161. return;
  162. }
  163. if (!DashboardPlugin.options) {
  164. Logger.warn(
  165. `DashboardPlugin's init() method was not called. The Dashboard UI will not be served.`,
  166. loggerCtx,
  167. );
  168. return;
  169. }
  170. const { route, appDir, viteDevServerPort = 5173 } = DashboardPlugin.options;
  171. Logger.verbose('Creating dashboard middleware', loggerCtx);
  172. consumer.apply(this.createDynamicHandler(route, appDir, viteDevServerPort)).forRoutes(route);
  173. registerPluginStartupMessage('Dashboard UI', route);
  174. }
  175. private createStaticServer(dashboardPath: string) {
  176. const limiter = rateLimit({
  177. windowMs: 60 * 1000,
  178. limit: this.rateLimitRequests,
  179. standardHeaders: true,
  180. legacyHeaders: false,
  181. });
  182. const dashboardServer = express.Router();
  183. dashboardServer.use(limiter);
  184. dashboardServer.use(express.static(dashboardPath));
  185. dashboardServer.use((req, res) => {
  186. res.sendFile('index.html', { root: dashboardPath });
  187. });
  188. return dashboardServer;
  189. }
  190. private async checkViteDevServer(port: number): Promise<boolean> {
  191. return new Promise(resolve => {
  192. const req = http.request(
  193. {
  194. hostname: 'localhost',
  195. port,
  196. path: '/',
  197. method: 'HEAD',
  198. timeout: 1000,
  199. },
  200. (res: http.IncomingMessage) => {
  201. resolve(res.statusCode !== undefined && res.statusCode < 400);
  202. },
  203. );
  204. req.on('error', () => {
  205. resolve(false);
  206. });
  207. req.on('timeout', () => {
  208. req.destroy();
  209. resolve(false);
  210. });
  211. req.end();
  212. });
  213. }
  214. private checkBuiltFiles(appDir: string): boolean {
  215. try {
  216. const indexPath = path.join(appDir, 'index.html');
  217. return fs.existsSync(indexPath);
  218. } catch {
  219. return false;
  220. }
  221. }
  222. private createDefaultPage() {
  223. const limiter = rateLimit({
  224. windowMs: 60 * 1000,
  225. limit: this.rateLimitRequests,
  226. standardHeaders: true,
  227. legacyHeaders: false,
  228. });
  229. const defaultPageServer = express.Router();
  230. defaultPageServer.use(limiter);
  231. defaultPageServer.use((req, res) => {
  232. try {
  233. const htmlPath = path.join(__dirname, 'default-page.html');
  234. const defaultHtml = fs.readFileSync(htmlPath, 'utf8');
  235. res.setHeader('Content-Type', 'text/html');
  236. res.send(defaultHtml);
  237. } catch (error) {
  238. res.status(500).send(
  239. `Unable to load default page: ${error instanceof Error ? error.message : String(error)}`,
  240. );
  241. }
  242. });
  243. return defaultPageServer;
  244. }
  245. private createDynamicHandler(route: string, appDir: string, viteDevServerPort: number) {
  246. const limiter = rateLimit({
  247. windowMs: 60 * 1000,
  248. limit: this.rateLimitRequests,
  249. standardHeaders: true,
  250. legacyHeaders: false,
  251. });
  252. // Pre-create handlers to avoid recreating them on each request
  253. const proxyHandler = createProxyHandler({
  254. label: 'Dashboard Vite Dev Server',
  255. route,
  256. port: viteDevServerPort,
  257. basePath: route,
  258. });
  259. const staticServer = this.createStaticServer(appDir);
  260. const defaultPage = this.createDefaultPage();
  261. // Track current mode to log changes
  262. let currentMode: 'vite' | 'built' | 'default' | null = null;
  263. const dynamicRouter = express.Router();
  264. dynamicRouter.use(limiter);
  265. // Add status endpoint for polling (localhost only for security)
  266. dynamicRouter.get(
  267. '/__status',
  268. (req, res, next) => {
  269. const clientIp = req.ip || req.connection.remoteAddress || req.socket.remoteAddress;
  270. const isLocalhost =
  271. clientIp === '127.0.0.1' ||
  272. clientIp === '::1' ||
  273. clientIp === '::ffff:127.0.0.1' ||
  274. clientIp === 'localhost';
  275. if (!isLocalhost) {
  276. return res.status(403).json({ error: 'Access denied' });
  277. }
  278. next();
  279. },
  280. async (req, res) => {
  281. try {
  282. const isViteRunning = await this.checkViteDevServer(viteDevServerPort);
  283. const hasBuiltFiles = this.checkBuiltFiles(appDir);
  284. const mode = isViteRunning ? 'vite' : hasBuiltFiles ? 'built' : 'default';
  285. res.json({
  286. viteRunning: isViteRunning,
  287. hasBuiltFiles,
  288. mode,
  289. });
  290. } catch (error) {
  291. res.status(500).json({
  292. error: `Status check failed: ${error instanceof Error ? error.message : String(error)}`,
  293. });
  294. }
  295. },
  296. );
  297. dynamicRouter.use(async (req, res, next) => {
  298. try {
  299. // Check for Vite dev server first (highest priority)
  300. const isViteRunning = await this.checkViteDevServer(viteDevServerPort);
  301. const hasBuiltFiles = this.checkBuiltFiles(appDir);
  302. // Determine new mode
  303. const staticMode = hasBuiltFiles ? 'built' : 'default';
  304. const newMode: 'vite' | 'built' | 'default' = isViteRunning ? 'vite' : staticMode;
  305. // Log mode change
  306. if (currentMode !== newMode) {
  307. const modeDescriptions = {
  308. vite: 'Vite dev server (with HMR)',
  309. built: 'built static files',
  310. default: 'default getting-started page',
  311. };
  312. if (currentMode !== null) {
  313. Logger.info(
  314. `Dashboard mode changed: ${currentMode} → ${newMode} (serving ${modeDescriptions[newMode]})`,
  315. loggerCtx,
  316. );
  317. }
  318. currentMode = newMode;
  319. }
  320. // Route to appropriate handler
  321. if (isViteRunning) {
  322. return proxyHandler(req, res, next);
  323. }
  324. if (hasBuiltFiles) {
  325. return staticServer(req, res, next);
  326. }
  327. // Fall back to default page
  328. return defaultPage(req, res, next);
  329. } catch (error) {
  330. Logger.error(`Dashboard dynamic handler error: ${String(error)}`, loggerCtx);
  331. res.status(500).send('Dashboard unavailable');
  332. }
  333. });
  334. return dynamicRouter;
  335. }
  336. }