| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374 |
- import { MiddlewareConsumer, NestModule } from '@nestjs/common';
- import { Type } from '@vendure/common/lib/shared-types';
- import {
- createProxyHandler,
- Logger,
- PluginCommonModule,
- ProcessContext,
- registerPluginStartupMessage,
- SettingsStoreScopes,
- VendurePlugin,
- } from '@vendure/core';
- import express from 'express';
- import { rateLimit } from 'express-rate-limit';
- import * as fs from 'node:fs';
- import * as http from 'node:http';
- import * as path from 'node:path';
- import { adminApiExtensions } from './api/api-extensions.js';
- import { MetricsResolver } from './api/metrics.resolver.js';
- import { loggerCtx, manageDashboardGlobalViews } from './constants.js';
- import { MetricsService } from './service/metrics.service.js';
- /**
- * @description
- * Configuration options for the {@link DashboardPlugin}.
- *
- * @docsCategory core plugins/DashboardPlugin
- */
- export interface DashboardPluginOptions {
- /**
- * @description
- * The route to the Dashboard UI.
- *
- * @default 'dashboard'
- */
- route: string;
- /**
- * @description
- * The path to the dashboard UI app dist directory.
- */
- appDir: string;
- /**
- * @description
- * The port on which to check for a running Vite dev server.
- * If a Vite dev server is detected on this port, requests will be proxied to it
- * instead of serving static files from appDir.
- *
- * @default 5173
- */
- viteDevServerPort?: number;
- }
- /**
- * @description
- * This plugin serves the static files of the Vendure Dashboard and provides the
- * GraphQL extensions needed for the order metrics on the dashboard index page.
- *
- * ## Installation
- *
- * `npm install \@vendure/dashboard`
- *
- * ## Usage
- *
- * First you need to set up compilation of the Dashboard, using the Vite configuration
- * described in the [Dashboard Getting Started Guide](/guides/extending-the-dashboard/getting-started/)
- *
- * ## Development vs Production
- *
- * When developing, you can run `npx vite` (or `npm run dev`) to start the Vite development server.
- * The plugin will automatically detect if Vite is running on the default port (5173) and proxy
- * requests to it instead of serving static files. This enables hot module replacement and faster
- * development iterations.
- *
- * For production, run `npx vite build` to build the dashboard app. The built app files will be
- * output to the location specified by `build.outDir` in your Vite config file. This should then
- * be passed to the `appDir` init option, as in the example below:
- *
- * @example
- * ```ts
- * import { DashboardPlugin } from '\@vendure/dashboard/plugin';
- *
- * const config: VendureConfig = {
- * // Add an instance of the plugin to the plugins array
- * plugins: [
- * DashboardPlugin.init({
- * route: 'dashboard',
- * appDir: './dist/dashboard',
- * // Optional: customize Vite dev server port (defaults to 5173)
- * viteDevServerPort: 3000,
- * }),
- * ],
- * };
- * ```
- *
- * ## Metrics
- *
- * This plugin defines a `metricSummary` query which is used by the Dashboard UI to
- * display the order metrics on the dashboard.
- *
- * If you are building a stand-alone version of the Dashboard UI app, and therefore
- * don't need this plugin to serve the Dashboard UI, you can still use the
- * `metricSummary` query by adding the `DashboardPlugin` to the `plugins` array,
- * but without calling the `init()` method:
- *
- * @example
- * ```ts
- * import { DashboardPlugin } from '\@vendure/dashboard-plugin';
- *
- * const config: VendureConfig = {
- * plugins: [
- * DashboardPlugin, // <-- no call to .init()
- * ],
- * // ...
- * };
- * ```
- *
- * @docsCategory core plugins/DashboardPlugin
- */
- @VendurePlugin({
- imports: [PluginCommonModule],
- adminApiExtensions: {
- schema: adminApiExtensions,
- resolvers: [MetricsResolver],
- },
- providers: [MetricsService],
- configuration: config => {
- config.authOptions.customPermissions.push(manageDashboardGlobalViews);
- config.settingsStoreFields['vendure.dashboard'] = [
- {
- name: 'userSettings',
- scope: SettingsStoreScopes.user,
- },
- {
- name: 'globalSavedViews',
- scope: SettingsStoreScopes.global,
- requiresPermission: {
- read: manageDashboardGlobalViews.Read,
- write: manageDashboardGlobalViews.Write,
- },
- },
- {
- name: 'userSavedViews',
- scope: SettingsStoreScopes.user,
- },
- ];
- return config;
- },
- compatibility: '^3.0.0',
- })
- export class DashboardPlugin implements NestModule {
- private static options: DashboardPluginOptions | undefined;
- private readonly rateLimitRequests = process.env.NODE_ENV === 'production' ? 500 : 100_000;
- constructor(private readonly processContext: ProcessContext) {}
- /**
- * @description
- * Set the plugin options
- */
- static init(options: DashboardPluginOptions): Type<DashboardPlugin> {
- this.options = options;
- return DashboardPlugin;
- }
- configure(consumer: MiddlewareConsumer) {
- if (this.processContext.isWorker) {
- return;
- }
- if (!DashboardPlugin.options) {
- Logger.warn(
- `DashboardPlugin's init() method was not called. The Dashboard UI will not be served.`,
- loggerCtx,
- );
- return;
- }
- const { route, appDir, viteDevServerPort = 5173 } = DashboardPlugin.options;
- Logger.verbose('Creating dashboard middleware', loggerCtx);
- consumer.apply(this.createDynamicHandler(route, appDir, viteDevServerPort)).forRoutes(route);
- registerPluginStartupMessage('Dashboard UI', route);
- }
- private createStaticServer(dashboardPath: string) {
- const limiter = rateLimit({
- windowMs: 60 * 1000,
- limit: this.rateLimitRequests,
- standardHeaders: true,
- legacyHeaders: false,
- });
- const dashboardServer = express.Router();
- dashboardServer.use(limiter);
- dashboardServer.use(express.static(dashboardPath));
- dashboardServer.use((req, res) => {
- res.sendFile('index.html', { root: dashboardPath });
- });
- return dashboardServer;
- }
- private async checkViteDevServer(port: number): Promise<boolean> {
- return new Promise(resolve => {
- const req = http.request(
- {
- hostname: 'localhost',
- port,
- path: '/',
- method: 'HEAD',
- timeout: 1000,
- },
- (res: http.IncomingMessage) => {
- resolve(res.statusCode !== undefined && res.statusCode < 400);
- },
- );
- req.on('error', () => {
- resolve(false);
- });
- req.on('timeout', () => {
- req.destroy();
- resolve(false);
- });
- req.end();
- });
- }
- private checkBuiltFiles(appDir: string): boolean {
- try {
- const indexPath = path.join(appDir, 'index.html');
- return fs.existsSync(indexPath);
- } catch {
- return false;
- }
- }
- private createDefaultPage() {
- const limiter = rateLimit({
- windowMs: 60 * 1000,
- limit: this.rateLimitRequests,
- standardHeaders: true,
- legacyHeaders: false,
- });
- const defaultPageServer = express.Router();
- defaultPageServer.use(limiter);
- defaultPageServer.use((req, res) => {
- try {
- const htmlPath = path.join(__dirname, 'default-page.html');
- const defaultHtml = fs.readFileSync(htmlPath, 'utf8');
- res.setHeader('Content-Type', 'text/html');
- res.send(defaultHtml);
- } catch (error) {
- res.status(500).send(
- `Unable to load default page: ${error instanceof Error ? error.message : String(error)}`,
- );
- }
- });
- return defaultPageServer;
- }
- private createDynamicHandler(route: string, appDir: string, viteDevServerPort: number) {
- const limiter = rateLimit({
- windowMs: 60 * 1000,
- limit: this.rateLimitRequests,
- standardHeaders: true,
- legacyHeaders: false,
- });
- // Pre-create handlers to avoid recreating them on each request
- const proxyHandler = createProxyHandler({
- label: 'Dashboard Vite Dev Server',
- route,
- port: viteDevServerPort,
- basePath: route,
- });
- const staticServer = this.createStaticServer(appDir);
- const defaultPage = this.createDefaultPage();
- // Track current mode to log changes
- let currentMode: 'vite' | 'built' | 'default' | null = null;
- const dynamicRouter = express.Router();
- dynamicRouter.use(limiter);
- // Add status endpoint for polling (localhost only for security)
- dynamicRouter.get(
- '/__status',
- (req, res, next) => {
- const clientIp = req.ip || req.connection.remoteAddress || req.socket.remoteAddress;
- const isLocalhost =
- clientIp === '127.0.0.1' ||
- clientIp === '::1' ||
- clientIp === '::ffff:127.0.0.1' ||
- clientIp === 'localhost';
- if (!isLocalhost) {
- return res.status(403).json({ error: 'Access denied' });
- }
- next();
- },
- async (req, res) => {
- try {
- const isViteRunning = await this.checkViteDevServer(viteDevServerPort);
- const hasBuiltFiles = this.checkBuiltFiles(appDir);
- const mode = isViteRunning ? 'vite' : hasBuiltFiles ? 'built' : 'default';
- res.json({
- viteRunning: isViteRunning,
- hasBuiltFiles,
- mode,
- });
- } catch (error) {
- res.status(500).json({
- error: `Status check failed: ${error instanceof Error ? error.message : String(error)}`,
- });
- }
- },
- );
- dynamicRouter.use(async (req, res, next) => {
- try {
- // Check for Vite dev server first (highest priority)
- const isViteRunning = await this.checkViteDevServer(viteDevServerPort);
- const hasBuiltFiles = this.checkBuiltFiles(appDir);
- // Determine new mode
- const staticMode = hasBuiltFiles ? 'built' : 'default';
- const newMode: 'vite' | 'built' | 'default' = isViteRunning ? 'vite' : staticMode;
- // Log mode change
- if (currentMode !== newMode) {
- const modeDescriptions = {
- vite: 'Vite dev server (with HMR)',
- built: 'built static files',
- default: 'default getting-started page',
- };
- if (currentMode !== null) {
- Logger.info(
- `Dashboard mode changed: ${currentMode} → ${newMode} (serving ${modeDescriptions[newMode]})`,
- loggerCtx,
- );
- }
- currentMode = newMode;
- }
- // Route to appropriate handler
- if (isViteRunning) {
- return proxyHandler(req, res, next);
- }
- if (hasBuiltFiles) {
- return staticServer(req, res, next);
- }
- // Fall back to default page
- return defaultPage(req, res, next);
- } catch (error) {
- Logger.error(`Dashboard dynamic handler error: ${String(error)}`, loggerCtx);
- res.status(500).send('Dashboard unavailable');
- }
- });
- return dynamicRouter;
- }
- }
|