From c816b3be92a34c9292fb6b201b9e64117d571ff8 Mon Sep 17 00:00:00 2001 From: Nicola Krumschmidt Date: Fri, 1 Oct 2021 17:13:11 +0200 Subject: [PATCH] Move extension management into a class (#8478) --- api/src/app.ts | 13 +- api/src/cli/index.ts | 7 +- api/src/controllers/extensions.ts | 10 +- api/src/extensions.ts | 343 +++++++++++++++++++----------- api/src/services/graphql.ts | 18 +- 5 files changed, 240 insertions(+), 151 deletions(-) diff --git a/api/src/app.ts b/api/src/app.ts index 6a7cd99c0c..b8c92ec11a 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -31,7 +31,7 @@ import { isInstalled, validateDatabaseConnection, validateDatabaseExtensions, va import { emitAsyncSafe } from './emitter'; import env from './env'; import { InvalidPayloadException } from './exceptions'; -import { initializeExtensions, registerExtensionEndpoints, registerExtensionHooks } from './extensions'; +import { getExtensionManager } from './extensions'; import logger, { expressLogger } from './logger'; import authenticate from './middleware/authenticate'; import cache from './middleware/cache'; @@ -74,14 +74,12 @@ export default async function createApp(): Promise { await flushCaches(); - await initializeExtensions(); + const extensionManager = getExtensionManager(); - registerExtensionHooks(); + await extensionManager.initialize(); const app = express(); - const customRouter = express.Router(); - app.disable('x-powered-by'); app.set('trust proxy', true); app.set('query parser', (str: string) => qs.parse(str, { depth: 10 })); @@ -192,11 +190,8 @@ export default async function createApp(): Promise { app.use('/utils', utilsRouter); app.use('/webhooks', webhooksRouter); - app.use(customRouter); - - // Register custom hooks / endpoints await emitAsyncSafe('routes.custom.init.before', { app }); - registerExtensionEndpoints(customRouter); + app.use(extensionManager.getEndpointRouter()); await emitAsyncSafe('routes.custom.init.after', { app }); app.use(notFoundHandler); diff --git a/api/src/cli/index.ts b/api/src/cli/index.ts index f7539ed935..714c1b81a6 100644 --- a/api/src/cli/index.ts +++ b/api/src/cli/index.ts @@ -1,7 +1,7 @@ import { Command, Option } from 'commander'; import { startServer } from '../server'; import { emitAsyncSafe } from '../emitter'; -import { initializeExtensions, registerExtensionHooks } from '../extensions'; +import { getExtensionManager } from '../extensions'; import bootstrap from './commands/bootstrap'; import count from './commands/count'; import dbInstall from './commands/database/install'; @@ -18,8 +18,9 @@ const pkg = require('../../package.json'); export async function createCli(): Promise { const program = new Command(); - await initializeExtensions(); - registerExtensionHooks(); + const extensionManager = getExtensionManager(); + + await extensionManager.initialize(); await emitAsyncSafe('cli.init.before', { program }); diff --git a/api/src/controllers/extensions.ts b/api/src/controllers/extensions.ts index 72a1708815..ce15514d90 100644 --- a/api/src/controllers/extensions.ts +++ b/api/src/controllers/extensions.ts @@ -1,7 +1,7 @@ import { Router } from 'express'; import asyncHandler from '../utils/async-handler'; import { RouteNotFoundException } from '../exceptions'; -import { listExtensions, getAppExtensionSource } from '../extensions'; +import { getExtensionManager } from '../extensions'; import { respond } from '../middleware/respond'; import { depluralize, isAppExtension } from '@directus/shared/utils'; import { Plural } from '@directus/shared/types'; @@ -17,7 +17,9 @@ router.get( throw new RouteNotFoundException(req.path); } - const extensions = listExtensions(type); + const extensionManager = getExtensionManager(); + + const extensions = extensionManager.listExtensions(type); res.locals.payload = { data: extensions, @@ -37,7 +39,9 @@ router.get( throw new RouteNotFoundException(req.path); } - const extensionSource = getAppExtensionSource(type); + const extensionManager = getExtensionManager(); + + const extensionSource = extensionManager.getAppExtensions(type); if (extensionSource === undefined) { throw new RouteNotFoundException(req.path); } diff --git a/api/src/extensions.ts b/api/src/extensions.ts index 60dcfc8f98..8ba143c281 100644 --- a/api/src/extensions.ts +++ b/api/src/extensions.ts @@ -26,7 +26,7 @@ import fse from 'fs-extra'; import { getSchema } from './utils/get-schema'; import * as services from './services'; -import { schedule, validate } from 'node-cron'; +import { schedule, ScheduledTask, validate } from 'node-cron'; import { REGEX_BETWEEN_PARENS } from '@directus/shared/constants'; import { rollup } from 'rollup'; // @TODO Remove this once a new version of @rollup/plugin-virtual has been released @@ -35,133 +35,188 @@ import virtual from '@rollup/plugin-virtual'; import alias from '@rollup/plugin-alias'; import { Url } from './utils/url'; import getModuleDefault from './utils/get-module-default'; +import { ListenerFn } from 'eventemitter2'; -let extensions: Extension[] = []; -let extensionBundles: Partial> = {}; -const registeredHooks: string[] = []; +let extensionManager: ExtensionManager | undefined; -export async function initializeExtensions(): Promise { - try { - await ensureExtensionDirs(env.EXTENSIONS_PATH, env.SERVE_APP ? EXTENSION_TYPES : API_EXTENSION_TYPES); - extensions = await getExtensions(); - } catch (err: any) { - logger.warn(`Couldn't load extensions`); - logger.warn(err); +export function getExtensionManager(): ExtensionManager { + if (extensionManager) { + return extensionManager; } - if (env.SERVE_APP) { - extensionBundles = await generateExtensionBundles(); + extensionManager = new ExtensionManager(); + + return extensionManager; +} + +class ExtensionManager { + private isInitialized = false; + + private extensions: Extension[] = []; + + private appExtensions: Partial> = {}; + + private apiHooks: ( + | { type: 'cron'; path: string; task: ScheduledTask } + | { type: 'event'; path: string; event: string; handler: ListenerFn } + )[] = []; + private apiEndpoints: { path: string }[] = []; + + private endpointRouter: Router; + + constructor() { + this.endpointRouter = Router(); } - const loadedExtensions = listExtensions(); - if (loadedExtensions.length > 0) { - logger.info(`Loaded extensions: ${loadedExtensions.join(', ')}`); - } -} + public async initialize(): Promise { + if (this.isInitialized) return; -export function listExtensions(type?: ExtensionType): string[] { - if (type === undefined) { - return extensions.map((extension) => extension.name); - } else { - return extensions.filter((extension) => extension.type === type).map((extension) => extension.name); - } -} - -export function getAppExtensionSource(type: AppExtensionType): string | undefined { - return extensionBundles[type]; -} - -export function registerExtensionEndpoints(router: Router): void { - const endpoints = extensions.filter((extension) => extension.type === 'endpoint'); - registerEndpoints(endpoints, router); -} - -export function registerExtensionHooks(): void { - const hooks = extensions.filter((extension) => extension.type === 'hook'); - registerHooks(hooks); -} - -async function getExtensions(): Promise { - const packageExtensions = await getPackageExtensions( - '.', - env.SERVE_APP ? EXTENSION_PACKAGE_TYPES : API_EXTENSION_PACKAGE_TYPES - ); - const localExtensions = await getLocalExtensions( - env.EXTENSIONS_PATH, - env.SERVE_APP ? EXTENSION_TYPES : API_EXTENSION_TYPES - ); - - return [...packageExtensions, ...localExtensions]; -} - -async function generateExtensionBundles() { - const sharedDepsMapping = await getSharedDepsMapping(APP_SHARED_DEPS); - const internalImports = Object.entries(sharedDepsMapping).map(([name, path]) => ({ - find: name, - replacement: path, - })); - - const bundles: Partial> = {}; - - for (const extensionType of APP_EXTENSION_TYPES) { - const entry = generateExtensionsEntry(extensionType, extensions); - - const bundle = await rollup({ - input: 'entry', - external: Object.values(sharedDepsMapping), - makeAbsoluteExternalsRelative: false, - plugins: [virtual({ entry }), alias({ entries: internalImports })], - }); - const { output } = await bundle.generate({ format: 'es', compact: true }); - - bundles[extensionType] = output[0].code; - - await bundle.close(); - } - - return bundles; -} - -async function getSharedDepsMapping(deps: string[]) { - const appDir = await fse.readdir(path.join(resolvePackage('@directus/app'), 'dist')); - - const depsMapping: Record = {}; - for (const dep of deps) { - const depName = appDir.find((file) => dep.replace(/\//g, '_') === file.substring(0, file.indexOf('.'))); - - if (depName) { - const depUrl = new Url(env.PUBLIC_URL).addPath('admin', depName); - - depsMapping[dep] = depUrl.toString({ rootRelative: true }); - } else { - logger.warn(`Couldn't find shared extension dependency "${dep}"`); - } - } - - return depsMapping; -} - -function registerHooks(hooks: Extension[]) { - for (const hook of hooks) { try { - registerHook(hook); - } catch (error: any) { - logger.warn(`Couldn't register hook "${hook.name}"`); - logger.warn(error); + await ensureExtensionDirs(env.EXTENSIONS_PATH, env.SERVE_APP ? EXTENSION_TYPES : API_EXTENSION_TYPES); + + this.extensions = await this.getExtensions(); + } catch (err: any) { + logger.warn(`Couldn't load extensions`); + logger.warn(err); + } + + this.registerHooks(); + this.registerEndpoints(); + + if (env.SERVE_APP) { + this.appExtensions = await this.generateExtensionBundles(); + } + + const loadedExtensions = this.listExtensions(); + if (loadedExtensions.length > 0) { + logger.info(`Loaded extensions: ${loadedExtensions.join(', ')}`); + } + + this.isInitialized = true; + } + + public async reload(): Promise { + if (!this.isInitialized) return; + + logger.info('Reloading extensions'); + + this.unregisterHooks(); + this.unregisterEndpoints(); + + if (env.SERVE_APP) { + this.appExtensions = {}; + } + + this.isInitialized = false; + await this.initialize(); + } + + public listExtensions(type?: ExtensionType): string[] { + if (type === undefined) { + return this.extensions.map((extension) => extension.name); + } else { + return this.extensions.filter((extension) => extension.type === type).map((extension) => extension.name); } } - function registerHook(hook: Extension) { + public getAppExtensions(type: AppExtensionType): string | undefined { + return this.appExtensions[type]; + } + + public getEndpointRouter(): Router { + return this.endpointRouter; + } + + private async getExtensions(): Promise { + const packageExtensions = await getPackageExtensions( + '.', + env.SERVE_APP ? EXTENSION_PACKAGE_TYPES : API_EXTENSION_PACKAGE_TYPES + ); + const localExtensions = await getLocalExtensions( + env.EXTENSIONS_PATH, + env.SERVE_APP ? EXTENSION_TYPES : API_EXTENSION_TYPES + ); + + return [...packageExtensions, ...localExtensions]; + } + + private async generateExtensionBundles() { + const sharedDepsMapping = await this.getSharedDepsMapping(APP_SHARED_DEPS); + const internalImports = Object.entries(sharedDepsMapping).map(([name, path]) => ({ + find: name, + replacement: path, + })); + + const bundles: Partial> = {}; + + for (const extensionType of APP_EXTENSION_TYPES) { + const entry = generateExtensionsEntry(extensionType, this.extensions); + + const bundle = await rollup({ + input: 'entry', + external: Object.values(sharedDepsMapping), + makeAbsoluteExternalsRelative: false, + plugins: [virtual({ entry }), alias({ entries: internalImports })], + }); + const { output } = await bundle.generate({ format: 'es', compact: true }); + + bundles[extensionType] = output[0].code; + + await bundle.close(); + } + + return bundles; + } + + private async getSharedDepsMapping(deps: string[]) { + const appDir = await fse.readdir(path.join(resolvePackage('@directus/app'), 'dist')); + + const depsMapping: Record = {}; + for (const dep of deps) { + const depName = appDir.find((file) => dep.replace(/\//g, '_') === file.substring(0, file.indexOf('.'))); + + if (depName) { + const depUrl = new Url(env.PUBLIC_URL).addPath('admin', depName); + + depsMapping[dep] = depUrl.toString({ rootRelative: true }); + } else { + logger.warn(`Couldn't find shared extension dependency "${dep}"`); + } + } + + return depsMapping; + } + + private registerHooks(): void { + const hooks = this.extensions.filter((extension) => extension.type === 'hook'); + + for (const hook of hooks) { + try { + this.registerHook(hook); + } catch (error: any) { + logger.warn(`Couldn't register hook "${hook.name}"`); + logger.warn(error); + } + } + } + + private registerEndpoints(): void { + const endpoints = this.extensions.filter((extension) => extension.type === 'endpoint'); + + for (const endpoint of endpoints) { + try { + this.registerEndpoint(endpoint, this.endpointRouter); + } catch (error: any) { + logger.warn(`Couldn't register endpoint "${endpoint.name}"`); + logger.warn(error); + } + } + } + + private registerHook(hook: Extension) { const hookPath = path.resolve(hook.path, hook.entrypoint || ''); const hookInstance: HookConfig | { default: HookConfig } = require(hookPath); - // Make sure hooks are only registered once - if (registeredHooks.includes(hookPath)) { - return; - } else { - registeredHooks.push(hookPath); - } - const register = getModuleDefault(hookInstance); const events = register({ services, exceptions, env, database: getDatabase(), logger, getSchema }); @@ -173,43 +228,73 @@ function registerHooks(hooks: Extension[]) { if (!cron || validate(cron) === false) { logger.warn(`Couldn't register cron hook. Provided cron is invalid: ${cron}`); } else { - schedule(cron, async () => { + const task = schedule(cron, async () => { try { await handler(); } catch (error: any) { logger.error(error); } }); + + this.apiHooks.push({ + type: 'cron', + path: hookPath, + task, + }); } } else { emitter.on(event, handler); + + this.apiHooks.push({ + type: 'event', + path: hookPath, + event, + handler, + }); } } } -} -function registerEndpoints(endpoints: Extension[], router: Router) { - for (const endpoint of endpoints) { - try { - registerEndpoint(endpoint); - } catch (error: any) { - logger.warn(`Couldn't register endpoint "${endpoint.name}"`); - logger.warn(error); - } - } - - function registerEndpoint(endpoint: Extension) { + private registerEndpoint(endpoint: Extension, router: Router) { const endpointPath = path.resolve(endpoint.path, endpoint.entrypoint || ''); const endpointInstance: EndpointConfig | { default: EndpointConfig } = require(endpointPath); const mod = getModuleDefault(endpointInstance); const register = typeof mod === 'function' ? mod : mod.handler; - const pathName = typeof mod === 'function' ? endpoint.name : mod.id; + const routeName = typeof mod === 'function' ? endpoint.name : mod.id; const scopedRouter = express.Router(); - router.use(`/${pathName}`, scopedRouter); + router.use(`/${routeName}`, scopedRouter); register(scopedRouter, { services, exceptions, env, database: getDatabase(), logger, getSchema }); + + this.apiEndpoints.push({ + path: endpointPath, + }); + } + + private unregisterHooks(): void { + for (const hook of this.apiHooks) { + if (hook.type === 'cron') { + hook.task.destroy(); + } else { + emitter.off(hook.event, hook.handler); + } + + delete require.cache[require.resolve(hook.path)]; + } + + this.apiHooks = []; + } + + private unregisterEndpoints(): void { + for (const endpoint of this.apiEndpoints) { + delete require.cache[require.resolve(endpoint.path)]; + } + + this.endpointRouter.stack = []; + + this.apiEndpoints = []; } } diff --git a/api/src/services/graphql.ts b/api/src/services/graphql.ts index 0fc7bb913f..4e4e131961 100644 --- a/api/src/services/graphql.ts +++ b/api/src/services/graphql.ts @@ -51,7 +51,7 @@ import getDatabase from '../database'; import env from '../env'; import { BaseException } from '@directus/shared/exceptions'; import { ForbiddenException, GraphQLValidationException, InvalidPayloadException } from '../exceptions'; -import { listExtensions } from '../extensions'; +import { getExtensionManager } from '../extensions'; import { Accountability } from '@directus/shared/types'; import { AbstractServiceOptions, Action, Aggregate, GraphQLParams, Item, Query, SchemaOverview } from '../types'; import { getGraphQLType } from '../utils/get-graphql-type'; @@ -1660,12 +1660,16 @@ export class GraphQLService { modules: new GraphQLList(GraphQLString), }, }), - resolve: async () => ({ - interfaces: listExtensions('interface'), - displays: listExtensions('display'), - layouts: listExtensions('layout'), - modules: listExtensions('module'), - }), + resolve: async () => { + const extensionManager = getExtensionManager(); + + return { + interfaces: extensionManager.listExtensions('interface'), + displays: extensionManager.listExtensions('display'), + layouts: extensionManager.listExtensions('layout'), + modules: extensionManager.listExtensions('module'), + }; + }, }, server_specs_oas: { type: GraphQLJSON,