1
0
Эх сурвалжийг харах

feat(admin-ui-plugin): Detect whether extensions need to be re-compiled

Relates to #55
Michael Bromley 6 жил өмнө
parent
commit
ba8c44fba3

+ 0 - 21
packages/admin-ui-plugin/build.js

@@ -1,21 +0,0 @@
-/* tslint:disable:no-console */
-const path = require ('path');
-const fs = require ('fs-extra');
-const { exec } = require('child_process');
-
-console.log('Building admin-ui from source...');
-exec(
-    'yarn build --prod=true',
-    {
-        cwd: path.join(__dirname, '../admin-ui'),
-    },
-    async error => {
-        if (error) {
-            console.log(error);
-            process.exit(1);
-        }
-        console.log('done!');
-        await fs.copy('../admin-ui/dist', 'lib/admin-ui');
-        process.exit(0);
-    },
-);

+ 6 - 0
packages/admin-ui-plugin/build.ts

@@ -0,0 +1,6 @@
+/* tslint:disable:no-console */
+import { compileAdminUiApp } from '@vendure/admin-ui/devkit/compile';
+import path from 'path';
+
+console.log('Building admin-ui from source...');
+compileAdminUiApp(path.join(__dirname, 'lib/admin-ui'), []);

+ 1 - 1
packages/admin-ui-plugin/package.json

@@ -8,7 +8,7 @@
   ],
   "license": "MIT",
   "scripts": {
-    "build": "rimraf lib && node build.js && yarn compile",
+    "build": "rimraf lib && node -r ts-node/register build.ts && yarn compile",
     "watch": "tsc -p ./tsconfig.build.json --watch",
     "compile": "tsc -p ./tsconfig.build.json"
   },

+ 26 - 49
packages/admin-ui-plugin/src/plugin.ts

