directus

Форк
0
/
manager.ts 
931 строка · 26.6 Кб
1
import { useEnv } from '@directus/env';
2
import type {
3
	ApiExtension,
4
	BundleExtension,
5
	EndpointConfig,
6
	Extension,
7
	ExtensionSettings,
8
	HookConfig,
9
	HybridExtension,
10
	OperationApiConfig,
11
} from '@directus/extensions';
12
import { APP_SHARED_DEPS, HYBRID_EXTENSION_TYPES } from '@directus/extensions';
13
import { generateExtensionsEntrypoint } from '@directus/extensions/node';
14
import type {
15
	ActionHandler,
16
	EmbedHandler,
17
	FilterHandler,
18
	InitHandler,
19
	PromiseCallback,
20
	ScheduleHandler,
21
} from '@directus/types';
22
import { isTypeIn, toBoolean } from '@directus/utils';
23
import { pathToRelativeUrl, processId } from '@directus/utils/node';
24
import aliasDefault from '@rollup/plugin-alias';
25
import nodeResolveDefault from '@rollup/plugin-node-resolve';
26
import virtualDefault from '@rollup/plugin-virtual';
27
import chokidar, { FSWatcher } from 'chokidar';
28
import express, { Router } from 'express';
29
import ivm from 'isolated-vm';
30
import { clone, debounce, isPlainObject } from 'lodash-es';
31
import { readFile, readdir } from 'node:fs/promises';
32
import os from 'node:os';
33
import { dirname } from 'node:path';
34
import { fileURLToPath } from 'node:url';
35
import path from 'path';
36
import { rollup } from 'rollup';
37
import { useBus } from '../bus/index.js';
38
import getDatabase from '../database/index.js';
39
import emitter, { Emitter } from '../emitter.js';
40
import { getFlowManager } from '../flows.js';
41
import { useLogger } from '../logger.js';
42
import * as services from '../services/index.js';
43
import { deleteFromRequireCache } from '../utils/delete-from-require-cache.js';
44
import getModuleDefault from '../utils/get-module-default.js';
45
import { getSchema } from '../utils/get-schema.js';
46
import { importFileUrl } from '../utils/import-file-url.js';
47
import { JobQueue } from '../utils/job-queue.js';
48
import { scheduleSynchronizedJob, validateCron } from '../utils/schedule.js';
49
import { getExtensionsPath } from './lib/get-extensions-path.js';
50
import { getExtensionsSettings } from './lib/get-extensions-settings.js';
51
import { getExtensions } from './lib/get-extensions.js';
52
import { getSharedDepsMapping } from './lib/get-shared-deps-mapping.js';
53
import { getInstallationManager } from './lib/installation/index.js';
54
import type { InstallationManager } from './lib/installation/manager.js';
55
import { generateApiExtensionsSandboxEntrypoint } from './lib/sandbox/generate-api-extensions-sandbox-entrypoint.js';
56
import { instantiateSandboxSdk } from './lib/sandbox/sdk/instantiate.js';
57
import { syncExtensions } from './lib/sync-extensions.js';
58
import { wrapEmbeds } from './lib/wrap-embeds.js';
59
import type { BundleConfig, ExtensionManagerOptions } from './types.js';
60

61
// Workaround for https://github.com/rollup/plugins/issues/1329
62
const virtual = virtualDefault as unknown as typeof virtualDefault.default;
63
const alias = aliasDefault as unknown as typeof aliasDefault.default;
64
const nodeResolve = nodeResolveDefault as unknown as typeof nodeResolveDefault.default;
65

66
const __dirname = dirname(fileURLToPath(import.meta.url));
67

68
const env = useEnv();
69

70
const defaultOptions: ExtensionManagerOptions = {
71
	schedule: true,
72
	watch: env['EXTENSIONS_AUTO_RELOAD'] as boolean,
73
};
74

