Skip to content

Commit

Permalink
feat(admin-ui-plugin): Detect whether extensions need to be re-compiled
Browse files Browse the repository at this point in the history
Relates to #55
  • Loading branch information
michaelbromley committed Sep 9, 2019
1 parent c2742ec commit ba8c44f
Show file tree
Hide file tree
Showing 6 changed files with 133 additions and 72 deletions.
21 changes: 0 additions & 21 deletions packages/admin-ui-plugin/build.js

This file was deleted.

6 changes: 6 additions & 0 deletions packages/admin-ui-plugin/build.ts
Original file line number Diff line number Diff line change
@@ -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'), []);
2 changes: 1 addition & 1 deletion packages/admin-ui-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
75 changes: 26 additions & 49 deletions packages/admin-ui-plugin/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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}.
Expand Down Expand Up @@ -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
Expand All @@ -123,53 +125,42 @@ 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 */
onVendureClose(): Promise<void> {
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 {
Expand All @@ -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 changes: 99 additions & 0 deletions packages/admin-ui-plugin/src/ui-app-compiler.service.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
}
2 changes: 1 addition & 1 deletion packages/admin-ui/devkit/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down

0 comments on commit ba8c44f

Please sign in to comment.