@directus/api 13.2.0 → 14.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/dist/__utils__/snapshots.js +9 -0
  2. package/dist/app.js +2 -0
  3. package/dist/cli/index.js +6 -3
  4. package/dist/controllers/extensions.js +28 -19
  5. package/dist/controllers/versions.d.ts +2 -0
  6. package/dist/controllers/versions.js +188 -0
  7. package/dist/database/migrations/20230823A-add-content-versioning.d.ts +3 -0
  8. package/dist/database/migrations/20230823A-add-content-versioning.js +36 -0
  9. package/dist/database/migrations/20230927A-themes.d.ts +3 -0
  10. package/dist/database/migrations/20230927A-themes.js +49 -0
  11. package/dist/database/migrations/20231009B-update-panel-options.d.ts +3 -0
  12. package/dist/database/migrations/20231009B-update-panel-options.js +77 -0
  13. package/dist/database/migrations/20231010A-add-extensions.d.ts +3 -0
  14. package/dist/database/migrations/20231010A-add-extensions.js +9 -0
  15. package/dist/database/run-ast.js +1 -1
  16. package/dist/database/system-data/collections/collections.yaml +6 -0
  17. package/dist/database/system-data/fields/activity.yaml +4 -4
  18. package/dist/database/system-data/fields/collections.yaml +19 -0
  19. package/dist/database/system-data/fields/extensions.yaml +10 -0
  20. package/dist/database/system-data/fields/revisions.yaml +3 -0
  21. package/dist/database/system-data/fields/settings.yaml +73 -17
  22. package/dist/database/system-data/fields/users.yaml +48 -12
  23. package/dist/database/system-data/fields/versions.yaml +38 -0
  24. package/dist/database/system-data/fields/webhooks.yaml +9 -9
  25. package/dist/database/system-data/relations/relations.yaml +88 -20
  26. package/dist/env.js +4 -0
  27. package/dist/extensions/lib/get-extensions-settings.d.ts +7 -0
  28. package/dist/extensions/lib/get-extensions-settings.js +39 -0
  29. package/dist/extensions/lib/get-extensions.d.ts +1 -0
  30. package/dist/extensions/lib/get-extensions.js +11 -0
  31. package/dist/extensions/{get-shared-deps-mapping.js → lib/get-shared-deps-mapping.js} +3 -3
  32. package/dist/extensions/lib/sandbox/generate-api-extensions-sandbox-entrypoint.d.ts +31 -0
  33. package/dist/extensions/lib/sandbox/generate-api-extensions-sandbox-entrypoint.js +80 -0
  34. package/dist/extensions/lib/sandbox/generate-host-function-reference.d.ts +11 -0
  35. package/dist/extensions/lib/sandbox/generate-host-function-reference.js +28 -0
  36. package/dist/extensions/lib/sandbox/register/action.d.ts +6 -0
  37. package/dist/extensions/lib/sandbox/register/action.js +18 -0
  38. package/dist/extensions/lib/sandbox/register/call-reference.d.ts +5 -0
  39. package/dist/extensions/lib/sandbox/register/call-reference.js +20 -0
  40. package/dist/extensions/lib/sandbox/register/filter.d.ts +6 -0
  41. package/dist/extensions/lib/sandbox/register/filter.js +21 -0
  42. package/dist/extensions/lib/sandbox/register/index.d.ts +5 -0
  43. package/dist/extensions/lib/sandbox/register/index.js +5 -0
  44. package/dist/extensions/lib/sandbox/register/operation.d.ts +6 -0
  45. package/dist/extensions/lib/sandbox/register/operation.js +19 -0
  46. package/dist/extensions/lib/sandbox/register/route.d.ts +17 -0
  47. package/dist/extensions/lib/sandbox/register/route.js +44 -0
  48. package/dist/extensions/lib/sandbox/sdk/generators/index.d.ts +3 -0
  49. package/dist/extensions/lib/sandbox/sdk/generators/index.js +3 -0
  50. package/dist/extensions/lib/sandbox/sdk/generators/log.d.ts +3 -0
  51. package/dist/extensions/lib/sandbox/sdk/generators/log.js +11 -0
  52. package/dist/extensions/lib/sandbox/sdk/generators/request.d.ts +12 -0
  53. package/dist/extensions/lib/sandbox/sdk/generators/request.js +49 -0
  54. package/dist/extensions/lib/sandbox/sdk/generators/sleep.d.ts +3 -0
  55. package/dist/extensions/lib/sandbox/sdk/generators/sleep.js +11 -0
  56. package/dist/extensions/lib/sandbox/sdk/index.d.ts +2 -0
  57. package/dist/extensions/lib/sandbox/sdk/index.js +2 -0
  58. package/dist/extensions/lib/sandbox/sdk/instantiate.d.ts +11 -0
  59. package/dist/extensions/lib/sandbox/sdk/instantiate.js +28 -0
  60. package/dist/extensions/lib/sandbox/sdk/sdk.d.ts +20 -0
  61. package/dist/extensions/lib/sandbox/sdk/sdk.js +11 -0
  62. package/dist/extensions/lib/sandbox/sdk/utils/index.d.ts +1 -0
  63. package/dist/extensions/lib/sandbox/sdk/utils/index.js +1 -0
  64. package/dist/extensions/lib/sandbox/sdk/utils/wrap.d.ts +11 -0
  65. package/dist/extensions/lib/sandbox/sdk/utils/wrap.js +17 -0
  66. package/dist/extensions/manager.d.ts +128 -14
  67. package/dist/extensions/manager.js +319 -144
  68. package/dist/extensions/types.d.ts +1 -5
  69. package/dist/flows.d.ts +1 -1
  70. package/dist/flows.js +6 -6
  71. package/dist/middleware/respond.js +12 -0
  72. package/dist/services/assets.js +1 -1
  73. package/dist/services/extensions.d.ts +31 -0
  74. package/dist/services/extensions.js +121 -0
  75. package/dist/services/graphql/index.d.ts +1 -1
  76. package/dist/services/graphql/index.js +87 -24
  77. package/dist/services/index.d.ts +2 -0
  78. package/dist/services/index.js +2 -0
  79. package/dist/services/server.js +3 -1
  80. package/dist/services/users.js +2 -0
  81. package/dist/services/versions.d.ts +21 -0
  82. package/dist/services/versions.js +232 -0
  83. package/dist/types/collection.d.ts +1 -0
  84. package/dist/utils/apply-query.d.ts +1 -1
  85. package/dist/utils/apply-query.js +30 -2
  86. package/dist/utils/delete-from-require-cache.d.ts +1 -0
  87. package/dist/utils/delete-from-require-cache.js +5 -0
  88. package/dist/utils/get-service.js +3 -1
  89. package/dist/utils/import-file-url.d.ts +5 -0
  90. package/dist/utils/import-file-url.js +6 -0
  91. package/dist/utils/job-queue.d.ts +2 -3
  92. package/dist/utils/sanitize-query.js +3 -0
  93. package/dist/utils/validate-query.js +1 -0
  94. package/package.json +27 -26
  95. package/dist/extensions/get-extensions.d.ts +0 -47
  96. package/dist/extensions/get-extensions.js +0 -9
  97. package/dist/extensions/normalize-extension-info.d.ts +0 -5
  98. package/dist/extensions/normalize-extension-info.js +0 -30
  99. /package/dist/extensions/{get-shared-deps-mapping.d.ts → lib/get-shared-deps-mapping.d.ts} +0 -0
  100. /package/dist/extensions/{wrap-embeds.d.ts → lib/wrap-embeds.d.ts} +0 -0
  101. /package/dist/extensions/{wrap-embeds.js → lib/wrap-embeds.js} +0 -0
