diff --git a/api/src/controllers/extensions.ts b/api/src/controllers/extensions.ts index 4ac36e15e3..83953c2118 100644 --- a/api/src/controllers/extensions.ts +++ b/api/src/controllers/extensions.ts @@ -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); - - 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); }) diff --git a/api/src/extensions.ts b/api/src/extensions.ts index 4777c0e8fe..a02b4a27af 100644 --- a/api/src/extensions.ts +++ b/api/src/extensions.ts @@ -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>; -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 { - 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 { const sharedDepsMapping = await this.getSharedDepsMapping(APP_SHARED_DEPS); const internalImports = Object.entries(sharedDepsMapping).map(([name, path]) => ({ find: name, replacement: path, })); - const bundles: Partial> = {}; + 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> { const appDir = await fse.readdir(path.join(resolvePackage('@directus/app', __dirname), 'dist', 'assets')); const depsMapping: Record = {}; @@ -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 { 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 { + 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 = []; } } diff --git a/app/src/__setup__/mock-globals.ts b/app/src/__setup__/mock-globals.ts new file mode 100644 index 0000000000..9933aa1170 --- /dev/null +++ b/app/src/__setup__/mock-globals.ts @@ -0,0 +1,21 @@ +import { afterEach } from 'vitest'; + +const originals = new Map(); + +originals.set('createObjectURL', globalThis.URL.createObjectURL); + +Object.defineProperty(globalThis.URL, 'createObjectURL', { + value: () => 'blob:http://localhost/0', + writable: true, + enumerable: false, + configurable: true, +}); + +afterEach(() => { + Object.defineProperty(globalThis.URL, 'createObjectURL', { + value: originals.get('createObjectURL'), + writable: true, + enumerable: false, + configurable: true, + }); +}); diff --git a/app/src/components/v-form/form-field-interface.vue b/app/src/components/v-form/form-field-interface.vue index 79646d050c..60a8d8483f 100644 --- a/app/src/components/v-form/form-field-interface.vue +++ b/app/src/components/v-form/form-field-interface.vue @@ -48,8 +48,8 @@ import { useI18n } from 'vue-i18n'; import { computed } from 'vue'; import { Field } from '@directus/shared/types'; -import { getInterface } from '@/interfaces'; import { getDefaultInterfaceForType } from '@/utils/get-default-interface-for-type'; +import { useExtension } from '@/composables/use-extension'; interface Props { field: Field; @@ -82,7 +82,12 @@ defineEmits(['update:modelValue', 'setFieldValue']); const { t } = useI18n(); -const interfaceExists = computed(() => !!getInterface(props.field?.meta?.interface || 'input')); +const inter = useExtension( + 'interface', + computed(() => props.field?.meta?.interface ?? 'input') +); + +const interfaceExists = computed(() => !!inter.value);