Browse Source

feat(admin-ui-plugin): Add watch mode for UI extension development

Relates to #55
Michael Bromley 6 years ago
parent
commit
c0b4d3fd72

+ 4 - 0
packages/admin-ui-plugin/src/constants.ts

@@ -0,0 +1,4 @@
+import path from 'path';
+
+export const UI_PATH = path.join(__dirname, '../admin-ui');
+export const loggerCtx = 'AdminUiPlugin';

+ 56 - 16
packages/admin-ui-plugin/src/plugin.ts

@@ -1,3 +1,4 @@
+import { Watcher } from '@vendure/admin-ui/devkit/watch';
 import { DEFAULT_AUTH_TOKEN_HEADER_KEY } from '@vendure/common/lib/shared-constants';
 import { AdminUiConfig, AdminUiExtension, Type } from '@vendure/common/lib/shared-types';
 import {
@@ -16,6 +17,7 @@ import fs from 'fs-extra';
 import { Server } from 'http';
 import path from 'path';
 
+import { UI_PATH } from './constants';
 import { UiAppCompiler } from './ui-app-compiler.service';
 
 /**
@@ -61,6 +63,16 @@ export interface AdminUiOptions {
      * to be compiled into and made available by the AdminUi application.
      */
     extensions?: AdminUiExtension[];
+    /**
+     * @description
+     * Set to `true` in order to run the Admin UI in development mode (using the Angular CLI
+     * [ng serve](https://angular.io/cli/serve) command). When in watch mode, any changes to
+     * UI extension files will be watched and trigger a rebuild of the Admin UI with live
+     * reloading.
+     *
+     * @default false
+     */
+    watch?: boolean;
 }
 
 /**
@@ -100,6 +112,7 @@ export interface AdminUiOptions {
 export class AdminUiPlugin implements OnVendureBootstrap, OnVendureClose {
     private static options: AdminUiOptions;
     private server: Server;
+    private watcher: Watcher | undefined;
 
     constructor(private configService: ConfigService, private appCompiler: UiAppCompiler) {}
 
@@ -116,36 +129,64 @@ export class AdminUiPlugin implements OnVendureBootstrap, OnVendureClose {
     static async configure(config: RuntimeVendureConfig): Promise<RuntimeVendureConfig> {
         const route = 'admin';
         config.middleware.push({
-            handler: createProxyHandler({ ...this.options, route, label: 'Admin UI' }),
+            handler: createProxyHandler({
+                ...this.options,
+                route: 'admin',
+                label: 'Admin UI',
+                basePath: this.options.watch ? 'admin' : undefined,
+            }),
             route,
         });
+        if (this.options.watch) {
+            config.middleware.push({
+                handler: createProxyHandler({
+                    ...this.options,
+                    route: 'sockjs-node',
+                    label: 'Admin UI live reload',
+                    basePath: 'sockjs-node',
+                }),
+                route: 'sockjs-node',
+            });
+        }
         return config;
     }
 
     /** @internal */
     async onVendureBootstrap() {
         const { adminApiPath, authOptions } = this.configService;
-        const { apiHost, apiPort, extensions } = AdminUiPlugin.options;
-        const adminUiPath = await this.appCompiler.compileAdminUiApp(extensions);
+        const { apiHost, apiPort, extensions, watch, port } = AdminUiPlugin.options;
+        let adminUiConfigPath: string;
+
+        if (watch) {
+            this.watcher = this.appCompiler.watchAdminUiApp(extensions, port);
+            adminUiConfigPath = path.join(__dirname, '../../../admin-ui/src', 'vendure-ui-config.json');
+        } else {
+            const adminUiPath = await this.appCompiler.compileAdminUiApp(extensions);
+            const adminUiServer = express();
+            adminUiServer.use(express.static(UI_PATH));
+            adminUiServer.use((req, res) => {
+                res.sendFile(path.join(UI_PATH, 'index.html'));
+            });
+            this.server = adminUiServer.listen(AdminUiPlugin.options.port);
+            adminUiConfigPath = path.join(UI_PATH, 'vendure-ui-config.json');
+        }
         await this.overwriteAdminUiConfig({
             host: apiHost || 'auto',
             port: apiPort || 'auto',
             adminApiPath,
-            adminUiPath,
             authOptions,
+            adminUiConfigPath,
         });
-
-        const adminUiServer = express();
-        adminUiServer.use(express.static(adminUiPath));
-        adminUiServer.use((req, res) => {
-            res.sendFile(path.join(adminUiPath, 'index.html'));
-        });
-        this.server = adminUiServer.listen(AdminUiPlugin.options.port);
     }
 
     /** @internal */