@@ -8,9 +8,9 @@ import nodeResolveDefault from '@rollup/plugin-node-resolve';
8
8
  import virtualDefault from '@rollup/plugin-virtual';
9
9
  import chokidar, { FSWatcher } from 'chokidar';
10
10
  import express, { Router } from 'express';
11
+ import ivm from 'isolated-vm';
11
12
  import { clone } from 'lodash-es';
12
- import { readdir } from 'node:fs/promises';
13
- import { createRequire } from 'node:module';
13
+ import { readFile, readdir } from 'node:fs/promises';
14
14
  import { dirname } from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
16
  import path from 'path';
@@ -21,50 +21,96 @@ import env from '../env.js';
21
21
  import { getFlowManager } from '../flows.js';
22
22
  import logger from '../logger.js';
23
23
  import * as services from '../services/index.js';
24
+ import { deleteFromRequireCache } from '../utils/delete-from-require-cache.js';
24
25
  import getModuleDefault from '../utils/get-module-default.js';
25
26
  import { getSchema } from '../utils/get-schema.js';
27
+ import { importFileUrl } from '../utils/import-file-url.js';
26
28
  import { JobQueue } from '../utils/job-queue.js';
27
29
  import { scheduleSynchronizedJob, validateCron } from '../utils/schedule.js';