@@ -1,4 +1,3 @@
-import { compileUiExtensions } from '@vendure/admin-ui/devkit/compile';
 import { DEFAULT_AUTH_TOKEN_HEADER_KEY } from '@vendure/common/lib/shared-constants';
 import { AdminUiConfig, AdminUiExtension, Type } from '@vendure/common/lib/shared-types';
 import {
@@ -17,6 +16,8 @@ import fs from 'fs-extra';
 import { Server } from 'http';
 import path from 'path';
 
+import { UiAppCompiler } from './ui-app-compiler.service';
+
 /**
  * @description
  * Configuration options for the {@link AdminUiPlugin}.
@@ -93,13 +94,14 @@ export interface AdminUiOptions {
  */
 @VendurePlugin({
     imports: [PluginCommonModule],
+    providers: [UiAppCompiler],
     configuration: config => AdminUiPlugin.configure(config),
 })
 export class AdminUiPlugin implements OnVendureBootstrap, OnVendureClose {
     private static options: AdminUiOptions;
     private server: Server;
 
-    constructor(private configService: ConfigService) {}
+    constructor(private configService: ConfigService, private appCompiler: UiAppCompiler) {}
 
     /**
      * @description
@@ -123,17 +125,22 @@ export class AdminUiPlugin implements OnVendureBootstrap, OnVendureClose {
     /** @internal */
     async onVendureBootstrap() {
         const { adminApiPath, authOptions } = this.configService;
-        const { apiHost, apiPort } = AdminUiPlugin.options;
-        await this.compileAdminUiApp();
-        await this.overwriteAdminUiConfig(apiHost || 'auto', apiPort || 'auto', adminApiPath, authOptions);
+        const { apiHost, apiPort, extensions } = AdminUiPlugin.options;
+        const adminUiPath = await this.appCompiler.compileAdminUiApp(extensions);
+        await this.overwriteAdminUiConfig({
+            host: apiHost || 'auto',
+            port: apiPort || 'auto',
+            adminApiPath,
+            adminUiPath,
+            authOptions,
+        });
 
-        const adminUiPath = this.getAdminUiPath();
-        const assetServer = express();
-        assetServer.use(express.static(adminUiPath));
-        assetServer.use((req, res) => {
+        const adminUiServer = express();
+        adminUiServer.use(express.static(adminUiPath));
+        adminUiServer.use((req, res) => {
             res.sendFile(path.join(adminUiPath, 'index.html'));
         });
-        this.server = assetServer.listen(AdminUiPlugin.options.port);
+        this.server = adminUiServer.listen(AdminUiPlugin.options.port);
     }
 
     /** @internal */
@@ -141,35 +148,19 @@ export class AdminUiPlugin implements OnVendureBootstrap, OnVendureClose {
         return new Promise(resolve => this.server.close(() => resolve()));
     }
 
-    private async compileAdminUiApp() {
-        const extensions = this.getExtensions();
-        Logger.info('Compiling Admin UI extensions...', 'AdminUiPlugin');
-        await compileUiExtensions(path.join(__dirname, '../admin-ui'), extensions);
-        Logger.info('Completed compilation!', 'AdminUiPlugin');
-    }
-
-    private getExtensions(): Array<Required<AdminUiExtension>> {
-        return (AdminUiPlugin.options.extensions || []).map(e => {
-            const id =
-                e.id ||
-                Math.random()
-                    .toString(36)
-                    .substr(4);
-            return { ...e, id };
-        });
-    }
-
     /**
      * Overwrites the parts of the admin-ui app's `vendure-ui-config.json` file relating to connecting to
      * the server admin API.
      */
-    private async overwriteAdminUiConfig(
-        host: string | 'auto',
-        port: number | 'auto',
-        adminApiPath: string,
-        authOptions: AuthOptions,
-    ) {
-        const adminUiConfigPath = path.join(this.getAdminUiPath(), 'vendure-ui-config.json');
+    private async overwriteAdminUiConfig(options: {
+        host: string | 'auto';
+        port: number | 'auto';
+        adminUiPath: string;
+        adminApiPath: string;
+        authOptions: AuthOptions;
+    }) {
+        const { host, port, adminApiPath, adminUiPath, authOptions } = options;
+        const adminUiConfigPath = path.join(adminUiPath, 'vendure-ui-config.json');
         const adminUiConfig = await fs.readFile(adminUiConfigPath, 'utf-8');
         let config: AdminUiConfig;
         try {
@@ -184,18 +175,4 @@ export class AdminUiPlugin implements OnVendureBootstrap, OnVendureClose {
         config.authTokenHeaderKey = authOptions.authTokenHeaderKey || DEFAULT_AUTH_TOKEN_HEADER_KEY;
         await fs.writeFile(adminUiConfigPath, JSON.stringify(config, null, 2));
     }
-
-    private getAdminUiPath(): string {
-        // attempt to read from the path location on a production npm install
-        const prodPath = path.join(__dirname, '../admin-ui');
-        if (fs.existsSync(path.join(prodPath, 'index.html'))) {
-            return prodPath;
-        }
-        // attempt to read from the path on a development install
-        const devPath = path.join(__dirname, '../lib/admin-ui');
-        if (fs.existsSync(path.join(devPath, 'index.html'))) {
-            return devPath;
-        }
-        throw new Error(`AdminUiPlugin: admin-ui app not found`);
-    }
 }

+ 99 - 0
packages/admin-ui-plugin/src/ui-app-compiler.service.ts

@@ -0,0 +1,99 @@
+import { Injectable } from '@nestjs/common';
+import { compileAdminUiApp } from '@vendure/admin-ui/devkit/compile';
+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';
+
+@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'));
+        const extensionsWithId = this.normalizeExtensions(extensions);
+
+        if (!compiledAppExists || this.extensionModulesHaveChanged(extensionsWithId)) {
+            Logger.info('Compiling Admin UI with extensions...', 'AdminUiPlugin');
+            await compileAdminUiApp(path.join(__dirname, '../admin-ui'), extensionsWithId);
+            Logger.info('Completed compilation!', 'AdminUiPlugin');
+        } else {
+            Logger.info('Extensions not changed since last run', 'AdminUiPlugin');
+        }
+        return this.outputPath;
+    }
+
+    /**
+     * Ensures each extension has an ID. If not defined by the user, a deterministic ID is generated
+     * from a hash of the extension config.
+     */
+    private normalizeExtensions(extensions?: AdminUiExtension[]): Array<Required<AdminUiExtension>> {
+        return (extensions || []).map(e => {
+            if (e.id) {
+                return e as Required<AdminUiExtension>;
+            }
+            const hash = crypto.createHash('sha256');
+            hash.update(JSON.stringify(e));
+            const id = hash.digest('hex');
+            return { ...e, id };
+        });
+    }
+
+    /**
+     * Checks whether the extensions configuration or any of the extension module files have been
+     * changed since the last run.
+     */
+    private extensionModulesHaveChanged(extensions: Array<Required<AdminUiExtension>>): boolean {
+        fs.ensureFileSync(this.hashfile);
+        const previousHash = fs.readFileSync(this.hashfile, 'utf-8');
+        if (!previousHash && extensions.length === 0) {
+            // No extensions are configured and there is no last has,
+            // as when the plugin is newly installed. In this case,
+            // it would be unnecessary to recompile.
+            return false;
+        }
+        const currentHash = this.getExtensionModulesHash(extensions);
+
+        if (currentHash === previousHash) {
+            return false;
+        }
+        fs.writeFileSync(this.hashfile, currentHash, 'utf-8');
+        return true;
+    }
+
+    /**
+     * Generates a hash based on the extensions array as well as the modified time of each file
+     * in the ngModulesPaths.
+     */
+    private getExtensionModulesHash(extensions: Array<Required<AdminUiExtension>>): string {
+        let modifiedDates: string[] = [];
+        for (const extension of extensions) {
+            modifiedDates = [...modifiedDates, ...this.getAllModifiedDates(extension.ngModulePath)];
+        }
+        const hash = crypto.createHash('sha256');
+        hash.update(modifiedDates.join('') + JSON.stringify(extensions));
+        return hash.digest('hex');
+    }
+
+    private getAllModifiedDates(dirPath: string): string[] {
+        const modifiedDates: string[] = [];
+        this.visitRecursive(dirPath, filePath => {
+            modifiedDates.push(fs.statSync(filePath).mtimeMs.toString());
+        });
+        return modifiedDates;
+    }
+
+    private visitRecursive(dirPath: string, visitor: (filePath: string) => void) {
+        const files = fs.readdirSync(dirPath);
+        for (const file of files) {
+            const fullPath = path.join(dirPath, file);
+            if (fs.statSync(fullPath).isDirectory()) {
+                this.visitRecursive(fullPath, visitor);
+            } else {
+                visitor(fullPath);
+            }
+        }
+    }
+}

+ 1 - 1
packages/admin-ui/devkit/compile.ts

@@ -12,7 +12,7 @@ const tempExtensionsModuleFile = path.join(EXTENSIONS_DIR, 'extensions.module.ts
 /**
  * Builds the admin-ui app using the Angular CLI `ng build --prod` command.
  */
-export function compileUiExtensions(outputPath: string, extensions: Array<Required<AdminUiExtension>>) {
+export function compileAdminUiApp(outputPath: string, extensions: Array<Required<AdminUiExtension>>) {
     const cwd = path.join(__dirname, '..');
     const relativeOutputPath = path.relative(cwd, outputPath);
     return new Promise((resolve, reject) => {