@directus/api 13.2.0 → 14.0.1

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