mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
Move extension management into a class (#8478)
This commit is contained in:
committed by
GitHub
parent
2be2c36dc2
commit
c816b3be92
@@ -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<express.Application> {
|
||||
|
||||
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<express.Application> {
|
||||
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);
|
||||
|
||||
@@ -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<Command> {
|
||||
const program = new Command();
|
||||
|
||||
await initializeExtensions();
|
||||
registerExtensionHooks();
|
||||
const extensionManager = getExtensionManager();
|
||||
|
||||
await extensionManager.initialize();
|
||||
|
||||
await emitAsyncSafe('cli.init.before', { program });
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<Record<AppExtensionType, string>> = {};
|
||||
const registeredHooks: string[] = [];
|
||||
let extensionManager: ExtensionManager | undefined;
|
||||
|
||||
export async function initializeExtensions(): Promise<void> {
|
||||
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<Record<AppExtensionType, string>> = {};
|
||||
|
||||
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<void> {
|
||||
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<Extension[]> {
|
||||
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<Record<AppExtensionType, string>> = {};
|
||||
|
||||
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<string, string> = {};
|
||||
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<void> {
|
||||
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<Extension[]> {
|
||||
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<Record<AppExtensionType, string>> = {};
|
||||
|
||||
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<string, string> = {};
|
||||
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 = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user