Add support for a package extension bundle type (#15672)

* Add bundle type to constants and types

* Add support for API bundle extensions

* Rename generateExtensionsEntry to generateExtensionsEntrypoint

* Add support for App bundle extensions

* Refactor App extension registration

* Replace extensions inject with useExtensions()

* Replace getInterfaces() with useExtensions()

* Replace getDisplays() with useExtensions()

* Replace getLayouts() with useExtensions()

* Replace getModules() with useExtensions()

* Replace getPanels() with useExtensions()

* Replace getOperations() with useExtensions()

* Add useExtension() composable

* Replace useExtensions() with useExtension() where applicable

* Remove interface getters

* Remove display getters

* Remove layout getters

* Remove module getter

* Remove panel getters

* Remove operation getters

* Rename extension register.ts files to index.ts

* Perform module pre register check in parallel

* Remove Refs from AppExtensionConfigs type

* Remove old extension shims

* Ensure registration of modules is awaited when hydrating

* Add support for scaffolding package extensions

* Add support for building bundle extensions

* Add JsonValue type

* Use json for complex command line flags

* Load internal extensions if custom ones are not available

* Fix extension manifest validation for pack extensions

* Fix tests in shared

* Add SplitEntrypoint type

* Move command specific utils to helpers

* Add SDK version getter

* Move extension dev deps generation to helpers

* Move template path to getter util

* Move template copying to a helper

* Only rename copied template files

* Add directus-extension add command

* Convert provided extension source path to url

* Replace deprecated import.meta.globEager

* Mock URL.createObjectURL to make App unit tests pass

* Update rollup-plugin-typescript2

* indentation

* sort vite glob imported modules

* fix unintentional wrong commit

* Simplify app extension import logic

* reinstall @rollup/plugin-virtual

* add test for getInterfaces() expected sort order

Co-authored-by: Rijk van Zanten <rijkvanzanten@me.com>
Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com>
This commit is contained in:
Nicola Krumschmidt
2022-11-16 17:28:52 +01:00
committed by GitHub
parent 0859102a61
commit 7bf90efa62
127 changed files with 1898 additions and 957 deletions

View File

@@ -35,27 +35,20 @@ router.get(
);
router.get(
'/:type/index.js',
'/sources/index.js',
asyncHandler(async (req, res) => {
const type = depluralize(req.params.type as Plural<string>);
if (!isIn(type, APP_OR_HYBRID_EXTENSION_TYPES)) {
throw new RouteNotFoundException(req.path);
}
const extensionManager = getExtensionManager();
const extensionSource = extensionManager.getAppExtensions(type);
if (extensionSource === undefined) {
const extensionSource = extensionManager.getAppExtensions();
if (extensionSource === null) {
throw new RouteNotFoundException(req.path);
}
res.setHeader('Content-Type', 'application/javascript; charset=UTF-8');
if (env.EXTENSIONS_CACHE_TTL) {
res.setHeader('Cache-Control', getCacheControlHeader(req, ms(env.EXTENSIONS_CACHE_TTL as string)));
} else {
res.setHeader('Cache-Control', 'no-store');
}
res.setHeader(
'Cache-Control',
env.EXTENSIONS_CACHE_TTL ? getCacheControlHeader(req, ms(env.EXTENSIONS_CACHE_TTL as string)) : 'no-store'
);
res.setHeader('Vary', 'Origin, Cache-Control');
res.end(extensionSource);
})

View File

@@ -3,21 +3,20 @@ import path from 'path';
import {
ActionHandler,
ApiExtension,
AppExtensionType,
BundleExtension,
EndpointConfig,
Extension,
ExtensionType,
FilterHandler,
HookConfig,
HybridExtension,
HybridExtensionType,
InitHandler,
OperationApiConfig,
ScheduleHandler,
} from '@directus/shared/types';
import {
ensureExtensionDirs,
generateExtensionsEntry,
generateExtensionsEntrypoint,
getLocalExtensions,
getPackageExtensions,
pathToRelativeUrl,
@@ -26,12 +25,10 @@ import {
import {
API_OR_HYBRID_EXTENSION_PACKAGE_TYPES,
API_OR_HYBRID_EXTENSION_TYPES,
APP_OR_HYBRID_EXTENSION_TYPES,
APP_SHARED_DEPS,
EXTENSION_PACKAGE_TYPES,
EXTENSION_TYPES,
HYBRID_EXTENSION_TYPES,
PACKAGE_EXTENSION_TYPES,
} from '@directus/shared/constants';
import getDatabase from './database';
import emitter, { Emitter } from './emitter';
@@ -69,13 +66,15 @@ export function getExtensionManager(): ExtensionManager {
return extensionManager;
}
type AppExtensions = Partial<Record<AppExtensionType | HybridExtensionType, string>>;
type ApiExtensions = {
hooks: { path: string; events: EventHandler[] }[];
endpoints: { path: string }[];
operations: { path: string }[];
type BundleConfig = {
endpoints: { name: string; config: EndpointConfig }[];
hooks: { name: string; config: HookConfig }[];
operations: { name: string; config: OperationApiConfig }[];
};
type AppExtensions = string | null;
type ApiExtensions = { path: string }[];
type Options = {
schedule: boolean;
watch: boolean;
@@ -92,10 +91,11 @@ class ExtensionManager {
private extensions: Extension[] = [];
private appExtensions: AppExtensions = {};
private apiExtensions: ApiExtensions = { hooks: [], endpoints: [], operations: [] };
private appExtensions: AppExtensions = null;
private apiExtensions: ApiExtensions = [];
private apiEmitter: Emitter;
private hookEvents: EventHandler[] = [];
private endpointRouter: Router;
private reloadQueue: JobQueue;
@@ -179,8 +179,8 @@ class ExtensionManager {
}
}
public getAppExtensions(type: AppExtensionType | HybridExtensionType): string | undefined {
return this.appExtensions[type];
public getAppExtensions(): string | null {
return this.appExtensions;
}
public getEndpointRouter(): Router {
@@ -198,25 +198,24 @@ class ExtensionManager {
}
await this.registerHooks();
this.registerEndpoints();
await this.registerEndpoints();
await this.registerOperations();
await this.registerBundles();
if (env.SERVE_APP) {
this.appExtensions = await this.generateExtensionBundles();
this.appExtensions = await this.generateExtensionBundle();
}
this.isLoaded = true;
}
private async unload(): Promise<void> {
this.unregisterHooks();
this.unregisterEndpoints();
this.unregisterOperations();
this.unregisterApiExtensions();
this.apiEmitter.offAll();
if (env.SERVE_APP) {
this.appExtensions = {};
this.appExtensions = null;
}
this.isLoaded = false;
@@ -257,9 +256,9 @@ class ExtensionManager {
extensions
.filter((extension) => !extension.local)
.flatMap((extension) =>
isTypeIn(extension, PACKAGE_EXTENSION_TYPES)
extension.type === 'pack'
? path.resolve(extension.path, 'package.json')
: isTypeIn(extension, HYBRID_EXTENSION_TYPES)
: isTypeIn(extension, HYBRID_EXTENSION_TYPES) || extension.type === 'bundle'
? [
path.resolve(extension.path, extension.entrypoint.app),
path.resolve(extension.path, extension.entrypoint.api),
@@ -288,40 +287,36 @@ class ExtensionManager {
return [...packageExtensions, ...localExtensions];
}
private async generateExtensionBundles() {
private async generateExtensionBundle(): Promise<string | null> {
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 | HybridExtensionType, string>> = {};
const entrypoint = generateExtensionsEntrypoint(this.extensions);
for (const extensionType of APP_OR_HYBRID_EXTENSION_TYPES) {
const entry = generateExtensionsEntry(extensionType, this.extensions);
try {
const bundle = await rollup({
input: 'entry',
external: Object.values(sharedDepsMapping),
makeAbsoluteExternalsRelative: false,
plugins: [virtual({ entry: entrypoint }), alias({ entries: internalImports })],
});
const { output } = await bundle.generate({ format: 'es', compact: true });
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 });
await bundle.close();
bundles[extensionType] = output[0].code;
await bundle.close();
} catch (error: any) {
logger.warn(`Couldn't bundle App extensions`);
logger.warn(error);
}
return output[0].code;
} catch (error: any) {
logger.warn(`Couldn't bundle App extensions`);
logger.warn(error);
}
return bundles;
return null;
}
private async getSharedDepsMapping(deps: string[]) {
private async getSharedDepsMapping(deps: string[]): Promise<Record<string, string>> {
const appDir = await fse.readdir(path.join(resolvePackage('@directus/app', __dirname), 'dist', 'assets'));
const depsMapping: Record<string, string> = {};
@@ -350,7 +345,9 @@ class ExtensionManager {
const config = getModuleDefault(hookInstance);
this.registerHook(config, hookPath);
this.registerHook(config);
this.apiExtensions.push({ path: hookPath });
} catch (error: any) {
logger.warn(`Couldn't register hook "${hook.name}"`);
logger.warn(error);
@@ -358,7 +355,7 @@ class ExtensionManager {
}
}
private registerEndpoints(): void {
private async registerEndpoints(): Promise<void> {
const endpoints = this.extensions.filter((extension): extension is ApiExtension => extension.type === 'endpoint');
for (const endpoint of endpoints) {
@@ -368,7 +365,9 @@ class ExtensionManager {
const config = getModuleDefault(endpointInstance);
this.registerEndpoint(config, endpointPath, endpoint.name, this.endpointRouter);
this.registerEndpoint(config, endpoint.name);
this.apiExtensions.push({ path: endpointPath });
} catch (error: any) {
logger.warn(`Couldn't register endpoint "${endpoint.name}"`);
logger.warn(error);
@@ -400,7 +399,9 @@ class ExtensionManager {
const config = getModuleDefault(operationInstance);
this.registerOperation(config, operationPath);
this.registerOperation(config);
this.apiExtensions.push({ path: operationPath });
} catch (error: any) {
logger.warn(`Couldn't register operation "${operation.name}"`);
logger.warn(error);
@@ -408,17 +409,42 @@ class ExtensionManager {
}
}
private registerHook(register: HookConfig, path: string) {
const hookHandler: { path: string; events: EventHandler[] } = {
path,
events: [],
};
private async registerBundles(): Promise<void> {
const bundles = this.extensions.filter((extension): extension is BundleExtension => extension.type === 'bundle');
for (const bundle of bundles) {
try {
const bundlePath = path.resolve(bundle.path, bundle.entrypoint.api);
const bundleInstances: BundleConfig | { default: BundleConfig } = require(bundlePath);
const configs = getModuleDefault(bundleInstances);
for (const { config } of configs.hooks) {
this.registerHook(config);
}
for (const { config, name } of configs.endpoints) {
this.registerEndpoint(config, name);
}
for (const { config } of configs.operations) {
this.registerOperation(config);
}
this.apiExtensions.push({ path: bundlePath });
} catch (error: any) {
logger.warn(`Couldn't register bundle "${bundle.name}"`);
logger.warn(error);
}
}
}
private registerHook(register: HookConfig): void {
const registerFunctions = {
filter: (event: string, handler: FilterHandler) => {
emitter.onFilter(event, handler);
hookHandler.events.push({
this.hookEvents.push({
type: 'filter',
name: event,
handler,
@@ -427,7 +453,7 @@ class ExtensionManager {
action: (event: string, handler: ActionHandler) => {
emitter.onAction(event, handler);
hookHandler.events.push({
this.hookEvents.push({
type: 'action',
name: event,
handler,
@@ -436,7 +462,7 @@ class ExtensionManager {
init: (event: string, handler: InitHandler) => {
emitter.onInit(event, handler);
hookHandler.events.push({
this.hookEvents.push({
type: 'init',
name: event,
handler,
@@ -454,7 +480,7 @@ class ExtensionManager {
}
});
hookHandler.events.push({
this.hookEvents.push({
type: 'schedule',
task,
});
@@ -473,16 +499,14 @@ class ExtensionManager {
logger,
getSchema,
});
this.apiExtensions.hooks.push(hookHandler);
}
private registerEndpoint(config: EndpointConfig, path: string, name: string, router: Router) {
private registerEndpoint(config: EndpointConfig, name: string): void {
const register = typeof config === 'function' ? config : config.handler;
const routeName = typeof config === 'function' ? name : config.id;
const scopedRouter = express.Router();
router.use(`/${routeName}`, scopedRouter);
this.endpointRouter.use(`/${routeName}`, scopedRouter);
register(scopedRouter, {
services,
@@ -493,66 +517,44 @@ class ExtensionManager {
logger,
getSchema,
});
this.apiExtensions.endpoints.push({
path,
});
}
private registerOperation(config: OperationApiConfig, path: string) {
private registerOperation(config: OperationApiConfig): void {
const flowManager = getFlowManager();
flowManager.addOperation(config.id, config.handler);
this.apiExtensions.operations.push({
path,
});
}
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;
}
private unregisterApiExtensions(): void {
for (const event of this.hookEvents) {
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.hookEvents = [];
this.endpointRouter.stack = [];
this.apiExtensions.endpoints = [];
}
private unregisterOperations(): void {
for (const operation of this.apiExtensions.operations) {
delete require.cache[require.resolve(operation.path)];
}
const flowManager = getFlowManager();
flowManager.clearOperations();
this.apiExtensions.operations = [];
for (const apiExtension of this.apiExtensions) {
delete require.cache[require.resolve(apiExtension.path)];
}
this.apiExtensions = [];
}
}