75
export class ExtensionManager {
76
	private options: ExtensionManagerOptions = defaultOptions;
77

78
	/**
79
	 * Whether or not the extensions have been read from disk and registered into the system
80
	 */
81
	private isLoaded = false;
82

83
	// folder:Extension
84
	private localExtensions: Map<string, Extension> = new Map();
85

86
	// versionId:Extension
87
	private registryExtensions: Map<string, Extension> = new Map();
88

89
	// name:Extension
90
	private moduleExtensions: Map<string, Extension> = new Map();
91

92
	/**
93
	 * Settings for the extensions that are loaded within the current process
94
	 */
95
	private 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
	 */
101
	private 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
	 */
107
	private appExtensionChunks: Map<string, string> = new Map();
108

109
	/**
110
	 * Callbacks to be able to unregister extensions
111
	 */
112
	private 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
	 */
119
	private 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
	 */
125
	private 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
	 */
130
	private hookEmbedsHead: string[] = [];
131

132
	/**
133
	 * Custom HTML to be injected at the end of the `<body>` tag of the app's index.html
134
	 */
135
	private hookEmbedsBody: string[] = [];
136

137
	/**
138
	 * Used to prevent race conditions when reloading extensions. Forces each reload to happen in
139
	 * sequence.
140
	 */
141
	private reloadQueue: JobQueue = new JobQueue();
142

143
	/**
144
	 * Optional file system watcher to auto-reload extensions when the local file system changes
145
	 */
146
	private watcher: FSWatcher | null = null;
147

148
	/**
149
	 * installation manager responsible for installing extensions from registries
150
	 */
151

152
	private installationManager: InstallationManager = getInstallationManager();
153

154
	private messenger = useBus();
155

156
	/**
157
	 * channel to publish on registering extension from external registry
158
	 */
159
	private reloadChannel = `extensions.reload`;
160

161
	private processId = processId();
162

163
	public get extensions() {
164
		return [...this.localExtensions.values(), ...this.registryExtensions.values(), ...this.moduleExtensions.values()];
165
	}
166

167
	public getExtension(source: string, folder: string) {
168
		switch (source) {
169
			case 'module':
170
				return this.moduleExtensions.get(folder);
171
			case 'registry':
172
				return this.registryExtensions.get(folder);
173
			case 'local':
174
				return this.localExtensions.get(folder);
175
		}
176

177
		return 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
	 */
187
	public async initialize(options: Partial<ExtensionManagerOptions> = {}): Promise<void> {
188
		const logger = useLogger();
189

190
		this.options = {
191
			...defaultOptions,
192
			...options,
193
		};
194

195
		const wasWatcherInitialized = this.watcher !== null;
196

197
		if (this.options.watch && !wasWatcherInitialized) {
198
			this.initializeWatcher();
199
		} else if (!this.options.watch && wasWatcherInitialized) {
200
			await this.closeWatcher();
201
		}
202

203
		if (!this.isLoaded) {
204
			await this.load();
205

206
			if (this.extensions.length > 0) {
207
				logger.info(`Loaded extensions: ${this.extensions.map((ext) => ext.name).join(', ')}`);
208
			}
209
		}
210

211
		if (this.options.watch && !wasWatcherInitialized) {
212
			this.updateWatchedExtensions(Array.from(this.localExtensions.values()));
213
		}
214

215
		this.messenger.subscribe(this.reloadChannel, (payload: Record<string, unknown>) => {
216
			// Ignore requests for reloading that were published by the current process
217
			if (isPlainObject(payload) && 'origin' in payload && payload['origin'] === this.processId) return;
218
			this.reload();
219
		});
220
	}
221

222
	/**
223
	 * Installs an external extension from registry
224
	 */
225
	public async install(versionId: string): Promise<void> {
226
		await this.installationManager.install(versionId);
227
		await this.reload({ forceSync: true });
228
		await this.broadcastReloadNotification();
229
	}
230

231
	public async uninstall(folder: string) {
232
		await this.installationManager.uninstall(folder);
233
		await this.reload({ forceSync: true });
234
		await this.broadcastReloadNotification();
235
	}
236

237
	public async broadcastReloadNotification() {
238
		await 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
	 */
244
	private async load(options?: { forceSync: boolean }): Promise<void> {
245
		const logger = useLogger();
246

247
		if (env['EXTENSIONS_LOCATION']) {
248
			try {
249
				await syncExtensions({ force: options?.forceSync ?? false });
250
			} catch (error) {
251
				logger.error(`Failed to sync extensions`);
252
				logger.error(error);
253
				process.exit(1);
254
			}
255
		}
256

257
		try {
258
			const { local, registry, module } = await getExtensions();
259

260
			this.localExtensions = local;
261
			this.registryExtensions = registry;
262
			this.moduleExtensions = module;
263

264
			this.extensionsSettings = await getExtensionsSettings({ local, registry, module });
265
		} catch (error) {
266
			this.handleExtensionError({ error, reason: `Couldn't load extensions` });
267
		}
268

269
		await Promise.all([this.registerInternalOperations(), this.registerApiExtensions()]);
270

271
		if (env['SERVE_APP']) {
272
			this.appExtensionsBundle = await this.generateExtensionBundle();
273
		}
274

275
		this.isLoaded = true;
276
	}
277

278
	/**
279
	 * Unregister all extensions from the current process
280
	 */
281
	private async unload(): Promise<void> {
282
		await this.unregisterApiExtensions();
283

284
		this.localEmitter.offAll();
285

286
		this.appExtensionsBundle = null;
287

288
		this.isLoaded = false;
289
	}
290

291
	/**
292
	 * Reload all the extensions. Will unload if extensions have already been loaded
293
	 */
294
	public reload(options?: { forceSync: boolean }): Promise<unknown> {
295
		if (this.reloadQueue.size > 0) {
296
			// The pending job in the queue will already handle the additional changes
297
			return Promise.resolve();
298
		}
299

300
		const logger = useLogger();
301

302
		let resolve: (val?: unknown) => void;
303
		let reject: (val?: unknown) => void;
304

305
		const promise = new Promise((res, rej) => {
306
			resolve = res;
307
			reject = rej;
308
		});
309

310
		this.reloadQueue.enqueue(async () => {
311
			if (this.isLoaded) {
312
				const prevExtensions = clone(this.extensions);
313

314
				await this.unload();
315
				await this.load(options);
316

317
				logger.info('Extensions reloaded');
318

319
				const added = this.extensions.filter(
320
					(extension) => !prevExtensions.some((prevExtension) => extension.path === prevExtension.path),
321
				);
322

323
				const removed = prevExtensions.filter(
324
					(prevExtension) => !this.extensions.some((extension) => prevExtension.path === extension.path),
325
				);
326

327
				this.updateWatchedExtensions(added, removed);
328

329
				const addedExtensions = added.map((extension) => extension.name);
330
				const removedExtensions = removed.map((extension) => extension.name);
331

332
				if (addedExtensions.length > 0) {
333
					logger.info(`Added extensions: ${addedExtensions.join(', ')}`);
334
				}
335

336
				if (removedExtensions.length > 0) {
337
					logger.info(`Removed extensions: ${removedExtensions.join(', ')}`);
338
				}
339

340
				resolve();
341
			} else {
342
				logger.warn('Extensions have to be loaded before they can be reloaded');
343
				reject(new Error('Extensions have to be loaded before they can be reloaded'));
344
			}
345
		});
346

347
		return promise;
348
	}
349

350
	/**
351
	 * Return the previously generated app extensions bundle
352
	 */
353
	public getAppExtensionsBundle(): string | null {
354
		return this.appExtensionsBundle;
355
	}
356

357
	/**
358
	 * Return the previously generated app extension bundle chunk by name
359
	 */
360
	public getAppExtensionChunk(name: string): string | null {
361
		return this.appExtensionChunks.get(name) ?? null;
362
	}
363

364
	/**
365
	 * Return the scoped router for custom endpoints
366
	 */
367
	public getEndpointRouter(): Router {
368
		return this.endpointRouter;
369
	}
370

371
	/**
372
	 * Return the custom HTML head and body embeds wrapped in a marker comment
373
	 */
374
	public getEmbeds() {
375
		return {
376
			head: wrapEmbeds('Custom Embed Head', this.hookEmbedsHead),
377
			body: wrapEmbeds('Custom Embed Body', this.hookEmbedsBody),
378
		};
379
	}
380

381
	/**
382
	 * Start the chokidar watcher for extensions on the local filesystem
383
	 */
384
	private initializeWatcher(): void {
385
		const logger = useLogger();
386

387
		logger.info('Watching extensions for changes...');
388

389
		const extensionDirUrl = pathToRelativeUrl(getExtensionsPath());
390

391
		this.watcher = chokidar.watch(
392
			[path.resolve('package.json'), path.posix.join(extensionDirUrl, '*', 'package.json')],
393
			{
394
				ignoreInitial: true,
395
				// dotdirs are watched by default and frequently found in 'node_modules'
396
				ignored: `${extensionDirUrl}/**/node_modules/**`,
397
				// on macOS dotdirs in linked extensions are watched too
398
				followSymlinks: os.platform() === 'darwin' ? false : true,
399
			},
400
		);
401

402
		this.watcher
403
			.on(
404
				'add',
405
				debounce(() => this.reload(), 500),
406
			)
407
			.on(
408
				'change',
409
				debounce(() => this.reload(), 650),
410
			)
411
			.on(
412
				'unlink',
413
				debounce(() => this.reload(), 2000),
414
			);
415
	}
416

417
	/**
418
	 * Close and destroy the local filesystem watcher if enabled
419
	 */
420
	private async closeWatcher(): Promise<void> {
421
		if (this.watcher) {
422
			await this.watcher.close();
423

424
			this.watcher = null;
425
		}
426
	}
427

428
	/**
429
	 * Update the chokidar watcher configuration when new extensions are added or existing ones
430
	 * removed
431
	 */
432
	private updateWatchedExtensions(added: Extension[], removed: Extension[] = []): void {
433
		if (!this.watcher) return;
434

435
		const extensionDir = path.resolve(getExtensionsPath());
436
		const registryDir = path.join(extensionDir, '.registry');
437

438
		const toPackageExtensionPaths = (extensions: Extension[]) =>
439
			extensions
440
				.filter(
441
					(extension) =>
442
						extension.local && extension.path.startsWith(extensionDir) && !extension.path.startsWith(registryDir),
443
				)
444
				.flatMap((extension) =>
445
					isTypeIn(extension, HYBRID_EXTENSION_TYPES) || extension.type === 'bundle'
446
						? [
447
								path.resolve(extension.path, extension.entrypoint.app),
448
								path.resolve(extension.path, extension.entrypoint.api),
449
						  ]
450
						: path.resolve(extension.path, extension.entrypoint),
451
				);
452

453
		this.watcher.add(toPackageExtensionPaths(added));
454
		this.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
	 */
461
	private async generateExtensionBundle(): Promise<string | null> {
462
		const logger = useLogger();
463

464
		const sharedDepsMapping = await getSharedDepsMapping(APP_SHARED_DEPS);
465

466
		const internalImports = Object.entries(sharedDepsMapping).map(([name, path]) => ({
467
			find: name,
468
			replacement: path,
469
		}));
470

471
		const entrypoint = generateExtensionsEntrypoint(
472
			{ module: this.moduleExtensions, registry: this.registryExtensions, local: this.localExtensions },
473
			this.extensionsSettings,
474
		);
475

476
		try {
477
			const bundle = await rollup({
478
				input: 'entry',
479
				external: Object.values(sharedDepsMapping),
480
				makeAbsoluteExternalsRelative: false,
481
				plugins: [virtual({ entry: entrypoint }), alias({ entries: internalImports }), nodeResolve({ browser: true })],
482
			});
483

484
			const { output } = await bundle.generate({ format: 'es', compact: true });
485

486
			for (const out of output) {
487
				if (out.type === 'chunk') {
488
					this.appExtensionChunks.set(out.fileName, out.code);
489
				}
490
			}
491

492
			await bundle.close();
493

494
			return output[0].code;
495
		} catch (error) {
496
			logger.warn(`Couldn't bundle App extensions`);
497
			logger.warn(error);
498
		}
499

500
		return null;
501
	}
502

503
	private async registerSandboxedApiExtension(extension: ApiExtension | HybridExtension) {
504
		const logger = useLogger();
505

506
		const sandboxMemory = Number(env['EXTENSIONS_SANDBOX_MEMORY']);
507
		const sandboxTimeout = Number(env['EXTENSIONS_SANDBOX_TIMEOUT']);
508

509
		const entrypointPath = path.resolve(
510
			extension.path,
511
			isTypeIn(extension, HYBRID_EXTENSION_TYPES) ? extension.entrypoint.api : extension.entrypoint,
512
		);
513

514
		const extensionCode = await readFile(entrypointPath, 'utf-8');
515

516
		const isolate = new ivm.Isolate({
517
			memoryLimit: sandboxMemory,
518
			onCatastrophicError: (error) => {
519
				logger.error(`Error in API extension sandbox of ${extension.type} "${extension.name}"`);
520
				logger.error(error);
521

522
				process.abort();
523
			},
524
		});
525

526
		const context = await isolate.createContext();
527

528
		const module = await isolate.compileModule(extensionCode, { filename: `file://${entrypointPath}` });
529

530
		const sdkModule = await instantiateSandboxSdk(isolate, extension.sandbox?.requestedScopes ?? {});
531

532
		await module.instantiate(context, (specifier) => {
533
			if (specifier !== 'directus:api') {
534
				throw new Error('Imports other than "directus:api" are prohibited in API extension sandboxes');
535
			}
536

537
			return sdkModule;
538
		});
539

540
		await module.evaluate({ timeout: sandboxTimeout });
541

542
		const cb = await module.namespace.get('default', { reference: true });
543

544
		const { code, hostFunctions, unregisterFunction } = generateApiExtensionsSandboxEntrypoint(
545
			extension.type,
546
			extension.name,
547
			this.endpointRouter,
548
		);
549

550
		await context.evalClosure(code, [cb, ...hostFunctions.map((fn) => new ivm.Reference(fn))], {
551
			timeout: sandboxTimeout,
552
			filename: '<extensions-sandbox>',
553
		});
554

555
		this.unregisterFunctionMap.set(extension.name, async () => {
556
			await unregisterFunction();
557

558
			if (!isolate.isDisposed) isolate.dispose();
559
		});
560
	}
561

562
	private async registerApiExtensions(): Promise<void> {
563
		const sources = {
564
			module: this.moduleExtensions,
565
			registry: this.registryExtensions,
566
			local: this.localExtensions,
567
		} as const;
568

569
		await Promise.all(
570
			Object.entries(sources).map(async ([source, extensions]) => {
571
				await Promise.all(
572
					Array.from(extensions.entries()).map(async ([folder, extension]) => {
573
						const { id, enabled } = this.extensionsSettings.find(
574
							(settings) => settings.source === source && settings.folder === folder,
575
						) ?? { enabled: false };
576

577
						if (!enabled) return;
578

579
						switch (extension.type) {
580
							case 'hook':
581
								await this.registerHookExtension(extension);
582
								break;
583
							case 'endpoint':
584
								await this.registerEndpointExtension(extension);
585
								break;
586
							case 'operation':
587
								await this.registerOperationExtension(extension);
588
								break;
589
							case 'bundle':
590
								await this.registerBundleExtension(extension, source as 'module' | 'registry' | 'local', id);
591
								break;
592
							default:
593
								return;
594
						}
595
					}),
596
				);
597
			}),
598
		);
599
	}
600

601
	private async registerHookExtension(hook: ApiExtension) {
602
		try {
603
			if (hook.sandbox?.enabled) {
604
				await this.registerSandboxedApiExtension(hook);
605
			} else {
606
				const hookPath = path.resolve(hook.path, hook.entrypoint);
607

608
				const hookInstance: HookConfig | { default: HookConfig } = await importFileUrl(hookPath, import.meta.url, {
609
					fresh: true,
610
				});
611

612
				const config = getModuleDefault(hookInstance);
613

614
				const unregisterFunctions = this.registerHook(config, hook.name);
615

616
				this.unregisterFunctionMap.set(hook.name, async () => {
617
					await Promise.all(unregisterFunctions.map((fn) => fn()));
618

619
					deleteFromRequireCache(hookPath);
620
				});
621
			}
622
		} catch (error) {
623
			this.handleExtensionError({ error, reason: `Couldn't register hook "${hook.name}"` });
624
		}
625
	}
626

627
	private async registerEndpointExtension(endpoint: ApiExtension) {
628
		try {
629
			if (endpoint.sandbox?.enabled) {
630
				await this.registerSandboxedApiExtension(endpoint);
631
			} else {
632
				const endpointPath = path.resolve(endpoint.path, endpoint.entrypoint);
633

634
				const endpointInstance: EndpointConfig | { default: EndpointConfig } = await importFileUrl(
635
					endpointPath,
636
					import.meta.url,
637
					{
638
						fresh: true,
639
					},
640
				);
641

642
				const config = getModuleDefault(endpointInstance);
643

644
				const unregister = this.registerEndpoint(config, endpoint.name);
645

646
				this.unregisterFunctionMap.set(endpoint.name, async () => {
647
					await unregister();
648

649
					deleteFromRequireCache(endpointPath);
650
				});
651
			}
652
		} catch (error) {
653
			this.handleExtensionError({ error, reason: `Couldn't register endpoint "${endpoint.name}"` });
654
		}
655
	}
656

657
	private async registerOperationExtension(operation: HybridExtension) {
658
		try {
659
			if (operation.sandbox?.enabled) {
660
				await this.registerSandboxedApiExtension(operation);
661
			} else {
662
				const operationPath = path.resolve(operation.path, operation.entrypoint.api!);
663

664
				const operationInstance: OperationApiConfig | { default: OperationApiConfig } = await importFileUrl(
665
					operationPath,
666
					import.meta.url,
667
					{
668
						fresh: true,
669
					},
670
				);
671

672
				const config = getModuleDefault(operationInstance);
673

674
				const unregister = this.registerOperation(config);
675

676
				this.unregisterFunctionMap.set(operation.name, async () => {
677
					await unregister();
678

679
					deleteFromRequireCache(operationPath);
680
				});
681
			}
682
		} catch (error) {
683
			this.handleExtensionError({ error, reason: `Couldn't register operation "${operation.name}"` });
684
		}
685
	}
686

687
	private async registerBundleExtension(
688
		bundle: BundleExtension,
689
		source: 'local' | 'registry' | 'module',
690
		bundleId: string,
691
	) {
692
		const extensionEnabled = (extensionName: string) => {
693
			const settings = this.extensionsSettings.find(
694
				(settings) => settings.source === source && settings.folder === extensionName && settings.bundle === bundleId,
695
			);
696

697
			if (!settings) return false;
698
			return settings.enabled;
699
		};
700

701
		try {
702
			const bundlePath = path.resolve(bundle.path, bundle.entrypoint.api);
703

704
			const bundleInstances: BundleConfig | { default: BundleConfig } = await importFileUrl(
705
				bundlePath,
706
				import.meta.url,
707
				{
708
					fresh: true,
709
				},
710
			);
711

712
			const configs = getModuleDefault(bundleInstances);
713

714
			const unregisterFunctions: PromiseCallback[] = [];
715

716
			for (const { config, name } of configs.hooks) {
717
				if (!extensionEnabled(name)) continue;
718

719
				const unregisters = this.registerHook(config, name);
720

721
				unregisterFunctions.push(...unregisters);
722
			}
723

724
			for (const { config, name } of configs.endpoints) {
725
				if (!extensionEnabled(name)) continue;
726

727
				const unregister = this.registerEndpoint(config, name);
728

729
				unregisterFunctions.push(unregister);
730
			}
731

732
			for (const { config, name } of configs.operations) {
733
				if (!extensionEnabled(name)) continue;
734

735
				const unregister = this.registerOperation(config);
736

737
				unregisterFunctions.push(unregister);
738
			}
739

740
			this.unregisterFunctionMap.set(bundle.name, async () => {
741
				await Promise.all(unregisterFunctions.map((fn) => fn()));
742

743
				deleteFromRequireCache(bundlePath);
744
			});
745
		} catch (error) {
746
			this.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
	 */
754
	private async registerInternalOperations(): Promise<void> {
755
		const internalOperations = await readdir(path.join(__dirname, '..', 'operations'));
756

757
		for (const operation of internalOperations) {
758
			const operationInstance: OperationApiConfig | { default: OperationApiConfig } = await import(
759
				`../operations/${operation}/index.js`
760
			);
761

762
			const config = getModuleDefault(operationInstance);
763

764
			this.registerOperation(config);
765
		}
766
	}
767

768
	/**
769
	 * Register a single hook
770
	 */
771
	private registerHook(hookRegistrationCallback: HookConfig, name: string): PromiseCallback[] {
772
		const logger = useLogger();
773

774
		let scheduleIndex = 0;
775

776
		const unregisterFunctions: PromiseCallback[] = [];
777

778
		const hookRegistrationContext = {
779
			filter: <T = unknown>(event: string, handler: FilterHandler<T>) => {
780
				emitter.onFilter(event, handler);
781

782
				unregisterFunctions.push(() => {
783
					emitter.offFilter(event, handler);
784
				});
785
			},
786
			action: (event: string, handler: ActionHandler) => {
787
				emitter.onAction(event, handler);
788

789
				unregisterFunctions.push(() => {
790
					emitter.offAction(event, handler);
791
				});
792
			},
793
			init: (event: string, handler: InitHandler) => {
794
				emitter.onInit(event, handler);
795

796
				unregisterFunctions.push(() => {
797
					emitter.offInit(name, handler);
798
				});
799
			},
800
			schedule: (cron: string, handler: ScheduleHandler) => {
801
				if (validateCron(cron)) {
802
					const job = scheduleSynchronizedJob(`${name}:${scheduleIndex}`, cron, async () => {
803
						if (this.options.schedule) {
804
							try {
805
								await handler();
806
							} catch (error) {
807
								logger.error(error);
808
							}
809
						}
810
					});
811

812
					scheduleIndex++;
813

814
					unregisterFunctions.push(async () => {
815
						await job.stop();
816
					});
817
				} else {
818
					this.handleExtensionError({ reason: `Couldn't register cron hook. Provided cron is invalid: ${cron}` });
819
				}
820
			},
821
			embed: (position: 'head' | 'body', code: string | EmbedHandler) => {
822
				const content = typeof code === 'function' ? code() : code;
823

824
				if (content.trim().length !== 0) {
825
					if (position === 'head') {
826
						const index = this.hookEmbedsHead.length;
827

828
						this.hookEmbedsHead.push(content);
829

830
						unregisterFunctions.push(() => {
831
							this.hookEmbedsHead.splice(index, 1);
832
						});
833
					} else {
834
						const index = this.hookEmbedsBody.length;
835

836
						this.hookEmbedsBody.push(content);
837

838
						unregisterFunctions.push(() => {
839
							this.hookEmbedsBody.splice(index, 1);
840
						});
841
					}
842
				} else {
843
					this.handleExtensionError({ reason: `Couldn't register embed hook. Provided code is empty!` });
844
				}
845
			},
846
		};
847

848
		hookRegistrationCallback(hookRegistrationContext, {
849
			services,
850
			env,
851
			database: getDatabase(),
852
			emitter: this.localEmitter,
853
			logger,
854
			getSchema,
855
		});
856

857
		return unregisterFunctions;
858
	}
859

860
	/**
861
	 * Register an individual endpoint
862
	 */
863
	private registerEndpoint(config: EndpointConfig, name: string): PromiseCallback {
864
		const logger = useLogger();
865

866
		const endpointRegistrationCallback = typeof config === 'function' ? config : config.handler;
867
		const nameWithoutType = name.includes(':') ? name.split(':')[0] : name;
868
		const routeName = typeof config === 'function' ? nameWithoutType : config.id;
869

870
		const scopedRouter = express.Router();
871

872
		this.endpointRouter.use(`/${routeName}`, scopedRouter);
873

874
		endpointRegistrationCallback(scopedRouter, {
875
			services,
876
			env,
877
			database: getDatabase(),
878
			emitter: this.localEmitter,
879
			logger,
880
			getSchema,
881
		});
882

883
		const unregisterFunction = () => {
884
			this.endpointRouter.stack = this.endpointRouter.stack.filter((layer) => scopedRouter !== layer.handle);
885
		};
886

887
		return unregisterFunction;
888
	}
889

890
	/**
891
	 * Register an individual operation
892
	 */
893
	private registerOperation(config: OperationApiConfig): PromiseCallback {
894
		const flowManager = getFlowManager();
895

896
		flowManager.addOperation(config.id, config.handler);
897

898
		const unregisterFunction = () => {
899
			flowManager.removeOperation(config.id);
900
		};
901

902
		return unregisterFunction;
903
	}
904

905
	/**
906
	 * Remove the registration for all API extensions
907
	 */
908
	private async unregisterApiExtensions(): Promise<void> {
909
		const unregisterFunctions = Array.from(this.unregisterFunctionMap.values());
910

911
		await 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
	 */
918
	private handleExtensionError({ error, reason }: { error?: unknown; reason: string }): void {
919
		const logger = useLogger();
920

921
		if (toBoolean(env['EXTENSIONS_MUST_LOAD'])) {
922
			logger.error('EXTENSION_MUST_LOAD is enabled and an extension failed to load.');
923
			logger.error(reason);
924
			if (error) logger.error(error);
925
			process.exit(1);
926
		} else {
927
			logger.warn(reason);
928
			if (error) logger.warn(error);
929
		}
930
	}
931
}
932

Использование cookies

Мы используем файлы cookie в соответствии с Политикой конфиденциальности и Политикой использования cookies.

Нажимая кнопку «Принимаю», Вы даете АО «СберТех» согласие на обработку Ваших персональных данных в целях совершенствования нашего веб-сайта и Сервиса GitVerse, а также повышения удобства их использования.

Запретить использование cookies Вы можете самостоятельно в настройках Вашего браузера.