28
- import { getExtensions } from './get-extensions.js';
29
- import { getSharedDepsMapping } from './get-shared-deps-mapping.js';
30
- import { normalizeExtensionInfo } from './normalize-extension-info.js';
31
- import { wrapEmbeds } from './wrap-embeds.js';
30
+ import { getExtensionsSettings } from './lib/get-extensions-settings.js';
31
+ import { getExtensions } from './lib/get-extensions.js';
32
+ import { getSharedDepsMapping } from './lib/get-shared-deps-mapping.js';
33
+ import { generateApiExtensionsSandboxEntrypoint } from './lib/sandbox/generate-api-extensions-sandbox-entrypoint.js';
34
+ import { instantiateSandboxSdk } from './lib/sandbox/sdk/instantiate.js';
35
+ import { wrapEmbeds } from './lib/wrap-embeds.js';
32
36
  // Workaround for https://github.com/rollup/plugins/issues/1329
33
37
  const virtual = virtualDefault;
34
38
  const alias = aliasDefault;
35
39
  const nodeResolve = nodeResolveDefault;
36
- const require = createRequire(import.meta.url);
37
40
  const __dirname = dirname(fileURLToPath(import.meta.url));
38
- const defaultOptions = {
39
- schedule: true,
40
- watch: env['EXTENSIONS_AUTO_RELOAD'] && env['NODE_ENV'] !== 'development',
41
- };
42
41
  export class ExtensionManager {
42
+ options = {
43
+ schedule: true,
44
+ watch: env['EXTENSIONS_AUTO_RELOAD'] && env['NODE_ENV'] !== 'development',
45
+ };
46
+ /**
47
+ * Whether or not the extensions have been read from disk and registered into the system
48
+ */
43
49
  isLoaded = false;
44
- options;
50
+ /**
51
+ * All extensions that are loaded within the current process
52
+ */
45
53
  extensions = [];
46
- appExtensions = null;
47
- appExtensionChunks;
48
- apiExtensions = [];
49
- apiEmitter;
50
- hookEvents = [];
51
- endpointRouter;
54
+ /**
55
+ * Settings for the extensions that are loaded within the current process
56
+ */
57
+ extensionsSettings = [];
58
+ /**
59
+ * App extensions rolled up into a single bundle. Any chunks from the bundle will be available
60
+ * under appExtensionChunks
61
+ */
62
+ appExtensionsBundle = null;
63
+ /**
64
+ * Individual filename chunks from the rollup bundle. Used to improve the performance by allowing
65
+ * extensions to split up their bundle into multiple smaller chunks
66
+ */
67
+ appExtensionChunks = new Map();
68
+ /**
69
+ * Callbacks to be able to unregister extensions
70
+ */
71
+ unregisterFunctionMap = new Map();
72
+ /**
73
+ * A local-to-extensions scoped emitter that can be used to fire and listen to custom events
74
+ * between extensions. These events are completely isolated from the core events that trigger
75
+ * hooks etc
76
+ */
77
+ localEmitter = new Emitter();
78
+ /**
79
+ * Locally scoped express router used for custom endpoints. Allows extensions to dynamically
80
+ * register and de-register endpoints without affecting the regular global router
81
+ */
82
+ endpointRouter = Router();
83
+ /**
84
+ * Custom HTML to be injected at the end of the `<head>` tag of the app's index.html
85
+ */
52
86
  hookEmbedsHead = [];
87
+ /**
88
+ * Custom HTML to be injected at the end of the `<body>` tag of the app's index.html
89
+ */
53
90
  hookEmbedsBody = [];
54
- reloadQueue;
91
+ /**
92
+ * Used to prevent race conditions when reloading extensions. Forces each reload to happen in
93
+ * sequence.
94
+ */
95
+ reloadQueue = new JobQueue();
96
+ /**
97
+ * Optional file system watcher to auto-reload extensions when the local file system changes
98
+ */
55
99
  watcher = null;
56
- constructor() {
57
- this.options = defaultOptions;
58
- this.apiEmitter = new Emitter();
59
- this.endpointRouter = Router();
60
- this.reloadQueue = new JobQueue();
61
- this.appExtensionChunks = new Map();
62
- }
100
+ /**
101
+ * Load and register all extensions
102
+ *
103
+ * @param {ExtensionManagerOptions} options - Extension manager configuration options
104
+ * @param {boolean} options.schedule - Whether or not to allow for scheduled (CRON) hook extensions
105
+ * @param {boolean} options.watch - Whether or not to watch the local extensions folder for changes
106
+ */
63
107
  async initialize(options = {}) {
64
- this.options = {
65
- ...defaultOptions,
66
- ...options,
67
- };
108
+ if (options.schedule !== undefined) {
109
+ this.options.schedule = options.schedule;
110
+ }
111
+ if (options.watch !== undefined) {
112
+ this.options.watch = options.watch;
113
+ }
68
114
  const wasWatcherInitialized = this.watcher !== null;
69
115
  if (this.options.watch && !wasWatcherInitialized) {
70
116
  this.initializeWatcher();
@@ -74,15 +120,48 @@ export class ExtensionManager {
74
120
  }
75
121
  if (!this.isLoaded) {
76
122
  await this.load();
77
- const loadedExtensions = this.getExtensionsList();
78
- if (loadedExtensions.length > 0) {
79
- logger.info(`Loaded extensions: ${loadedExtensions.map((ext) => ext.name).join(', ')}`);
123
+ if (this.extensions.length > 0) {
124
+ logger.info(`Loaded extensions: ${this.extensions.map((ext) => ext.name).join(', ')}`);
80
125
  }
81
126
  }
82
127
  if (this.options.watch && !wasWatcherInitialized) {
83
128
  this.updateWatchedExtensions(this.extensions);
84
129
  }
85
130
  }
131
+ /**
132
+ * Load all extensions from disk and register them in their respective places
133
+ */
134
+ async load() {
135
+ try {
136
+ await ensureExtensionDirs(env['EXTENSIONS_PATH'], NESTED_EXTENSION_TYPES);
137
+ this.extensions = await getExtensions();
138
+ this.extensionsSettings = await getExtensionsSettings(this.extensions);
139
+ }
140
+ catch (err) {
141
+ logger.warn(`Couldn't load extensions`);
142
+ logger.warn(err);
143
+ }
144
+ await this.registerHooks();
145
+ await this.registerEndpoints();
146
+ await this.registerOperations();
147
+ await this.registerBundles();
148
+ if (env['SERVE_APP']) {
149
+ this.appExtensionsBundle = await this.generateExtensionBundle();
150
+ }
151
+ this.isLoaded = true;
152
+ }
153
+ /**
154
+ * Unregister all extensions from the current process
155
+ */
156
+ async unload() {
157
+ await this.unregisterApiExtensions();
158
+ this.localEmitter.offAll();
159
+ this.appExtensionsBundle = null;
160
+ this.isLoaded = false;
161
+ }
162
+ /**
163
+ * Reload all the extensions. Will unload if extensions have already been loaded
164
+ */
86
165
  reload() {
87
166
  this.reloadQueue.enqueue(async () => {
88
167
  if (this.isLoaded) {
@@ -107,57 +186,42 @@ export class ExtensionManager {
107
186
  }
108
187
  });
109
188
  }
110
- getExtensionsList(type) {
111
- const extensionInfo = this.extensions.map(normalizeExtensionInfo);
112
- if (type) {
113
- return extensionInfo.filter((extension) => extension.type === type);
114
- }
115
- return extensionInfo;
116
- }
117
- getExtension(name) {
118
- return this.extensions.find((extension) => extension.name === name);
119
- }
120
- getAppExtensions() {
121
- return this.appExtensions;
189
+ /**
190
+ * Return the previously generated app extensions bundle
191
+ */
192
+ getAppExtensionsBundle() {
193
+ return this.appExtensionsBundle;
122
194
  }
195
+ /**
196
+ * Return the previously generated app extension bundle chunk by name
197
+ */
123
198
  getAppExtensionChunk(name) {
124
199
  return this.appExtensionChunks.get(name) ?? null;
125
200
  }
201
+ /**
202
+ * Return the scoped router for custom endpoints
203
+ */
126
204
  getEndpointRouter() {
127
205
  return this.endpointRouter;
128
206
  }
207
+ /**
208
+ * Return the custom HTML head and body embeds wrapped in a marker comment
209
+ */
129
210
  getEmbeds() {
130
211
  return {
131
212
  head: wrapEmbeds('Custom Embed Head', this.hookEmbedsHead),
132
213
  body: wrapEmbeds('Custom Embed Body', this.hookEmbedsBody),
133
214
  };
134
215
  }
135
- async load() {
136
- try {
137
- await ensureExtensionDirs(env['EXTENSIONS_PATH'], NESTED_EXTENSION_TYPES);
138
- this.extensions = await getExtensions();
139
- }
140
- catch (err) {
141
- logger.warn(`Couldn't load extensions`);
142
- logger.warn(err);
143
- }
144
- await this.registerHooks();
145
- await this.registerEndpoints();
146
- await this.registerOperations();
147
- await this.registerBundles();
148
- if (env['SERVE_APP']) {
149
- this.appExtensions = await this.generateExtensionBundle();
150
- }
151
- this.isLoaded = true;
152
- }
153
- async unload() {
154
- await this.unregisterApiExtensions();
155
- this.apiEmitter.offAll();
156
- if (env['SERVE_APP']) {
157
- this.appExtensions = null;
158
- }
159
- this.isLoaded = false;
216
+ /**
217
+ * Allow reading the installed extensions
218
+ */
219
+ getExtensions() {
220
+ return this.extensions;
160
221
  }
222
+ /**
223
+ * Start the chokidar watcher for extensions on the local filesystem
224
+ */
161
225
  initializeWatcher() {
162
226
  logger.info('Watching extensions for changes...');
163
227
  const extensionDirUrl = pathToRelativeUrl(env['EXTENSIONS_PATH']);
@@ -181,12 +245,19 @@ export class ExtensionManager {
181
245
  .on('change', () => this.reload())
182
246
  .on('unlink', () => this.reload());
183
247
  }
248
+ /**
249
+ * Close and destroy the local filesystem watcher if enabled
250
+ */
184
251
  async closeWatcher() {
185
252
  if (this.watcher) {
186
253
  await this.watcher.close();
187
254
  this.watcher = null;
188
255
  }
189
256
  }
257
+ /**
258
+ * Update the chokidar watcher configuration when new extensions are added or existing ones
259
+ * removed
260
+ */
190
261
  updateWatchedExtensions(added, removed = []) {
191
262
  if (this.watcher) {
192
263
  const toPackageExtensionPaths = (extensions) => extensions
@@ -203,6 +274,10 @@ export class ExtensionManager {
203
274
  this.watcher.unwatch(removedPackageExtensionPaths);
204
275
  }
205
276
  }
277
+ /**
278
+ * Uses rollup to bundle the app extensions together into a single file the app can download and
279
+ * run.
280
+ */
206
281
  async generateExtensionBundle() {
207
282
  const sharedDepsMapping = await getSharedDepsMapping(APP_SHARED_DEPS);
208
283
  const internalImports = Object.entries(sharedDepsMapping).map(([name, path]) => ({
@@ -232,15 +307,66 @@ export class ExtensionManager {
232
307
  }
233
308
  return null;
234
309
  }
310
+ async registerSandboxedApiExtension(extension) {
311
+ const sandboxMemory = Number(env['EXTENSIONS_SANDBOX_MEMORY']);
312
+ const sandboxTimeout = Number(env['EXTENSIONS_SANDBOX_TIMEOUT']);
313
+ const entrypointPath = path.resolve(extension.path, isTypeIn(extension, HYBRID_EXTENSION_TYPES) ? extension.entrypoint.api : extension.entrypoint);
314
+ const extensionCode = await readFile(entrypointPath, 'utf-8');
315
+ const isolate = new ivm.Isolate({
316
+ memoryLimit: sandboxMemory,
317
+ onCatastrophicError: (e) => {
318
+ logger.error(`Error in API extension sandbox of ${extension.type} "${extension.name}"`);
319
+ logger.error(e);
320
+ process.abort();
321
+ },
322
+ });
323
+ const context = await isolate.createContext();
324
+ const module = await isolate.compileModule(extensionCode, { filename: `file://${entrypointPath}` });
325
+ const sdkModule = await instantiateSandboxSdk(isolate, extension.sandbox?.requestedScopes ?? {});
326
+ await module.instantiate(context, (specifier) => {
327
+ if (specifier !== 'directus:api') {
328
+ throw new Error('Imports other than "directus:api" are prohibited in API extension sandboxes');
329
+ }
330
+ return sdkModule;
331
+ });
332
+ await module.evaluate({ timeout: sandboxTimeout });
333
+ const cb = await module.namespace.get('default', { reference: true });
334
+ const { code, hostFunctions, unregisterFunction } = generateApiExtensionsSandboxEntrypoint(extension.type, extension.name, this.endpointRouter);
335
+ await context.evalClosure(code, [cb, ...hostFunctions.map((fn) => new ivm.Reference(fn))], {
336
+ timeout: sandboxTimeout,
337
+ filename: '<extensions-sandbox>',
338
+ });
339
+ this.unregisterFunctionMap.set(extension.name, async () => {
340
+ await unregisterFunction();
341
+ isolate.dispose();
342
+ });
343
+ }
344
+ /**
345
+ * Import the hook module code for all hook extensions, and register them individually through
346
+ * registerHook
347
+ */
235
348
  async registerHooks() {
236
349
  const hooks = this.extensions.filter((extension) => extension.type === 'hook');
237
350
  for (const hook of hooks) {
351
+ const { enabled } = this.extensionsSettings.find(({ name }) => name === hook.name) ?? { enabled: false };
352
+ if (!enabled)
353
+ continue;
238
354
  try {
239
- const hookPath = path.resolve(hook.path, hook.entrypoint);
240
- const hookInstance = await import(`./${pathToRelativeUrl(hookPath, __dirname)}?t=${Date.now()}`);
241
- const config = getModuleDefault(hookInstance);
242
- this.registerHook(config, hook.name);
243
- this.apiExtensions.push({ path: hookPath });
355
+ if (hook.sandbox?.enabled) {
356
+ await this.registerSandboxedApiExtension(hook);
357
+ }
358
+ else {
359
+ const hookPath = path.resolve(hook.path, hook.entrypoint);
360
+ const hookInstance = await importFileUrl(hookPath, import.meta.url, {
361
+ fresh: true,
362
+ });
363
+ const config = getModuleDefault(hookInstance);
364
+ const unregisterFunctions = this.registerHook(config, hook.name);
365
+ this.unregisterFunctionMap.set(hook.name, async () => {
366
+ await Promise.all(unregisterFunctions.map((fn) => fn()));
367
+ deleteFromRequireCache(hookPath);
368
+ });
369
+ }
244
370
  }
245
371
  catch (error) {
246
372
  logger.warn(`Couldn't register hook "${hook.name}"`);
@@ -248,15 +374,32 @@ export class ExtensionManager {
248
374
  }
249
375
  }
250
376
  }
377
+ /**
378
+ * Import the endpoint module code for all endpoint extensions, and register them individually through
379
+ * registerEndpoint
380
+ */
251
381
  async registerEndpoints() {
252
382
  const endpoints = this.extensions.filter((extension) => extension.type === 'endpoint');
253
383
  for (const endpoint of endpoints) {
384
+ const { enabled } = this.extensionsSettings.find(({ name }) => name === endpoint.name) ?? { enabled: false };
385
+ if (!enabled)
386
+ continue;
254
387
  try {
255
- const endpointPath = path.resolve(endpoint.path, endpoint.entrypoint);
256
- const endpointInstance = await import(`./${pathToRelativeUrl(endpointPath, __dirname)}?t=${Date.now()}`);
257
- const config = getModuleDefault(endpointInstance);
258
- this.registerEndpoint(config, endpoint.name);
259
- this.apiExtensions.push({ path: endpointPath });
388
+ if (endpoint.sandbox?.enabled) {
389
+ await this.registerSandboxedApiExtension(endpoint);
390
+ }
391
+ else {
392
+ const endpointPath = path.resolve(endpoint.path, endpoint.entrypoint);
393
+ const endpointInstance = await importFileUrl(endpointPath, import.meta.url, {
394
+ fresh: true,
395
+ });
396
+ const config = getModuleDefault(endpointInstance);
397
+ const unregister = this.registerEndpoint(config, endpoint.name);
398
+ this.unregisterFunctionMap.set(endpoint.name, async () => {
399
+ await unregister();
400
+ deleteFromRequireCache(endpointPath);
401
+ });
402
+ }
260
403
  }
261
404
  catch (error) {
262
405
  logger.warn(`Couldn't register endpoint "${endpoint.name}"`);
@@ -264,6 +407,10 @@ export class ExtensionManager {
264
407
  }
265
408
  }
266
409
  }
410
+ /**
411
+ * Import the operation module code for all operation extensions, and register them individually through
412
+ * registerOperation
413
+ */
267
414
  async registerOperations() {
268
415
  const internalOperations = await readdir(path.join(__dirname, '..', 'operations'));
269
416
  for (const operation of internalOperations) {
@@ -273,12 +420,25 @@ export class ExtensionManager {
273
420
  }
274
421
  const operations = this.extensions.filter((extension) => extension.type === 'operation');
275
422
  for (const operation of operations) {
423
+ const { enabled } = this.extensionsSettings.find(({ name }) => name === operation.name) ?? { enabled: false };
424
+ if (!enabled)
425
+ continue;
276
426
  try {
277
- const operationPath = path.resolve(operation.path, operation.entrypoint.api);
278
- const operationInstance = await import(`./${pathToRelativeUrl(operationPath, __dirname)}?t=${Date.now()}`);
279
- const config = getModuleDefault(operationInstance);
280
- this.registerOperation(config);
281
- this.apiExtensions.push({ path: operationPath });
427
+ if (operation.sandbox?.enabled) {
428
+ await this.registerSandboxedApiExtension(operation);
429
+ }
430
+ else {
431
+ const operationPath = path.resolve(operation.path, operation.entrypoint.api);
432
+ const operationInstance = await importFileUrl(operationPath, import.meta.url, {
433
+ fresh: true,
434
+ });
435
+ const config = getModuleDefault(operationInstance);
436
+ const unregister = this.registerOperation(config);
437
+ this.unregisterFunctionMap.set(operation.name, async () => {
438
+ await unregister();
439
+ deleteFromRequireCache(operationPath);
440
+ });
441
+ }
282
442
  }
283
443
  catch (error) {
284
444
  logger.warn(`Couldn't register operation "${operation.name}"`);
@@ -286,23 +446,36 @@ export class ExtensionManager {
286
446
  }
287
447
  }
288
448
  }
449
+ /**
450
+ * Import the module code for all hook, endpoint, and operation extensions registered within a
451
+ * bundle, and register them with their respective registration function
452
+ */
289
453
  async registerBundles() {
290
454
  const bundles = this.extensions.filter((extension) => extension.type === 'bundle');
291
455
  for (const bundle of bundles) {
292
456
  try {
293
457
  const bundlePath = path.resolve(bundle.path, bundle.entrypoint.api);
294
- const bundleInstances = await import(`./${pathToRelativeUrl(bundlePath, __dirname)}?t=${Date.now()}`);
458
+ const bundleInstances = await importFileUrl(bundlePath, import.meta.url, {
459
+ fresh: true,
460
+ });
295
461
  const configs = getModuleDefault(bundleInstances);
462
+ const unregisterFunctions = [];
296
463
  for (const { config, name } of configs.hooks) {
297
- this.registerHook(config, name);
464
+ const unregisters = this.registerHook(config, name);
465
+ unregisterFunctions.push(...unregisters);
298
466
  }
299
467
  for (const { config, name } of configs.endpoints) {
300
- this.registerEndpoint(config, name);
468
+ const unregister = this.registerEndpoint(config, name);
469
+ unregisterFunctions.push(unregister);
301
470
  }
302
471
  for (const { config } of configs.operations) {
303
- this.registerOperation(config);
472
+ const unregister = this.registerOperation(config);
473
+ unregisterFunctions.push(unregister);
304
474
  }
305
- this.apiExtensions.push({ path: bundlePath });
475
+ this.unregisterFunctionMap.set(bundle.name, async () => {
476
+ await Promise.all(unregisterFunctions.map((fn) => fn()));
477
+ deleteFromRequireCache(bundlePath);
478
+ });
306
479
  }
307
480
  catch (error) {
308
481
  logger.warn(`Couldn't register bundle "${bundle.name}"`);
@@ -310,31 +483,29 @@ export class ExtensionManager {
310
483
  }
311
484
  }
312
485
  }
313
- registerHook(register, name) {
486
+ /**
487
+ * Register a single hook
488
+ */
489
+ registerHook(hookRegistrationCallback, name) {
314
490
  let scheduleIndex = 0;
315
- const registerFunctions = {
491
+ const unregisterFunctions = [];
492
+ const hookRegistrationContext = {
316
493
  filter: (event, handler) => {
317
494
  emitter.onFilter(event, handler);
318
- this.hookEvents.push({
319
- type: 'filter',
320
- name: event,
321
- handler,
495
+ unregisterFunctions.push(() => {
496
+ emitter.offFilter(event, handler);
322
497
  });
323
498
  },
324
499
  action: (event, handler) => {
325
500
  emitter.onAction(event, handler);
326
- this.hookEvents.push({
327
- type: 'action',
328
- name: event,
329
- handler,
501
+ unregisterFunctions.push(() => {
502
+ emitter.offAction(event, handler);
330
503
  });
331
504
  },
332
505
  init: (event, handler) => {
333
506
  emitter.onInit(event, handler);
334
- this.hookEvents.push({
335
- type: 'init',
336
- name: event,
337
- handler,
507
+ unregisterFunctions.push(() => {
508
+ emitter.offInit(name, handler);
338
509
  });
339
510
  },
340
511
  schedule: (cron, handler) => {
@@ -350,9 +521,8 @@ export class ExtensionManager {
350
521
  }
351
522
  });
352
523
  scheduleIndex++;
353
- this.hookEvents.push({
354
- type: 'schedule',
355
- job,
524
+ unregisterFunctions.push(async () => {
525
+ await job.stop();
356
526
  });
357
527
  }
358
528
  else {
@@ -361,69 +531,74 @@ export class ExtensionManager {
361
531
  },
362
532
  embed: (position, code) => {
363
533
  const content = typeof code === 'function' ? code() : code;
364
- if (content.trim().length === 0) {
365
- logger.warn(`Couldn't register embed hook. Provided code is empty!`);
366
- return;
367
- }
368
- if (position === 'head') {
369
- this.hookEmbedsHead.push(content);
534
+ if (content.trim().length !== 0) {
535
+ if (position === 'head') {
536
+ const index = this.hookEmbedsHead.length;
537
+ this.hookEmbedsHead.push(content);
538
+ unregisterFunctions.push(() => {
539
+ this.hookEmbedsHead.splice(index, 1);
540
+ });
541
+ }
542
+ else {
543
+ const index = this.hookEmbedsBody.length;
544
+ this.hookEmbedsBody.push(content);
545
+ unregisterFunctions.push(() => {
546
+ this.hookEmbedsBody.splice(index, 1);
547
+ });
548
+ }
370
549
  }
371
- if (position === 'body') {
372
- this.hookEmbedsBody.push(content);
550
+ else {
551
+ logger.warn(`Couldn't register embed hook. Provided code is empty!`);
373
552
  }
374
553
  },
375
554
  };
376
- register(registerFunctions, {
555
+ hookRegistrationCallback(hookRegistrationContext, {
377
556
  services,
378
557
  env,
379
558
  database: getDatabase(),
380
- emitter: this.apiEmitter,
559
+ emitter: this.localEmitter,
381
560
  logger,
382
561
  getSchema,
383
562
  });
563
+ return unregisterFunctions;
384
564
  }
565
+ /**
566
+ * Register an individual endpoint
567
+ */
385
568
  registerEndpoint(config, name) {
386
- const register = typeof config === 'function' ? config : config.handler;
569
+ const endpointRegistrationCallback = typeof config === 'function' ? config : config.handler;
387
570
  const routeName = typeof config === 'function' ? name : config.id;
388
571
  const scopedRouter = express.Router();
389
572
  this.endpointRouter.use(`/${routeName}`, scopedRouter);
390
- register(scopedRouter, {
573
+ endpointRegistrationCallback(scopedRouter, {
391
574
  services,
392
575
  env,
393
576
  database: getDatabase(),
394
- emitter: this.apiEmitter,
577
+ emitter: this.localEmitter,
395
578
  logger,
396
579
  getSchema,
397
580
  });
581
+ const unregisterFunction = () => {
582
+ this.endpointRouter.stack = this.endpointRouter.stack.filter((layer) => scopedRouter !== layer.handle);
583
+ };
584
+ return unregisterFunction;
398
585
  }
586
+ /**
587
+ * Register an individual operation
588
+ */
399
589
  registerOperation(config) {
400
590
  const flowManager = getFlowManager();
401
591
  flowManager.addOperation(config.id, config.handler);
592
+ const unregisterFunction = () => {
593
+ flowManager.removeOperation(config.id);
594
+ };
595
+ return unregisterFunction;
402
596
  }
597
+ /**
598
+ * Remove the registration for all API extensions
599
+ */
403
600
  async unregisterApiExtensions() {
404
- for (const event of this.hookEvents) {
405
- switch (event.type) {
406
- case 'filter':
407
- emitter.offFilter(event.name, event.handler);
408
- break;
409
- case 'action':
410
- emitter.offAction(event.name, event.handler);
411
- break;
412
- case 'init':
413
- emitter.offInit(event.name, event.handler);
414
- break;
415
- case 'schedule':
416
- await event.job.stop();
417
- break;
418
- }
419
- }
420
- this.hookEvents = [];
421
- this.endpointRouter.stack = [];
422
- const flowManager = getFlowManager();
423
- flowManager.clearOperations();
424
- for (const apiExtension of this.apiExtensions) {
425
- delete require.cache[require.resolve(apiExtension.path)];
426
- }
427
- this.apiExtensions = [];
601
+ const unregisterFunctions = Array.from(this.unregisterFunctionMap.values());
602
+ await Promise.all(unregisterFunctions.map((fn) => fn()));
428
603
  }
429
604
  }
@@ -13,11 +13,7 @@ export type BundleConfig = {
13
13
  config: OperationApiConfig;
14
14
  }[];
15
15
  };
16
- export type AppExtensions = string | null;
17
- export type ApiExtensions = {
18
- path: string;
19
- }[];
20
- export interface Options {
16
+ export interface ExtensionManagerOptions {
21
17
  schedule: boolean;
22
18
  watch: boolean;
23
19
  }