directus
931 строка · 26.6 Кб
1import { useEnv } from '@directus/env';
2import type {
3ApiExtension,
4BundleExtension,
5EndpointConfig,
6Extension,
7ExtensionSettings,
8HookConfig,
9HybridExtension,
10OperationApiConfig,
11} from '@directus/extensions';
12import { APP_SHARED_DEPS, HYBRID_EXTENSION_TYPES } from '@directus/extensions';
13import { generateExtensionsEntrypoint } from '@directus/extensions/node';
14import type {
15ActionHandler,
16EmbedHandler,
17FilterHandler,
18InitHandler,
19PromiseCallback,
20ScheduleHandler,
21} from '@directus/types';
22import { isTypeIn, toBoolean } from '@directus/utils';
23import { pathToRelativeUrl, processId } from '@directus/utils/node';
24import aliasDefault from '@rollup/plugin-alias';
25import nodeResolveDefault from '@rollup/plugin-node-resolve';
26import virtualDefault from '@rollup/plugin-virtual';
27import chokidar, { FSWatcher } from 'chokidar';
28import express, { Router } from 'express';
29import ivm from 'isolated-vm';
30import { clone, debounce, isPlainObject } from 'lodash-es';
31import { readFile, readdir } from 'node:fs/promises';
32import os from 'node:os';
33import { dirname } from 'node:path';
34import { fileURLToPath } from 'node:url';
35import path from 'path';
36import { rollup } from 'rollup';
37import { useBus } from '../bus/index.js';
38import getDatabase from '../database/index.js';
39import emitter, { Emitter } from '../emitter.js';
40import { getFlowManager } from '../flows.js';
41import { useLogger } from '../logger.js';
42import * as services from '../services/index.js';
43import { deleteFromRequireCache } from '../utils/delete-from-require-cache.js';
44import getModuleDefault from '../utils/get-module-default.js';
45import { getSchema } from '../utils/get-schema.js';
46import { importFileUrl } from '../utils/import-file-url.js';
47import { JobQueue } from '../utils/job-queue.js';
48import { scheduleSynchronizedJob, validateCron } from '../utils/schedule.js';
49import { getExtensionsPath } from './lib/get-extensions-path.js';
50import { getExtensionsSettings } from './lib/get-extensions-settings.js';
51import { getExtensions } from './lib/get-extensions.js';
52import { getSharedDepsMapping } from './lib/get-shared-deps-mapping.js';
53import { getInstallationManager } from './lib/installation/index.js';
54import type { InstallationManager } from './lib/installation/manager.js';
55import { generateApiExtensionsSandboxEntrypoint } from './lib/sandbox/generate-api-extensions-sandbox-entrypoint.js';
56import { instantiateSandboxSdk } from './lib/sandbox/sdk/instantiate.js';
57import { syncExtensions } from './lib/sync-extensions.js';
58import { wrapEmbeds } from './lib/wrap-embeds.js';
59import type { BundleConfig, ExtensionManagerOptions } from './types.js';
60
61// Workaround for https://github.com/rollup/plugins/issues/1329
62const virtual = virtualDefault as unknown as typeof virtualDefault.default;
63const alias = aliasDefault as unknown as typeof aliasDefault.default;
64const nodeResolve = nodeResolveDefault as unknown as typeof nodeResolveDefault.default;
65
66const __dirname = dirname(fileURLToPath(import.meta.url));
67
68const env = useEnv();
69
70const defaultOptions: ExtensionManagerOptions = {
71schedule: true,
72watch: env['EXTENSIONS_AUTO_RELOAD'] as boolean,
73};
74
75export class ExtensionManager {
76private options: ExtensionManagerOptions = defaultOptions;
77
78/**
79* Whether or not the extensions have been read from disk and registered into the system
80*/
81private isLoaded = false;
82
83// folder:Extension
84private localExtensions: Map<string, Extension> = new Map();
85
86// versionId:Extension
87private registryExtensions: Map<string, Extension> = new Map();
88
89// name:Extension
90private moduleExtensions: Map<string, Extension> = new Map();
91
92/**
93* Settings for the extensions that are loaded within the current process
94*/
95private extensionsSettings: ExtensionSettings[] = [];
96
97/**
98* App extensions rolled up into a single bundle. Any chunks from the bundle will be available
99* under appExtensionChunks
100*/
101private appExtensionsBundle: string | null = null;
102
103/**
104* Individual filename chunks from the rollup bundle. Used to improve the performance by allowing
105* extensions to split up their bundle into multiple smaller chunks
106*/
107private appExtensionChunks: Map<string, string> = new Map();
108
109/**
110* Callbacks to be able to unregister extensions
111*/
112private unregisterFunctionMap: Map<string, PromiseCallback> = new Map();
113
114/**
115* A local-to-extensions scoped emitter that can be used to fire and listen to custom events
116* between extensions. These events are completely isolated from the core events that trigger
117* hooks etc
118*/
119private localEmitter: Emitter = new Emitter();
120
121/**
122* Locally scoped express router used for custom endpoints. Allows extensions to dynamically
123* register and de-register endpoints without affecting the regular global router
124*/
125private endpointRouter: Router = Router();
126
127/**
128* Custom HTML to be injected at the end of the `<head>` tag of the app's index.html
129*/
130private hookEmbedsHead: string[] = [];
131
132/**
133* Custom HTML to be injected at the end of the `<body>` tag of the app's index.html
134*/
135private hookEmbedsBody: string[] = [];
136
137/**
138* Used to prevent race conditions when reloading extensions. Forces each reload to happen in
139* sequence.
140*/
141private reloadQueue: JobQueue = new JobQueue();
142
143/**
144* Optional file system watcher to auto-reload extensions when the local file system changes
145*/
146private watcher: FSWatcher | null = null;
147
148/**
149* installation manager responsible for installing extensions from registries
150*/
151
152private installationManager: InstallationManager = getInstallationManager();
153
154private messenger = useBus();
155
156/**
157* channel to publish on registering extension from external registry
158*/
159private reloadChannel = `extensions.reload`;
160
161private processId = processId();
162
163public get extensions() {
164return [...this.localExtensions.values(), ...this.registryExtensions.values(), ...this.moduleExtensions.values()];
165}
166
167public getExtension(source: string, folder: string) {
168switch (source) {
169case 'module':
170return this.moduleExtensions.get(folder);
171case 'registry':
172return this.registryExtensions.get(folder);
173case 'local':
174return this.localExtensions.get(folder);
175}
176
177return undefined;
178}
179
180/**
181* Load and register all extensions
182*
183* @param {ExtensionManagerOptions} options - Extension manager configuration options
184* @param {boolean} options.schedule - Whether or not to allow for scheduled (CRON) hook extensions
185* @param {boolean} options.watch - Whether or not to watch the local extensions folder for changes
186*/
187public async initialize(options: Partial<ExtensionManagerOptions> = {}): Promise<void> {
188const logger = useLogger();
189
190this.options = {
191...defaultOptions,
192...options,
193};
194
195const wasWatcherInitialized = this.watcher !== null;
196
197if (this.options.watch && !wasWatcherInitialized) {
198this.initializeWatcher();
199} else if (!this.options.watch && wasWatcherInitialized) {
200await this.closeWatcher();
201}
202
203if (!this.isLoaded) {
204await this.load();
205
206if (this.extensions.length > 0) {
207logger.info(`Loaded extensions: ${this.extensions.map((ext) => ext.name).join(', ')}`);
208}
209}
210
211if (this.options.watch && !wasWatcherInitialized) {
212this.updateWatchedExtensions(Array.from(this.localExtensions.values()));
213}
214
215this.messenger.subscribe(this.reloadChannel, (payload: Record<string, unknown>) => {
216// Ignore requests for reloading that were published by the current process
217if (isPlainObject(payload) && 'origin' in payload && payload['origin'] === this.processId) return;
218this.reload();
219});
220}
221
222/**
223* Installs an external extension from registry
224*/
225public async install(versionId: string): Promise<void> {
226await this.installationManager.install(versionId);
227await this.reload({ forceSync: true });
228await this.broadcastReloadNotification();
229}
230
231public async uninstall(folder: string) {
232await this.installationManager.uninstall(folder);
233await this.reload({ forceSync: true });
234await this.broadcastReloadNotification();
235}
236
237public async broadcastReloadNotification() {
238await this.messenger.publish(this.reloadChannel, { origin: this.processId });
239}
240
241/**
242* Load all extensions from disk and register them in their respective places
243*/
244private async load(options?: { forceSync: boolean }): Promise<void> {
245const logger = useLogger();
246
247if (env['EXTENSIONS_LOCATION']) {
248try {
249await syncExtensions({ force: options?.forceSync ?? false });
250} catch (error) {
251logger.error(`Failed to sync extensions`);
252logger.error(error);
253process.exit(1);
254}
255}
256
257try {
258const { local, registry, module } = await getExtensions();
259
260this.localExtensions = local;
261this.registryExtensions = registry;
262this.moduleExtensions = module;
263
264this.extensionsSettings = await getExtensionsSettings({ local, registry, module });
265} catch (error) {
266this.handleExtensionError({ error, reason: `Couldn't load extensions` });
267}
268
269await Promise.all([this.registerInternalOperations(), this.registerApiExtensions()]);
270
271if (env['SERVE_APP']) {
272this.appExtensionsBundle = await this.generateExtensionBundle();
273}
274
275this.isLoaded = true;
276}
277
278/**
279* Unregister all extensions from the current process
280*/
281private async unload(): Promise<void> {
282await this.unregisterApiExtensions();
283
284this.localEmitter.offAll();
285
286this.appExtensionsBundle = null;
287
288this.isLoaded = false;
289}
290
291/**
292* Reload all the extensions. Will unload if extensions have already been loaded
293*/
294public reload(options?: { forceSync: boolean }): Promise<unknown> {
295if (this.reloadQueue.size > 0) {
296// The pending job in the queue will already handle the additional changes
297return Promise.resolve();
298}
299
300const logger = useLogger();
301
302let resolve: (val?: unknown) => void;
303let reject: (val?: unknown) => void;
304
305const promise = new Promise((res, rej) => {
306resolve = res;
307reject = rej;
308});
309
310this.reloadQueue.enqueue(async () => {
311if (this.isLoaded) {
312const prevExtensions = clone(this.extensions);
313
314await this.unload();
315await this.load(options);
316
317logger.info('Extensions reloaded');
318
319const added = this.extensions.filter(
320(extension) => !prevExtensions.some((prevExtension) => extension.path === prevExtension.path),
321);
322
323const removed = prevExtensions.filter(
324(prevExtension) => !this.extensions.some((extension) => prevExtension.path === extension.path),
325);
326
327this.updateWatchedExtensions(added, removed);
328
329const addedExtensions = added.map((extension) => extension.name);
330const removedExtensions = removed.map((extension) => extension.name);
331
332if (addedExtensions.length > 0) {
333logger.info(`Added extensions: ${addedExtensions.join(', ')}`);
334}
335
336if (removedExtensions.length > 0) {
337logger.info(`Removed extensions: ${removedExtensions.join(', ')}`);
338}
339
340resolve();
341} else {
342logger.warn('Extensions have to be loaded before they can be reloaded');
343reject(new Error('Extensions have to be loaded before they can be reloaded'));
344}
345});
346
347return promise;
348}
349
350/**
351* Return the previously generated app extensions bundle
352*/
353public getAppExtensionsBundle(): string | null {
354return this.appExtensionsBundle;
355}
356
357/**
358* Return the previously generated app extension bundle chunk by name
359*/
360public getAppExtensionChunk(name: string): string | null {
361return this.appExtensionChunks.get(name) ?? null;
362}
363
364/**
365* Return the scoped router for custom endpoints
366*/
367public getEndpointRouter(): Router {
368return this.endpointRouter;
369}
370
371/**
372* Return the custom HTML head and body embeds wrapped in a marker comment
373*/
374public getEmbeds() {
375return {
376head: wrapEmbeds('Custom Embed Head', this.hookEmbedsHead),
377body: wrapEmbeds('Custom Embed Body', this.hookEmbedsBody),
378};
379}
380
381/**
382* Start the chokidar watcher for extensions on the local filesystem
383*/
384private initializeWatcher(): void {
385const logger = useLogger();
386
387logger.info('Watching extensions for changes...');
388
389const extensionDirUrl = pathToRelativeUrl(getExtensionsPath());
390
391this.watcher = chokidar.watch(
392[path.resolve('package.json'), path.posix.join(extensionDirUrl, '*', 'package.json')],
393{
394ignoreInitial: true,
395// dotdirs are watched by default and frequently found in 'node_modules'
396ignored: `${extensionDirUrl}/**/node_modules/**`,
397// on macOS dotdirs in linked extensions are watched too
398followSymlinks: os.platform() === 'darwin' ? false : true,
399},
400);
401
402this.watcher
403.on(
404'add',
405debounce(() => this.reload(), 500),
406)
407.on(
408'change',
409debounce(() => this.reload(), 650),
410)
411.on(
412'unlink',
413debounce(() => this.reload(), 2000),
414);
415}
416
417/**
418* Close and destroy the local filesystem watcher if enabled
419*/
420private async closeWatcher(): Promise<void> {
421if (this.watcher) {
422await this.watcher.close();
423
424this.watcher = null;
425}
426}
427
428/**
429* Update the chokidar watcher configuration when new extensions are added or existing ones
430* removed
431*/
432private updateWatchedExtensions(added: Extension[], removed: Extension[] = []): void {
433if (!this.watcher) return;
434
435const extensionDir = path.resolve(getExtensionsPath());
436const registryDir = path.join(extensionDir, '.registry');
437
438const toPackageExtensionPaths = (extensions: Extension[]) =>
439extensions
440.filter(
441(extension) =>
442extension.local && extension.path.startsWith(extensionDir) && !extension.path.startsWith(registryDir),
443)
444.flatMap((extension) =>
445isTypeIn(extension, HYBRID_EXTENSION_TYPES) || extension.type === 'bundle'
446? [
447path.resolve(extension.path, extension.entrypoint.app),
448path.resolve(extension.path, extension.entrypoint.api),
449]
450: path.resolve(extension.path, extension.entrypoint),
451);
452
453this.watcher.add(toPackageExtensionPaths(added));
454this.watcher.unwatch(toPackageExtensionPaths(removed));
455}
456
457/**
458* Uses rollup to bundle the app extensions together into a single file the app can download and
459* run.
460*/
461private async generateExtensionBundle(): Promise<string | null> {
462const logger = useLogger();
463
464const sharedDepsMapping = await getSharedDepsMapping(APP_SHARED_DEPS);
465
466const internalImports = Object.entries(sharedDepsMapping).map(([name, path]) => ({
467find: name,
468replacement: path,
469}));
470
471const entrypoint = generateExtensionsEntrypoint(
472{ module: this.moduleExtensions, registry: this.registryExtensions, local: this.localExtensions },
473this.extensionsSettings,
474);
475
476try {
477const bundle = await rollup({
478input: 'entry',
479external: Object.values(sharedDepsMapping),
480makeAbsoluteExternalsRelative: false,
481plugins: [virtual({ entry: entrypoint }), alias({ entries: internalImports }), nodeResolve({ browser: true })],
482});
483
484const { output } = await bundle.generate({ format: 'es', compact: true });
485
486for (const out of output) {
487if (out.type === 'chunk') {
488this.appExtensionChunks.set(out.fileName, out.code);
489}
490}
491
492await bundle.close();
493
494return output[0].code;
495} catch (error) {
496logger.warn(`Couldn't bundle App extensions`);
497logger.warn(error);
498}
499
500return null;
501}
502
503private async registerSandboxedApiExtension(extension: ApiExtension | HybridExtension) {
504const logger = useLogger();
505
506const sandboxMemory = Number(env['EXTENSIONS_SANDBOX_MEMORY']);
507const sandboxTimeout = Number(env['EXTENSIONS_SANDBOX_TIMEOUT']);
508
509const entrypointPath = path.resolve(
510extension.path,
511isTypeIn(extension, HYBRID_EXTENSION_TYPES) ? extension.entrypoint.api : extension.entrypoint,
512);
513
514const extensionCode = await readFile(entrypointPath, 'utf-8');
515
516const isolate = new ivm.Isolate({
517memoryLimit: sandboxMemory,
518onCatastrophicError: (error) => {
519logger.error(`Error in API extension sandbox of ${extension.type} "${extension.name}"`);
520logger.error(error);
521
522process.abort();
523},
524});
525
526const context = await isolate.createContext();
527
528const module = await isolate.compileModule(extensionCode, { filename: `file://${entrypointPath}` });
529
530const sdkModule = await instantiateSandboxSdk(isolate, extension.sandbox?.requestedScopes ?? {});
531
532await module.instantiate(context, (specifier) => {
533if (specifier !== 'directus:api') {
534throw new Error('Imports other than "directus:api" are prohibited in API extension sandboxes');
535}
536
537return sdkModule;
538});
539
540await module.evaluate({ timeout: sandboxTimeout });
541
542const cb = await module.namespace.get('default', { reference: true });
543
544const { code, hostFunctions, unregisterFunction } = generateApiExtensionsSandboxEntrypoint(
545extension.type,
546extension.name,
547this.endpointRouter,
548);
549
550await context.evalClosure(code, [cb, ...hostFunctions.map((fn) => new ivm.Reference(fn))], {
551timeout: sandboxTimeout,
552filename: '<extensions-sandbox>',
553});
554
555this.unregisterFunctionMap.set(extension.name, async () => {
556await unregisterFunction();
557
558if (!isolate.isDisposed) isolate.dispose();
559});
560}
561
562private async registerApiExtensions(): Promise<void> {
563const sources = {
564module: this.moduleExtensions,
565registry: this.registryExtensions,
566local: this.localExtensions,
567} as const;
568
569await Promise.all(
570Object.entries(sources).map(async ([source, extensions]) => {
571await Promise.all(
572Array.from(extensions.entries()).map(async ([folder, extension]) => {
573const { id, enabled } = this.extensionsSettings.find(
574(settings) => settings.source === source && settings.folder === folder,
575) ?? { enabled: false };
576
577if (!enabled) return;
578
579switch (extension.type) {
580case 'hook':
581await this.registerHookExtension(extension);
582break;
583case 'endpoint':
584await this.registerEndpointExtension(extension);
585break;
586case 'operation':
587await this.registerOperationExtension(extension);
588break;
589case 'bundle':
590await this.registerBundleExtension(extension, source as 'module' | 'registry' | 'local', id);
591break;
592default:
593return;
594}
595}),
596);
597}),
598);
599}
600
601private async registerHookExtension(hook: ApiExtension) {
602try {
603if (hook.sandbox?.enabled) {
604await this.registerSandboxedApiExtension(hook);
605} else {
606const hookPath = path.resolve(hook.path, hook.entrypoint);
607
608const hookInstance: HookConfig | { default: HookConfig } = await importFileUrl(hookPath, import.meta.url, {
609fresh: true,
610});
611
612const config = getModuleDefault(hookInstance);
613
614const unregisterFunctions = this.registerHook(config, hook.name);
615
616this.unregisterFunctionMap.set(hook.name, async () => {
617await Promise.all(unregisterFunctions.map((fn) => fn()));
618
619deleteFromRequireCache(hookPath);
620});
621}
622} catch (error) {
623this.handleExtensionError({ error, reason: `Couldn't register hook "${hook.name}"` });
624}
625}
626
627private async registerEndpointExtension(endpoint: ApiExtension) {
628try {
629if (endpoint.sandbox?.enabled) {
630await this.registerSandboxedApiExtension(endpoint);
631} else {
632const endpointPath = path.resolve(endpoint.path, endpoint.entrypoint);
633
634const endpointInstance: EndpointConfig | { default: EndpointConfig } = await importFileUrl(
635endpointPath,
636import.meta.url,
637{
638fresh: true,
639},
640);
641
642const config = getModuleDefault(endpointInstance);
643
644const unregister = this.registerEndpoint(config, endpoint.name);
645
646this.unregisterFunctionMap.set(endpoint.name, async () => {
647await unregister();
648
649deleteFromRequireCache(endpointPath);
650});
651}
652} catch (error) {
653this.handleExtensionError({ error, reason: `Couldn't register endpoint "${endpoint.name}"` });
654}
655}
656
657private async registerOperationExtension(operation: HybridExtension) {
658try {
659if (operation.sandbox?.enabled) {
660await this.registerSandboxedApiExtension(operation);
661} else {
662const operationPath = path.resolve(operation.path, operation.entrypoint.api!);
663
664const operationInstance: OperationApiConfig | { default: OperationApiConfig } = await importFileUrl(
665operationPath,
666import.meta.url,
667{
668fresh: true,
669},
670);
671
672const config = getModuleDefault(operationInstance);
673
674const unregister = this.registerOperation(config);
675
676this.unregisterFunctionMap.set(operation.name, async () => {
677await unregister();
678
679deleteFromRequireCache(operationPath);
680});
681}
682} catch (error) {
683this.handleExtensionError({ error, reason: `Couldn't register operation "${operation.name}"` });
684}
685}
686
687private async registerBundleExtension(
688bundle: BundleExtension,
689source: 'local' | 'registry' | 'module',
690bundleId: string,
691) {
692const extensionEnabled = (extensionName: string) => {
693const settings = this.extensionsSettings.find(
694(settings) => settings.source === source && settings.folder === extensionName && settings.bundle === bundleId,
695);
696
697if (!settings) return false;
698return settings.enabled;
699};
700
701try {
702const bundlePath = path.resolve(bundle.path, bundle.entrypoint.api);
703
704const bundleInstances: BundleConfig | { default: BundleConfig } = await importFileUrl(
705bundlePath,
706import.meta.url,
707{
708fresh: true,
709},
710);
711
712const configs = getModuleDefault(bundleInstances);
713
714const unregisterFunctions: PromiseCallback[] = [];
715
716for (const { config, name } of configs.hooks) {
717if (!extensionEnabled(name)) continue;
718
719const unregisters = this.registerHook(config, name);
720
721unregisterFunctions.push(...unregisters);
722}
723
724for (const { config, name } of configs.endpoints) {
725if (!extensionEnabled(name)) continue;
726
727const unregister = this.registerEndpoint(config, name);
728
729unregisterFunctions.push(unregister);
730}
731
732for (const { config, name } of configs.operations) {
733if (!extensionEnabled(name)) continue;
734
735const unregister = this.registerOperation(config);
736
737unregisterFunctions.push(unregister);
738}
739
740this.unregisterFunctionMap.set(bundle.name, async () => {
741await Promise.all(unregisterFunctions.map((fn) => fn()));
742
743deleteFromRequireCache(bundlePath);
744});
745} catch (error) {
746this.handleExtensionError({ error, reason: `Couldn't register bundle "${bundle.name}"` });
747}
748}
749
750/**
751* Import the operation module code for all operation extensions, and register them individually through
752* registerOperation
753*/
754private async registerInternalOperations(): Promise<void> {
755const internalOperations = await readdir(path.join(__dirname, '..', 'operations'));
756
757for (const operation of internalOperations) {
758const operationInstance: OperationApiConfig | { default: OperationApiConfig } = await import(
759`../operations/${operation}/index.js`
760);
761
762const config = getModuleDefault(operationInstance);
763
764this.registerOperation(config);
765}
766}
767
768/**
769* Register a single hook
770*/
771private registerHook(hookRegistrationCallback: HookConfig, name: string): PromiseCallback[] {
772const logger = useLogger();
773
774let scheduleIndex = 0;
775
776const unregisterFunctions: PromiseCallback[] = [];
777
778const hookRegistrationContext = {
779filter: <T = unknown>(event: string, handler: FilterHandler<T>) => {
780emitter.onFilter(event, handler);
781
782unregisterFunctions.push(() => {
783emitter.offFilter(event, handler);
784});
785},
786action: (event: string, handler: ActionHandler) => {
787emitter.onAction(event, handler);
788
789unregisterFunctions.push(() => {
790emitter.offAction(event, handler);
791});
792},
793init: (event: string, handler: InitHandler) => {
794emitter.onInit(event, handler);
795
796unregisterFunctions.push(() => {
797emitter.offInit(name, handler);
798});
799},
800schedule: (cron: string, handler: ScheduleHandler) => {
801if (validateCron(cron)) {
802const job = scheduleSynchronizedJob(`${name}:${scheduleIndex}`, cron, async () => {
803if (this.options.schedule) {
804try {
805await handler();
806} catch (error) {
807logger.error(error);
808}
809}
810});
811
812scheduleIndex++;
813
814unregisterFunctions.push(async () => {
815await job.stop();
816});
817} else {
818this.handleExtensionError({ reason: `Couldn't register cron hook. Provided cron is invalid: ${cron}` });
819}
820},
821embed: (position: 'head' | 'body', code: string | EmbedHandler) => {
822const content = typeof code === 'function' ? code() : code;
823
824if (content.trim().length !== 0) {
825if (position === 'head') {
826const index = this.hookEmbedsHead.length;
827
828this.hookEmbedsHead.push(content);
829
830unregisterFunctions.push(() => {
831this.hookEmbedsHead.splice(index, 1);
832});
833} else {
834const index = this.hookEmbedsBody.length;
835
836this.hookEmbedsBody.push(content);
837
838unregisterFunctions.push(() => {
839this.hookEmbedsBody.splice(index, 1);
840});
841}
842} else {
843this.handleExtensionError({ reason: `Couldn't register embed hook. Provided code is empty!` });
844}
845},
846};
847
848hookRegistrationCallback(hookRegistrationContext, {
849services,
850env,
851database: getDatabase(),
852emitter: this.localEmitter,
853logger,
854getSchema,
855});
856
857return unregisterFunctions;
858}
859
860/**
861* Register an individual endpoint
862*/
863private registerEndpoint(config: EndpointConfig, name: string): PromiseCallback {
864const logger = useLogger();
865
866const endpointRegistrationCallback = typeof config === 'function' ? config : config.handler;
867const nameWithoutType = name.includes(':') ? name.split(':')[0] : name;
868const routeName = typeof config === 'function' ? nameWithoutType : config.id;
869
870const scopedRouter = express.Router();
871
872this.endpointRouter.use(`/${routeName}`, scopedRouter);
873
874endpointRegistrationCallback(scopedRouter, {
875services,
876env,
877database: getDatabase(),
878emitter: this.localEmitter,
879logger,
880getSchema,
881});
882
883const unregisterFunction = () => {
884this.endpointRouter.stack = this.endpointRouter.stack.filter((layer) => scopedRouter !== layer.handle);
885};
886
887return unregisterFunction;
888}
889
890/**
891* Register an individual operation
892*/
893private registerOperation(config: OperationApiConfig): PromiseCallback {
894const flowManager = getFlowManager();
895
896flowManager.addOperation(config.id, config.handler);
897
898const unregisterFunction = () => {
899flowManager.removeOperation(config.id);
900};
901
902return unregisterFunction;
903}
904
905/**
906* Remove the registration for all API extensions
907*/
908private async unregisterApiExtensions(): Promise<void> {
909const unregisterFunctions = Array.from(this.unregisterFunctionMap.values());
910
911await Promise.all(unregisterFunctions.map((fn) => fn()));
912}
913
914/**
915* If extensions must load successfully, any errors will cause the process to exit.
916* Otherwise, the error will only be logged as a warning.
917*/
918private handleExtensionError({ error, reason }: { error?: unknown; reason: string }): void {
919const logger = useLogger();
920
921if (toBoolean(env['EXTENSIONS_MUST_LOAD'])) {
922logger.error('EXTENSION_MUST_LOAD is enabled and an extension failed to load.');
923logger.error(reason);
924if (error) logger.error(error);
925process.exit(1);
926} else {
927logger.warn(reason);
928if (error) logger.warn(error);
929}
930}
931}
932