From ba8c44fba325c726fd7d43aa16f16ca7960f78a0 Mon Sep 17 00:00:00 2001 From: Michael Bromley Date: Wed, 4 Sep 2019 15:51:57 +0200 Subject: [PATCH] feat(admin-ui-plugin): Detect whether extensions need to be re-compiled Relates to #55 --- packages/admin-ui-plugin/build.js | 21 ---- packages/admin-ui-plugin/build.ts | 6 ++ packages/admin-ui-plugin/package.json | 2 +- packages/admin-ui-plugin/src/plugin.ts | 75 +++++--------- .../src/ui-app-compiler.service.ts | 99 +++++++++++++++++++ packages/admin-ui/devkit/compile.ts | 2 +- 6 files changed, 133 insertions(+), 72 deletions(-) delete mode 100644 packages/admin-ui-plugin/build.js create mode 100644 packages/admin-ui-plugin/build.ts create mode 100644 packages/admin-ui-plugin/src/ui-app-compiler.service.ts diff --git a/packages/admin-ui-plugin/build.js b/packages/admin-ui-plugin/build.js deleted file mode 100644 index f460f00ba8..0000000000 --- a/packages/admin-ui-plugin/build.js +++ /dev/null @@ -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); - }, -); diff --git a/packages/admin-ui-plugin/build.ts b/packages/admin-ui-plugin/build.ts new file mode 100644 index 0000000000..ff6baed093 --- /dev/null +++ b/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'), []); diff --git a/packages/admin-ui-plugin/package.json b/packages/admin-ui-plugin/package.json index 43b6ab56c9..22a0c05302 100644 --- a/packages/admin-ui-plugin/package.json +++ b/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" }, diff --git a/packages/admin-ui-plugin/src/plugin.ts b/packages/admin-ui-plugin/src/plugin.ts index 7fb933df41..2c6e1e1831 100644 --- a/packages/admin-ui-plugin/src/plugin.ts +++ b/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> { - 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`); - } } diff --git a/packages/admin-ui-plugin/src/ui-app-compiler.service.ts b/packages/admin-ui-plugin/src/ui-app-compiler.service.ts new file mode 100644 index 0000000000..68a4260527 --- /dev/null +++ b/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 { + 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> { + return (extensions || []).map(e => { + if (e.id) { + return e as Required; + } + 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>): 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>): 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); + } + } + } +} diff --git a/packages/admin-ui/devkit/compile.ts b/packages/admin-ui/devkit/compile.ts index b5476ce1db..5e132b66b1 100644 --- a/packages/admin-ui/devkit/compile.ts +++ b/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>) { +export function compileAdminUiApp(outputPath: string, extensions: Array>) { const cwd = path.join(__dirname, '..'); const relativeOutputPath = path.relative(cwd, outputPath); return new Promise((resolve, reject) => {