import express, { Router } from 'express'; import path from 'path'; import { ActionHandler, AppExtensionType, EndpointConfig, Extension, ExtensionType, FilterHandler, HookConfig, InitHandler, ScheduleHandler, } from '@directus/shared/types'; import { ensureExtensionDirs, generateExtensionsEntry, getLocalExtensions, getPackageExtensions, resolvePackage, } from '@directus/shared/utils/node'; import { API_EXTENSION_PACKAGE_TYPES, API_EXTENSION_TYPES, APP_EXTENSION_TYPES, APP_SHARED_DEPS, EXTENSION_PACKAGE_TYPES, EXTENSION_TYPES, } from '@directus/shared/constants'; import getDatabase from './database'; import emitter, { Emitter } from './emitter'; import env from './env'; import * as exceptions from './exceptions'; import * as sharedExceptions from '@directus/shared/exceptions'; import logger from './logger'; import fse from 'fs-extra'; import { getSchema } from './utils/get-schema'; import * as services from './services'; import { schedule, ScheduledTask, validate } from 'node-cron'; import { rollup } from 'rollup'; 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 { clone, escapeRegExp } from 'lodash'; import chokidar, { FSWatcher } from 'chokidar'; import { pluralize } from '@directus/shared/utils'; let extensionManager: ExtensionManager | undefined; export function getExtensionManager(): ExtensionManager { if (extensionManager) { return extensionManager; } extensionManager = new ExtensionManager(); return extensionManager; } type EventHandler = | { type: 'filter'; name: string; handler: FilterHandler } | { type: 'action'; name: string; handler: ActionHandler } | { type: 'init'; name: string; handler: InitHandler } | { type: 'schedule'; task: ScheduledTask }; type AppExtensions = Partial>; type ApiExtensions = { hooks: { path: string; events: EventHandler[] }[]; endpoints: { path: string }[]; }; type Options = { schedule: boolean; watch: boolean; }; const defaultOptions: Options = { schedule: true, watch: env.EXTENSIONS_AUTO_RELOAD && env.NODE_ENV !== 'development', }; class ExtensionManager { private isLoaded = false; private options: Options; private extensions: Extension[] = []; private appExtensions: AppExtensions = {}; private apiExtensions: ApiExtensions = { hooks: [], endpoints: [] }; private apiEmitter: Emitter; private endpointRouter: Router; private watcher: FSWatcher | null = null; constructor() { this.options = defaultOptions; this.apiEmitter = new Emitter(); this.endpointRouter = Router(); } public async initialize(options: Partial = {}): Promise { this.options = { ...defaultOptions, ...options, }; this.initializeWatcher(); if (!this.isLoaded) { await this.load(); this.updateWatchedExtensions(this.extensions); const loadedExtensions = this.getExtensionsList(); if (loadedExtensions.length > 0) { logger.info(`Loaded extensions: ${loadedExtensions.join(', ')}`); } } } public async reload(): Promise { if (this.isLoaded) { logger.info('Reloading extensions'); const prevExtensions = clone(this.extensions); await this.unload(); await this.load(); const added = this.extensions.filter( (extension) => !prevExtensions.some((prevExtension) => extension.path === prevExtension.path) ); const removed = prevExtensions.filter( (prevExtension) => !this.extensions.some((extension) => prevExtension.path === extension.path) ); this.updateWatchedExtensions(added, removed); const addedExtensions = added.map((extension) => extension.name); const removedExtensions = removed.map((extension) => extension.name); if (addedExtensions.length > 0) { logger.info(`Added extensions: ${addedExtensions.join(', ')}`); } if (removedExtensions.length > 0) { logger.info(`Removed extensions: ${removedExtensions.join(', ')}`); } } else { logger.warn('Extensions have to be loaded before they can be reloaded'); } } public getExtensionsList(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); } } public getAppExtensions(type: AppExtensionType): string | undefined { return this.appExtensions[type]; } public getEndpointRouter(): Router { return this.endpointRouter; } private async load(): Promise { try { 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(); } this.isLoaded = true; } private async unload(): Promise { this.unregisterHooks(); this.unregisterEndpoints(); this.apiEmitter.offAll(); if (env.SERVE_APP) { this.appExtensions = {}; } this.isLoaded = false; } private initializeWatcher(): void { if (this.options.watch && !this.watcher) { logger.info('Watching extensions for changes...'); const localExtensionPaths = (env.SERVE_APP ? EXTENSION_TYPES : API_EXTENSION_TYPES).map((type) => path.posix.join( path.relative('.', env.EXTENSIONS_PATH).split(path.sep).join(path.posix.sep), pluralize(type), '*', 'index.js' ) ); this.watcher = chokidar.watch([path.resolve('.', 'package.json'), ...localExtensionPaths], { ignoreInitial: true, }); this.watcher .on('add', () => this.reload()) .on('change', () => this.reload()) .on('unlink', () => this.reload()); } } private updateWatchedExtensions(added: Extension[], removed: Extension[] = []): void { if (this.watcher) { const toPackageExtensionPaths = (extensions: Extension[]) => extensions .filter((extension) => !extension.local) .map((extension) => extension.type !== 'pack' ? path.resolve(extension.path, extension.entrypoint || '') : path.resolve(extension.path, 'package.json') ); const addedPackageExtensionPaths = toPackageExtensionPaths(added); const removedPackageExtensionPaths = toPackageExtensionPaths(removed); this.watcher.add(addedPackageExtensionPaths); this.watcher.unwatch(removedPackageExtensionPaths); } } 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); try { 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(); } catch (error: any) { logger.warn(`Couldn't bundle App extensions`); logger.warn(error); } } return bundles; } private async getSharedDepsMapping(deps: string[]) { const appDir = await fse.readdir(path.join(resolvePackage('@directus/app'), 'dist', 'assets')); const depsMapping: Record = {}; for (const dep of deps) { const depRegex = new RegExp(`${escapeRegExp(dep.replace(/\//g, '_'))}\\.[0-9a-f]{8}\\.entry\\.js`); const depName = appDir.find((file) => depRegex.test(file)); if (depName) { const depUrl = new Url(env.PUBLIC_URL).addPath('admin', 'assets', 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); const register = getModuleDefault(hookInstance); const hookHandler: { path: string; events: EventHandler[] } = { path: hookPath, events: [], }; const registerFunctions = { filter: (event: string, handler: FilterHandler) => { emitter.onFilter(event, handler); hookHandler.events.push({ type: 'filter', name: event, handler, }); }, action: (event: string, handler: ActionHandler) => { emitter.onAction(event, handler); hookHandler.events.push({ type: 'action', name: event, handler, }); }, init: (event: string, handler: InitHandler) => { emitter.onInit(event, handler); hookHandler.events.push({ type: 'init', name: event, handler, }); }, schedule: (cron: string, handler: ScheduleHandler) => { if (validate(cron)) { const task = schedule(cron, async () => { if (this.options.schedule) { try { await handler(); } catch (error: any) { logger.error(error); } } }); hookHandler.events.push({ type: 'schedule', task, }); } else { logger.warn(`Couldn't register cron hook. Provided cron is invalid: ${cron}`); } }, }; register(registerFunctions, { services, exceptions: { ...exceptions, ...sharedExceptions }, env, database: getDatabase(), emitter: this.apiEmitter, logger, getSchema, }); this.apiExtensions.hooks.push(hookHandler); } 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 routeName = typeof mod === 'function' ? endpoint.name : mod.id; const scopedRouter = express.Router(); router.use(`/${routeName}`, scopedRouter); register(scopedRouter, { services, exceptions: { ...exceptions, ...sharedExceptions }, env, database: getDatabase(), emitter: this.apiEmitter, logger, getSchema, }); this.apiExtensions.endpoints.push({ path: endpointPath, }); } private unregisterHooks(): void { for (const hook of this.apiExtensions.hooks) { for (const event of hook.events) { switch (event.type) { case 'filter': emitter.offFilter(event.name, event.handler); break; case 'action': emitter.offAction(event.name, event.handler); break; case 'init': emitter.offInit(event.name, event.handler); break; case 'schedule': event.task.stop(); break; } } delete require.cache[require.resolve(hook.path)]; } this.apiExtensions.hooks = []; } private unregisterEndpoints(): void { for (const endpoint of this.apiExtensions.endpoints) { delete require.cache[require.resolve(endpoint.path)]; } this.endpointRouter.stack = []; this.apiExtensions.endpoints = []; } }