From b3b5b169e88c2317d90da2af273f4f083be8f973 Mon Sep 17 00:00:00 2001 From: garrettmills Date: Fri, 2 Jul 2021 21:45:15 -0500 Subject: [PATCH] Add mechanism for NPM package auto-discovery --- package.json | 6 ++ src/index.ts | 1 + src/lifecycle/Application.ts | 7 ++ src/support/NodeModules.ts | 112 +++++++++++++++++++++++++++++++ src/support/PackageDiscovered.ts | 33 +++++++++ src/support/types.ts | 45 +++++++++++++ 6 files changed, 204 insertions(+) create mode 100644 src/support/NodeModules.ts create mode 100644 src/support/PackageDiscovered.ts create mode 100644 src/support/types.ts diff --git a/package.json b/package.json index 9434ca2..503a47f 100644 --- a/package.json +++ b/package.json @@ -67,5 +67,11 @@ "@typescript-eslint/eslint-plugin": "^4.26.0", "@typescript-eslint/parser": "^4.26.0", "eslint": "^7.27.0" + }, + "extollo": { + "discover": true, + "units": { + "discover": false + } } } diff --git a/src/index.ts b/src/index.ts index 9cef892..fc7ee21 100644 --- a/src/index.ts +++ b/src/index.ts @@ -71,6 +71,7 @@ export * from './service/Middlewares' export * from './support/cache/MemoryCache' export * from './support/cache/CacheFactory' +export * from './support/NodeModules' export * from './views/ViewEngine' export * from './views/ViewEngineFactory' diff --git a/src/lifecycle/Application.ts b/src/lifecycle/Application.ts index 3b6ca75..ee54de5 100644 --- a/src/lifecycle/Application.ts +++ b/src/lifecycle/Application.ts @@ -48,6 +48,12 @@ export function appPath(...parts: PathLike[]): UniversalPath { * The main application container. */ export class Application extends Container { + public static readonly NODE_MODULES_INJECTION = 'extollo/npm' + + public static get NODE_MODULES_PROVIDER(): string { + return process.env.EXTOLLO_NPM || 'pnpm' + } + public static getContainer(): Container { const existing = globalRegistry.getGlobal('extollo/injector') if ( !existing ) { @@ -205,6 +211,7 @@ export class Application extends Container { this.setupLogging() this.registerFactory(new CacheFactory()) // FIXME move this somewhere else? + this.registerSingleton(Application.NODE_MODULES_INJECTION, Application.NODE_MODULES_PROVIDER) this.make(Logging).debug(`Application root: ${this.baseDir}`) } diff --git a/src/support/NodeModules.ts b/src/support/NodeModules.ts new file mode 100644 index 0000000..f58ce6d --- /dev/null +++ b/src/support/NodeModules.ts @@ -0,0 +1,112 @@ +import * as childProcess from 'child_process' +import {UniversalPath} from '../util' +import {Inject, Injectable, InjectParam} from '../di' +import {Application} from '../lifecycle/Application' +import {Logging} from '../service/Logging' +import {NodeModule, ExtolloAwareNodeModule} from './types' +import {EventBus} from '../event/EventBus' +import {PackageDiscovered} from './PackageDiscovered' + +/** + * A helper class for discovering and interacting with + * NPM-style modules. + */ +@Injectable() +export class NodeModules { + @Inject() + protected readonly logging!: Logging + + @Inject() + protected readonly bus!: EventBus + + constructor( + @InjectParam(Application.NODE_MODULES_INJECTION) + protected readonly manager: string, + ) { } + + /** + * Get the NodeModule entry for the base application. + */ + async app(): Promise { + return new Promise((res, rej) => { + childProcess.exec(`${this.manager} ls --json`, (error, stdout) => { + if ( error ) { + return rej(error) + } + + res(JSON.parse(stdout)[0]) + }) + }) + } + + /** + * Get the path to the node_modules folder for the base application. + */ + async root(): Promise { + return new Promise((res, rej) => { + childProcess.exec(`${this.manager} root`, (error, stdout) => { + if ( error ) { + return rej(error) + } + + res(new UniversalPath(stdout.trim())) + }) + }) + } + + /** + * Iterate over packages, recursively, starting with the base application's + * package.json and fire PackageDiscovered events for any that have a valid + * Extollo discovery entry. + */ + async discover(): Promise { + const root = await this.root() + const module = await this.app() + return this.discoverRoot(root, module) + } + + /** + * Recursively discover child-packages from the node_modules root for the + * given module. + * + * Fires PackageDiscovered events for valid, discovery-enabled packages. + * + * @param root - the path to node_modules + * @param module - the module whose children we are discovering + * @protected + */ + protected async discoverRoot(root: UniversalPath, module: NodeModule): Promise { + for ( const key in module.dependencies ) { + if ( !Object.prototype.hasOwnProperty.call(module.dependencies, key) ) { + continue + } + + this.logging.verbose(`Auto-discovery considering package: ${key}`) + + try { + const packageJson = root.concat(key, 'package.json') + this.logging.verbose(`Auto-discovery package path: ${packageJson}`) + if ( await packageJson.exists() ) { + const packageJsonString: string = await packageJson.read() + const packageJsonData: ExtolloAwareNodeModule = JSON.parse(packageJsonString) + if ( !packageJsonData?.extollo?.discover ) { + this.logging.debug(`Skipping non-discoverable package: ${key}`) + continue + } + + this.logging.info(`Auto-discovering package: ${key}`) + await this.bus.dispatch(new PackageDiscovered(packageJsonData, packageJson.clone())) + + const packageNodeModules = packageJson.concat('..', 'node_modules') + if ( await packageNodeModules.exists() && packageJsonData?.extollo?.recursiveDependencies?.discover ) { + this.logging.debug(`Recursing: ${packageNodeModules}`) + await this.discoverRoot(packageNodeModules, packageJsonData) + } + } + } catch (e: unknown) { + this.logging.error(`Encountered error while discovering package: ${key}`) + this.logging.error(e) + } + } + } +} diff --git a/src/support/PackageDiscovered.ts b/src/support/PackageDiscovered.ts new file mode 100644 index 0000000..5e0fb95 --- /dev/null +++ b/src/support/PackageDiscovered.ts @@ -0,0 +1,33 @@ +import {Event} from '../event/Event' +import {Awaitable, JSONState, UniversalPath} from '../util' +import {ExtolloAwareNodeModule} from './types' + +/** + * An event indicating that an NPM package has been discovered + * by the framework. + * + * Application services can listen for this event to register + * various discovery logic (e.g. automatically boot units + */ +export class PackageDiscovered extends Event { + constructor( + public packageConfig: ExtolloAwareNodeModule, + public packageJson: UniversalPath, + ) { + super() + } + + dehydrate(): Awaitable { + return { + packageConfig: this.packageConfig as JSONState, + packageJson: this.packageJson.toString(), + } + } + + rehydrate(state: JSONState): Awaitable { + if ( typeof state === 'object' ) { + this.packageConfig = (state.packageConfig as ExtolloAwareNodeModule) + this.packageJson = new UniversalPath(String(state.packageJson)) + } + } +} diff --git a/src/support/types.ts b/src/support/types.ts new file mode 100644 index 0000000..4293d0d --- /dev/null +++ b/src/support/types.ts @@ -0,0 +1,45 @@ +/** + * Partial package.json that may contain a partial Extollo discovery config. + */ +export interface ExtolloPackageDiscoveryConfig { + extollo?: { + discover?: boolean, + units?: { + discover?: boolean, + paths?: string[], + }, + recursiveDependencies?: { + discover?: boolean, + }, + }, +} + +/** + * Interface that defines a NodeModule dependency. + */ +export interface NodeDependencySpecEntry { + from: string, + version: string, + resolved?: string, + dependencies?: {[key: string]: NodeDependencySpecEntry}, + devDependencies?: {[key: string]: NodeDependencySpecEntry}, + unsavedDependencies?: {[key: string]: NodeDependencySpecEntry}, + optionalDependencies?: {[key: string]: NodeDependencySpecEntry}, +} + +/** + * Defines information and dependencies of an NPM package. + */ +export interface NodeModule { + name?: string, + version?: string, + dependencies?: {[key: string]: NodeDependencySpecEntry}, + devDependencies?: {[key: string]: NodeDependencySpecEntry}, + unsavedDependencies?: {[key: string]: NodeDependencySpecEntry}, + optionalDependencies?: {[key: string]: NodeDependencySpecEntry}, +} + +/** + * Type alias for a NodeModule that contains an ExtolloPackageDiscoveryConfig. + */ +export type ExtolloAwareNodeModule = NodeModule & ExtolloPackageDiscoveryConfig