-    onVendureClose(): Promise<void> {
-        return new Promise(resolve => this.server.close(() => resolve()));
+    async onVendureClose(): Promise<void> {
+        if (this.watcher) {
+            this.watcher.close();
+        }
+        if (this.server) {
+            await new Promise(resolve => this.server.close(() => resolve()));
+        }
     }
 
     /**
@@ -155,12 +196,11 @@ export class AdminUiPlugin implements OnVendureBootstrap, OnVendureClose {
     private async overwriteAdminUiConfig(options: {
         host: string | 'auto';
         port: number | 'auto';
-        adminUiPath: string;
         adminApiPath: string;
         authOptions: AuthOptions;
+        adminUiConfigPath: string;
     }) {
-        const { host, port, adminApiPath, adminUiPath, authOptions } = options;
-        const adminUiConfigPath = path.join(adminUiPath, 'vendure-ui-config.json');
+        const { host, port, adminApiPath, authOptions, adminUiConfigPath } = options;
         const adminUiConfig = await fs.readFile(adminUiConfigPath, 'utf-8');
         let config: AdminUiConfig;
         try {

+ 14 - 7
packages/admin-ui-plugin/src/ui-app-compiler.service.ts

@@ -1,28 +1,35 @@
 import { Injectable } from '@nestjs/common';
 import { compileAdminUiApp } from '@vendure/admin-ui/devkit/compile';
+import { watchAdminUiApp, Watcher } from '@vendure/admin-ui/devkit/watch';
 import { AdminUiExtension } from '@vendure/common/lib/shared-types';
 import { Logger } from '@vendure/core';
 import crypto from 'crypto';
 import fs from 'fs-extra';
 import path from 'path';
 
+import { loggerCtx, UI_PATH } from './constants';
+
 @Injectable()
 export class UiAppCompiler {
-    private readonly outputPath = path.join(__dirname, '../admin-ui');
     private readonly hashfile = path.join(__dirname, 'modules-hash.txt');
 
-    async compileAdminUiApp(extensions: AdminUiExtension[] | undefined): Promise<string> {
-        const compiledAppExists = fs.existsSync(path.join(this.outputPath, 'index.html'));
+    watchAdminUiApp(extensions: AdminUiExtension[] | undefined, port: number): Watcher {
+        const extensionsWithId = this.normalizeExtensions(extensions);
+        Logger.info(`Starting Admin UI in Angular dev server on port ${port}`, loggerCtx);
+        return watchAdminUiApp(extensionsWithId, port);
+    }
+
+    async compileAdminUiApp(extensions: AdminUiExtension[] | undefined): Promise<void> {
+        const compiledAppExists = fs.existsSync(path.join(UI_PATH, 'index.html'));
         const extensionsWithId = this.normalizeExtensions(extensions);
 
         if (!compiledAppExists || this.extensionModulesHaveChanged(extensionsWithId)) {
-            Logger.info('Compiling Admin UI with extensions...', 'AdminUiPlugin');
+            Logger.info('Compiling Admin UI with extensions...', loggerCtx);
             await compileAdminUiApp(path.join(__dirname, '../admin-ui'), extensionsWithId);
-            Logger.info('Completed compilation!', 'AdminUiPlugin');
+            Logger.info('Completed compilation!', loggerCtx);
         } else {
-            Logger.info('Extensions not changed since last run', 'AdminUiPlugin');
+            Logger.verbose('Extensions not changed since last run', loggerCtx);
         }
-        return this.outputPath;
     }
 
     /**

+ 4 - 3
packages/admin-ui/.gitignore

@@ -11,11 +11,12 @@
 # generated extension files
 /src/app/extensions/modules
 /src/app/extensions/extensions.module.ts.generated
+/src/app/extensions/extensions.module.ts.temp
 
 # compiled devkit files
-/devkit/compile.js
-/devkit/compile.d.ts
-/devkit/compile.js.map
+/devkit/*.js
+/devkit/*.d.ts
+/devkit/*.js.map
 
 # IDEs and editors
 /.idea

+ 42 - 1
packages/admin-ui/angular.json

@@ -60,7 +60,22 @@
               "vendorChunk": false,
               "buildOptimizer": true
             },
-            "compile-in-plugin": {
+            "plugin": {
+              "fileReplacements": [
+                {
+                  "replace": "src/environments/environment.ts",
+                  "with": "src/environments/environment.prod.ts"
+                }
+              ],
+              "optimization": true,
+              "outputHashing": "all",
+              "sourceMap": true,
+              "extractCss": true,
+              "namedChunks": false,
+              "aot": true,
+              "extractLicenses": true,
+              "vendorChunk": false,
+              "buildOptimizer": true,
               "styles": [
                 "../../@clr/icons/clr-icons.min.css",
                 "src/styles/styles.scss",
@@ -69,6 +84,26 @@
               "scripts": [
                 "../../trix/dist/trix-core.js"
               ]
+            },
+            "plugin-watch": {
+              "styles": [
+                "../../@clr/icons/clr-icons.min.css",
+                "src/styles/styles.scss",
+                "../../trix/dist/trix.css"
+              ],
+              "scripts": [
+                "../../trix/dist/trix-core.js"
+              ]
+            },
+            "plugin-dev": {
+              "styles": [
+                "../../node_modules/@clr/icons/clr-icons.min.css",
+                "src/styles/styles.scss",
+                "../../node_modules/trix/dist/trix.css"
+              ],
+              "scripts": [
+                "../../node_modules/trix/dist/trix-core.js"
+              ]
             }
           }
         },
@@ -80,6 +115,12 @@
           "configurations": {
             "production": {
               "browserTarget": "vendure-admin:build:production"
+            },
+            "plugin": {
+              "browserTarget": "vendure-admin:build:plugin-watch"
+            },
+            "plugin-dev": {
+              "browserTarget": "vendure-admin:build:plugin-dev"
             }
           }
         },

+ 80 - 0
packages/admin-ui/devkit/common.ts

@@ -0,0 +1,80 @@
+import { AdminUiExtension } from '@vendure/common/lib/shared-types';
+import * as fs from 'fs-extra';
+import * as path from 'path';
+
+const EXTENSIONS_DIR = path.join(__dirname, '../src/app/extensions');
+const EXTENSIONS_MODULES_DIR = 'modules';
+const originalExtensionsModuleFile = path.join(EXTENSIONS_DIR, 'extensions.module.ts');
+const tempExtensionsModuleFile = path.join(EXTENSIONS_DIR, 'extensions.module.ts.temp');
+
+/**
+ * Returns true if currently being executed from inside the Vendure monorepo.
+ */
+export function isInVendureMonorepo(): boolean {
+    return fs.existsSync(path.join(__dirname, '../../dev-server'));
+}
+
+/**
+ * Restores the placeholder ExtensionsModule file from a template.
+ */
+export function restoreExtensionsModule() {
+    fs.copyFileSync(path.join(__dirname, 'extensions.module.ts.template'), originalExtensionsModuleFile);
+}
+
+/**
+ * Deletes the contents of the /modules directory, which contains the plugin
+ * extension modules copied over during the last compilation.
+ */
+export function deleteExistingExtensionModules() {
+    fs.removeSync(path.join(EXTENSIONS_DIR, EXTENSIONS_MODULES_DIR));
+}
+
+/**
+ * Copies all files from the ngModulePaths of the configured extensions into the
+ * admin-ui source tree.
+ */
+export function copyExtensionModules(extensions: Array<Required<AdminUiExtension>>) {
+    for (const extension of extensions) {
+        const dirName = path.basename(path.dirname(extension.ngModulePath));
+        const dest = getModuleOutputDir(extension);
+        fs.copySync(extension.ngModulePath, dest);
+    }
+}
+
+export function getModuleOutputDir(extension: Required<AdminUiExtension>): string {
+    return path.join(EXTENSIONS_DIR, EXTENSIONS_MODULES_DIR, extension.id);
+}
+
+export function createExtensionsModule(extensions: Array<Required<AdminUiExtension>>) {
+    const removeTsExtension = (filename: string): string => filename.replace(/\.ts$/, '');
+    const importPath = (e: Required<AdminUiExtension>): string =>
+        `./${EXTENSIONS_MODULES_DIR}/${e.id}/${removeTsExtension(e.ngModuleFileName)}`;
+    fs.renameSync(originalExtensionsModuleFile, tempExtensionsModuleFile);
+
+    const source = generateExtensionModuleTsSource(
+        extensions.map(e => ({ className: e.ngModuleName, path: importPath(e) })),
+    );
+    fs.writeFileSync(path.join(EXTENSIONS_DIR, 'extensions.module.ts'), source, 'utf-8');
+}
+
+export function restoreOriginalExtensionsModule() {
+    fs.renameSync(originalExtensionsModuleFile, path.join(EXTENSIONS_DIR, 'extensions.module.ts.generated'));
+    restoreExtensionsModule();
+}
+
+function generateExtensionModuleTsSource(modules: Array<{ className: string; path: string }>): string {
+    return `/** This file is generated by the build() function. Do not edit. */
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+
+${modules.map(e => `import { ${e.className} } from '${e.path}';`).join('\n')}
+
+@NgModule({
+    imports: [
+        CommonModule,
+        ${modules.map(e => e.className + ',').join('\n')}
+    ],
+})
+export class ExtensionsModule {}
+`;
+}

+ 19 - 58
packages/admin-ui/devkit/compile.ts

@@ -1,13 +1,14 @@
 import { AdminUiExtension } from '@vendure/common/lib/shared-types';
-import { exec, spawn } from 'child_process';
-import * as fs from 'fs-extra';
+import { spawn } from 'child_process';
 import * as path from 'path';
 
-const EXTENSIONS_DIR = path.join(__dirname, '../src/app/extensions');
-const EXTENSIONS_MODULES_DIR = 'modules';
-
-const originalExtensionsModuleFile = path.join(EXTENSIONS_DIR, 'extensions.module.ts');
-const tempExtensionsModuleFile = path.join(EXTENSIONS_DIR, 'extensions.module.ts.temp');
+import {
+    copyExtensionModules,
+    createExtensionsModule,
+    deleteExistingExtensionModules,
+    isInVendureMonorepo,
+    restoreOriginalExtensionsModule,
+} from './common';
 
 /**
  * Builds the admin-ui app using the Angular CLI `ng build --prod` command.
@@ -16,15 +17,21 @@ export function compileAdminUiApp(outputPath: string, extensions: Array<Required
     const cwd = path.join(__dirname, '..');
     const relativeOutputPath = path.relative(cwd, outputPath);
     return new Promise((resolve, reject) => {
+        restoreOriginalExtensionsModule();
         deleteExistingExtensionModules();
         copyExtensionModules(extensions);
         createExtensionsModule(extensions);
 
-        const buildProcess = spawn('yarn', ['build:in-plugin', `--outputPath=${relativeOutputPath}`], {
-            cwd,
-            shell: true,
-            stdio: 'inherit',
-        });
+        const config = isInVendureMonorepo() ? 'plugin-dev' : 'plugin';
+        const buildProcess = spawn(
+            'yarn',
+            ['ng', 'build', `-c=${config}`, `--outputPath=${relativeOutputPath}`],
+            {
+                cwd,
+                shell: true,
+                stdio: 'inherit',
+            },
+        );
         buildProcess.on('close', code => {
             if (code === 0) {
                 resolve();
@@ -39,49 +46,3 @@ export function compileAdminUiApp(outputPath: string, extensions: Array<Required
         restoreOriginalExtensionsModule();
     });
 }
-
-function deleteExistingExtensionModules() {
-    fs.removeSync(path.join(EXTENSIONS_DIR, EXTENSIONS_MODULES_DIR));
-}
-
-function copyExtensionModules(extensions: Array<Required<AdminUiExtension>>) {
-    for (const extension of extensions) {
-        const dirName = path.basename(path.dirname(extension.ngModulePath));
-        const dest = path.join(EXTENSIONS_DIR, EXTENSIONS_MODULES_DIR, extension.id);
-        fs.copySync(extension.ngModulePath, dest);
-    }
-}
-
-function createExtensionsModule(extensions: Array<Required<AdminUiExtension>>) {
-    const removeTsExtension = (filename: string): string => filename.replace(/\.ts$/, '');
-    const importPath = (e: Required<AdminUiExtension>): string =>
-        `./${EXTENSIONS_MODULES_DIR}/${e.id}/${removeTsExtension(e.ngModuleFileName)}`;
-    fs.renameSync(originalExtensionsModuleFile, tempExtensionsModuleFile);
-
-    const source = generateExtensionModuleTsSource(
-        extensions.map(e => ({ className: e.ngModuleName, path: importPath(e) })),
-    );
-    fs.writeFileSync(path.join(EXTENSIONS_DIR, 'extensions.module.ts'), source, 'utf-8');
-}
-
-function restoreOriginalExtensionsModule() {
-    fs.renameSync(originalExtensionsModuleFile, path.join(EXTENSIONS_DIR, 'extensions.module.ts.generated'));
-    fs.renameSync(tempExtensionsModuleFile, originalExtensionsModuleFile);
-}
-
-function generateExtensionModuleTsSource(modules: Array<{ className: string; path: string }>): string {
-    return `/** This file is generated by the build() function. Do not edit. */
-import { CommonModule } from '@angular/common';
-import { NgModule } from '@angular/core';
-
-${modules.map(e => `import { ${e.className} } from '${e.path}';`).join('\n')}
-
-@NgModule({
-    imports: [
-        CommonModule,
-        ${modules.map(e => e.className + ',').join('\n')}
-    ],
-})
-export class ExtensionsModule {}
-`;
-}

+ 12 - 0
packages/admin-ui/devkit/extensions.module.ts.template

@@ -0,0 +1,12 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+
+/**
+ * This is a placeholder module for UI extensions provided by the AdminUiPlugin `extensions` option.
+ * When the {@link compileUiExtensions} function is executed, this module gets temporarily replaced
+ * by a generated module which includes all of the configured extension modules.
+ */
+@NgModule({
+    imports: [CommonModule],
+})
+export class ExtensionsModule {}

+ 74 - 0
packages/admin-ui/devkit/watch.ts

@@ -0,0 +1,74 @@
+import { AdminUiExtension } from '@vendure/common/lib/shared-types';
+import { spawn } from 'child_process';
+import { FSWatcher, watch } from 'chokidar';
+import * as fs from 'fs-extra';
+import * as path from 'path';
+
+import {
+    copyExtensionModules,
+    createExtensionsModule,
+    deleteExistingExtensionModules,
+    getModuleOutputDir,
+    isInVendureMonorepo,
+    restoreExtensionsModule,
+    restoreOriginalExtensionsModule,
+} from './common';
+
+export type Watcher = {
+    close: () => void;
+};
+
+/**
+ * Starts the admin ui app in watch mode using the Angular CLI `ng serve` command. Also watches
+ * the individual files of any configured extensions and copies them upon change, triggering a
+ * rebuild of the Angular app.
+ */
+export function watchAdminUiApp(extensions: Array<Required<AdminUiExtension>>, port: number): Watcher {
+    const cwd = path.join(__dirname, '..');
+    restoreExtensionsModule();
+    deleteExistingExtensionModules();
+    copyExtensionModules(extensions);
+    createExtensionsModule(extensions);
+
+    const config = isInVendureMonorepo() ? 'plugin-dev' : 'plugin';
+    const buildProcess = spawn('yarn', ['ng', 'serve', `-c=${config}`, `--port=${port}`], {
+        cwd,
+        shell: true,
+        stdio: 'inherit',
+    });
+
+    let watcher: FSWatcher | undefined;
+    for (const extension of extensions) {
+        if (!watcher) {
+            watcher = watch(extension.ngModulePath, {
+                depth: 4,
+                ignored: '**/node_modules/',
+            });
+        } else {
+            watcher.add(extension.ngModulePath);
+        }
+    }
+
+    if (watcher) {
+        watcher.on('change', filePath => {
+            const extension = extensions.find(e => filePath.includes(e.ngModulePath));
+            if (extension) {
+                const outputDir = getModuleOutputDir(extension);
+                const filePart = path.relative(extension.ngModulePath, filePath);
+                const dest = path.join(outputDir, filePart);
+                fs.copyFile(filePath, dest);
+            }
+        });
+    }
+
+    const close = () => {
+        if (watcher) {
+            watcher.close();
+        }
+        buildProcess.kill();
+        restoreOriginalExtensionsModule();
+    };
+
+    process.on('SIGINT', close);
+    return { close };
+}

+ 6 - 4
packages/admin-ui/src/vendure-ui-config.json

@@ -1,5 +1,7 @@
 {
-  "apiHost": "http://localhost",
-  "apiPort": 3000,
-  "adminApiPath": "admin-api"
-}
+  "apiHost": "auto",
+  "apiPort": "auto",
+  "adminApiPath": "admin-api",
+  "tokenMethod": "cookie",
+  "authTokenHeaderKey": "vendure-auth-token"
+}

+ 2 - 1
packages/admin-ui/tsconfig.devkit.json

@@ -15,7 +15,8 @@
     ]
   },
   "files": [
-    "devkit/compile.ts"
+    "devkit/compile.ts",
+    "devkit/watch.ts"
   ],
   "exclude": [
     "src/**/*"

+ 8 - 3
packages/core/src/plugin/plugin-utils.ts

@@ -41,7 +41,7 @@ export function createProxyHandler(options: ProxyOptions): RequestHandler {
         // TODO: how do we detect https?
         target: `http://${proxyHostname}:${options.port}`,
         pathRewrite: {
-            [`^${route}`]: `/`,
+            [`^${route}`]: `/` + (options.basePath || ''),
         },
         logProvider(provider: proxy.LogProvider): proxy.LogProvider {
             return {
@@ -100,6 +100,11 @@ export interface ProxyOptions {
      * @default 'localhost'
      */
     hostname?: string;
+    /**
+     * @description
+     * An optional base path on the proxied server.
+     */
+    basePath?: string;
 }
 
 /**
@@ -108,12 +113,12 @@ export interface ProxyOptions {
 export function logProxyMiddlewares(config: VendureConfig) {
     for (const middleware of config.middleware || []) {
         if ((middleware.handler as any).proxyMiddleware) {
-            const { port, hostname, label, route } = (middleware.handler as any)
+            const { port, hostname, label, route, basePath } = (middleware.handler as any)
                 .proxyMiddleware as ProxyOptions;
             Logger.info(
                 `${label}: http://${config.hostname || 'localhost'}:${
                     config.port
-                }/${route}/ -> http://${hostname || 'localhost'}:${port}`,
+                }/${route}/ -> http://${hostname || 'localhost'}:${port}${basePath ? `/${basePath}` : ''}`,
             );
         }
     }

+ 15 - 0
yarn.lock

@@ -4348,6 +4348,21 @@ chokidar@^2.0.2, chokidar@^2.0.3, chokidar@^2.1.1, chokidar@^2.1.6:
   optionalDependencies:
     fsevents "^1.2.7"
 
+chokidar@^3.0.2:
+  version "3.0.2"
+  resolved "http://localhost:4873/chokidar/-/chokidar-3.0.2.tgz#0d1cd6d04eb2df0327446188cd13736a3367d681"
+  integrity sha512-c4PR2egjNjI1um6bamCQ6bUNPDiyofNQruHvKgHQ4gDUP/ITSVSzNsiI5OWtHOsX323i5ha/kk4YmOZ1Ktg7KA==
+  dependencies:
+    anymatch "^3.0.1"
+    braces "^3.0.2"
+    glob-parent "^5.0.0"
+    is-binary-path "^2.1.0"
+    is-glob "^4.0.1"
+    normalize-path "^3.0.0"
+    readdirp "^3.1.1"
+  optionalDependencies:
+    fsevents "^2.0.6"
+
 chownr@^1.0.1, chownr@^1.1.1:
   version "1.1.1"
   resolved "https://registry.npmjs.org/chownr/-/chownr-1.1.1.tgz#54726b8b8fff4df053c42187e801fb4412df1494"