Browse Source

Merge branch 'plugin-redesign'

Michael Bromley 6 years ago
parent
commit
63ccf72e9e
54 changed files with 1735 additions and 1091 deletions
  1. 134 7
      docs/content/docs/plugins/_index.md
  2. 130 131
      docs/content/docs/plugins/writing-a-vendure-plugin.md
  3. 1 0
      packages/admin-ui-plugin/package.json
  4. 34 12
      packages/admin-ui-plugin/src/plugin.ts
  5. 1 0
      packages/asset-server-plugin/package.json
  6. 50 27
      packages/asset-server-plugin/src/plugin.ts
  7. 180 122
      packages/core/e2e/default-search-plugin.e2e-spec.ts
  8. 78 50
      packages/core/e2e/fixtures/test-plugins.ts
  9. 34 9
      packages/core/e2e/plugin.e2e-spec.ts
  10. 31 15
      packages/core/e2e/shop-auth.e2e-spec.ts
  11. 9 7
      packages/core/e2e/test-server.ts
  12. 16 10
      packages/core/src/api/api-internal-modules.ts
  13. 4 4
      packages/core/src/api/common/get-api-type.ts
  14. 32 0
      packages/core/src/api/common/parse-context.ts
  15. 1 1
      packages/core/src/api/common/request-context.service.ts
  16. 12 6
      packages/core/src/api/config/configure-graphql-module.ts
  17. 8 2
      packages/core/src/api/decorators/request-context.decorator.ts
  18. 13 9
      packages/core/src/api/middleware/asset-interceptor.ts
  19. 2 5
      packages/core/src/api/middleware/auth-guard.ts
  20. 12 8
      packages/core/src/api/middleware/id-interceptor.ts
  21. 41 18
      packages/core/src/api/middleware/validate-custom-fields-interceptor.ts
  22. 2 16
      packages/core/src/api/resolvers/entity/payment-entity.resolver.ts
  23. 2 15
      packages/core/src/api/resolvers/entity/refund-entity.resolver.ts
  24. 5 13
      packages/core/src/app.module.ts
  25. 11 37
      packages/core/src/bootstrap.ts
  26. 3 6
      packages/core/src/config/config.service.ts
  27. 0 1
      packages/core/src/config/index.ts
  28. 2 2
      packages/core/src/config/vendure-config.ts
  29. 0 108
      packages/core/src/config/vendure-plugin/vendure-plugin.ts
  30. 1 1
      packages/core/src/data-import/data-import.module.ts
  31. 1 0
      packages/core/src/event-bus/index.ts
  32. 1 1
      packages/core/src/plugin/default-search-plugin/constants.ts
  33. 20 63
      packages/core/src/plugin/default-search-plugin/default-search-plugin.ts
  34. 8 2
      packages/core/src/plugin/default-search-plugin/fulltext-search.service.ts
  35. 51 32
      packages/core/src/plugin/default-search-plugin/indexer/indexer.controller.ts
  36. 53 0
      packages/core/src/plugin/dynamic-plugin-api.module.ts
  37. 2 0
      packages/core/src/plugin/index.ts
  38. 41 0
      packages/core/src/plugin/plugin-common.module.ts
  39. 90 0
      packages/core/src/plugin/plugin-metadata.ts
  40. 23 31
      packages/core/src/plugin/plugin-utils.ts
  41. 82 63
      packages/core/src/plugin/plugin.module.ts
  42. 190 0
      packages/core/src/plugin/vendure-plugin.ts
  43. 26 13
      packages/core/src/service/service.module.ts
  44. 13 0
      packages/core/src/service/services/search.service.ts
  45. 1 16
      packages/core/src/worker/worker.module.ts
  46. 23 15
      packages/create/src/create-vendure-app.ts
  47. 39 43
      packages/create/src/helpers.ts
  48. 4 4
      packages/create/templates/vendure-config.hbs
  49. 16 7
      packages/dev-server/dev-config.ts
  50. 21 0
      packages/dev-server/rest-plugin.ts
  51. 19 14
      packages/elasticsearch-plugin/src/elasticsearch.service.ts
  52. 47 71
      packages/elasticsearch-plugin/src/plugin.ts
  53. 53 34
      packages/email-plugin/src/plugin.spec.ts
  54. 62 40
      packages/email-plugin/src/plugin.ts

+ 134 - 7
docs/content/docs/plugins/_index.md

@@ -1,7 +1,7 @@
 ---
 ---
 title: "Plugins"
 title: "Plugins"
 weight: 2
 weight: 2
-showtoc: false
+showtoc: true
 ---
 ---
  
  
 # Plugins
 # Plugins
@@ -21,10 +21,137 @@ This section details the official Vendure plugins included in the main Vendure r
 
 
 {{< figure src="plugin_architecture.png" >}}
 {{< figure src="plugin_architecture.png" >}}
 
 
-This diagram illustrates the how a plugin can integrate with and extend Vendure.
-
-1. A Plugin may define logic to be run by the Vendure Worker. This is suitable for long-running or resource-intensive tasks and is done by providing controllers via the [`VendurePlugin.defineWorkers()` method]({{< relref "vendure-plugin" >}}#defineworkers).
-2. A Plugin can modify any aspect of server configuration via the [`VendurePlugin.configure()` method]({{< relref "vendure-plugin" >}}#configure).
-3. A Plugin can extend the GraphQL APIs via the [`VendurePlugin.extendShopAPI()` method]({{< relref "vendure-plugin" >}}#extendshopapi) and the [`VendurePlugin.extendAdminAPI()` method]({{< relref "vendure-plugin" >}}#extendadminapi).
-4. A Plugin has full access to the ServiceModule, which means it may inject and of the core Vendure services (which are responsible for all interaction with the database as well as business logic). Additionally a plugin may define its own services via the [`VendurePlugin.defineProviders()` method]({{< relref "vendure-plugin" >}}#defineproviders) and may define new database entities via the [`VendurePlugin.defineEntities()` method]({{< relref "vendure-plugin" >}}#defineentities).
+A plugin in Vendure is a specialized Nestjs Module which is decorated with the [`VendurePlugin` class decorator]({{< relref "vendure-plugin" >}}). This diagram illustrates the how a plugin can integrate with and extend Vendure.
+ 
+1. A Plugin may define logic to be run by the [Vendure Worker]({{< relref "vendure-worker" >}}). This is suitable for long-running or resource-intensive tasks and is done by providing controllers via the [`workers` metadata property]({{< relref "vendure-plugin-metadata" >}}#workers).
+2. A Plugin can modify any aspect of server configuration via the [`configuration` metadata property]({{< relref "vendure-plugin-metadata" >}}#configuration).
+3. A Plugin can extend the GraphQL APIs via the [`shopApiExtensions` metadata property]({{< relref "vendure-plugin-metadata" >}}#shopapiextensions) and the [`adminApiExtensions` metadata property]({{< relref "vendure-plugin-metadata" >}}#adminapiextensions).
+4. A Plugin can interact with Vendure by importing the [`PluginCommonModule`]({{< relref "plugin-common-module" >}}), by which it may inject and of the core Vendure services (which are responsible for all interaction with the database as well as business logic). Additionally a plugin may define new database entities via the [`entities` metadata property]({{< relref "vendure-plugin-metadata" >}}#entities) and otherwise define any other providers and controllers just like any [Nestjs module](https://docs.nestjs.com/modules).
 5. A Plugin can run arbitrary code, which allows it to make use of external services. For example, a plugin could interface with a cloud storage provider, a payment gateway, or a video encoding service.
 5. A Plugin can run arbitrary code, which allows it to make use of external services. For example, a plugin could interface with a cloud storage provider, a payment gateway, or a video encoding service.
+
+## Plugin Examples
+
+Here are some simplified examples of plugins which serve to illustrate what can be done with Vendure plugins. *Note: implementation details are skipped in these examples for the sake of brevity. A complete example with explanation can be found in [Writing A Vendure Plugin]({{< relref "writing-a-vendure-plugin" >}}).*
+
+### Modifying the VendureConfig
+
+This example shows how to modify the VendureConfig, in this case by adding a custom field to allow product ratings.
+```TypeScript
+@VendurePlugin({
+  configure: config => {
+    config.customFields.Product.push({
+      name: 'rating',
+      type: 'float',
+      min: 0,
+      max: 5,
+    });
+    return config;
+  },
+})
+class ProductRatingPlugin {}
+```
+
+### Extending the GraphQL API
+
+This example adds a new query to the GraphQL Admin API. It also demonstrates how [Nest's dependency injection](https://docs.nestjs.com/providers) can be used to encapsulate and inject services within the plugin module.
+ 
+```TypeScript
+@VendurePlugin({
+  imports: [PluginCommonModule],
+  providers: [TopSellersService],
+  adminApiExtensions: {
+    schema: gql`
+      extend type Query {
+        topSellers(from: DateTime! to: DateTime!): [Product!]!
+      }
+    `,
+    resolvers: [TopSellersResolver]
+  }
+})
+export class TopSellersPlugin {}
+
+@Resolver()
+class TopSellersResolver {
+
+  constructor(private topSellersService: TopSellersService) {}
+
+  @Query()
+  topSellers(@Ctx() ctx: RequestContext, @Args() args: any) {
+    return this.topSellersService.getTopSellers(ctx, args.from, args.to);
+  }
+
+}
+
+@Injectable()
+class TopSellersService { 
+  getTopSellers() { /* ... */ }
+}
+```
+
+### Adding a REST endpoint
+
+This plugin adds a single REST endpoint at `<host:port>/products` which returns a list of all Products. Since this uses no Vendure-specific metadata, it could also be written using the Nestjs `@Module()` decorator rather than the `@VendurePlugin()` decorator.
+
+```TypeScript
+@VendurePlugin({
+    imports: [PluginCommonModule],
+    controllers: [ProductsController],
+})
+export class RestPlugin {}
+
+@Controller('products')
+export class ProductsController {
+    constructor(private productService: ProductService) {}
+
+    @Get()
+    findAll(@Ctx() ctx: RequestContext) {
+        return this.productService.findAll(ctx);
+    }
+}
+```
+
+### Running processes on the Worker
+
+This example shows how to set up a microservice running on the Worker process, as well as subcribing to events via the EventBus.
+
+```TypeScript
+@VendurePlugin({
+  imports: [PluginCommonModule],
+  workers: [OrderProcessingController],
+})
+export class OrderAnalyticsPlugin implements OnVendureBootstrap {
+
+  constructor(
+    @Inject(VENDURE_WORKER_CLIENT) private client: ClientProxy,
+    private eventBus: EventBus,
+  ) {}
+  
+  /**
+   * When the server bootstraps, set up a subscription for events 
+   * published whenever  an Order changes state. When an Order has 
+   * been fulfilled, we send a message to the controller running on
+   * the Worker process to let it process that order.
+   */
+  onVendureBootstrap() {
+    this.eventBus.subscribe(OrderStateTransitionEvent, event => {
+      if (event.toState === 'Fulfilled') {
+        this.client.send('ORDER_PLACED', event.order).subscribe();
+      }
+    });
+  }
+
+}
+
+/**
+ * This controller will run on the Worker process.
+ */
+@Controller()
+class OrderProcessingController {
+
+  @MessagePattern('ORDER_PLACED')
+  async processOrder(order) {
+    // Do some expensive computation
+  }
+
+}
+```

+ 130 - 131
docs/content/docs/plugins/writing-a-vendure-plugin.md

@@ -5,57 +5,53 @@ weight: 0
 
 
 # Writing a Vendure Plugin
 # Writing a Vendure Plugin
 
 
-A Vendure plugin is a class which implements the [`VendurePlugin` interface]({{< relref "vendure-plugin" >}}). This interface allows the plugin to:
-
-1. Modify the [VendureConfig]({{< relref "vendure-config" >}}) object.
-2. Extend the GraphQL API, including modifying existing types and adding completely new queries and mutations.
-3. Define new database entities and interact directly with the database.
-4. Run code before the server bootstraps, such as starting webservers.
+This is a complete example of how to implement a simple plugin step-by-step.
 
 
 ## Example: RandomCatPlugin
 ## Example: RandomCatPlugin
 
 
-Let's learn about these capabilities by writing a plugin which defines a new database entity and GraphQL mutation.
+Let's learn about Vendure plugins by writing a plugin which defines a new database entity and GraphQL mutation.
 
 
-This plugin will add a new mutation, `addRandomCat`, to the GraphQL API which allows us to conveniently link a random cat image from [http://random.cat](http://random.cat) to any product in out catalog.
+This plugin will add a new mutation, `addRandomCat`, to the GraphQL API which allows us to conveniently link a random cat image from [http://random.cat](http://random.cat) to any product in our catalog.
 
 
 ### Step 1: Define a new custom field
 ### Step 1: Define a new custom field
 
 
-We need a place to store the url of the cat image, so we will add a custom field to the Product entity. This is done by modifying the VendureConfig object in the the plugin's [`configure` method]({{< relref "vendure-plugin" >}}#configure):
+We need a place to store the url of the cat image, so we will add a custom field to the Product entity. This is done by modifying the VendureConfig object via the plugin's [`configuration` metadata property]({{< relref "vendure-plugin-metadata" >}}#configuration):
 
 
-```ts 
+```TypeScript
 import { VendurePlugin } from '@vendure/core';
 import { VendurePlugin } from '@vendure/core';
 
 
-export class RandomCatPlugin implements VendurePlugin {
-    configure(config) {
-        config.customFields.Product.push({
-            type: 'string',
-            name: 'catImageUrl',
-        });
-        return config;
-    }
-}
+@VendurePlugin({
+  configuration: config => {
+    config.customFields.Product.push({
+      type: 'string',
+      name: 'catImageUrl',
+    });
+    return config;
+  }
+})
+export class RandomCatPlugin {}
 ```
 ```
 
 
 ### Step 2: Create a service to fetch the data
 ### Step 2: Create a service to fetch the data
 
 
 Now we will create a service which is responsible for making the HTTP call to the random.cat API and returning the URL of a random cat image:
 Now we will create a service which is responsible for making the HTTP call to the random.cat API and returning the URL of a random cat image:
 
 
-```ts 
+```TypeScript
 import http from 'http';
 import http from 'http';
 import { Injectable } from '@nestjs/common';
 import { Injectable } from '@nestjs/common';
 
 
 @Injectable()
 @Injectable()
 export class CatFetcher {
 export class CatFetcher {
-    /** Fetch a random cat image url from random.cat */
-    fetchCat(): Promise<string> {
-        return new Promise((resolve) => {
-            http.get('http://aws.random.cat/meow', (resp) => {
-                let data = '';
-                resp.on('data', chunk => data += chunk);
-                resp.on('end', () => resolve(JSON.parse(data).file));
-            });
-        });
-    }
+  /** Fetch a random cat image url from random.cat */
+  fetchCat(): Promise<string> {
+    return new Promise((resolve) => {
+      http.get('http://aws.random.cat/meow', (resp) => {
+        let data = '';
+        resp.on('data', chunk => data += chunk);
+        resp.on('end', () => resolve(JSON.parse(data).file));
+      });
+    });
+  }
 }
 }
 ```
 ```
 
 
@@ -72,30 +68,23 @@ To use decorators with TypeScript, you must set the "emitDecoratorMetadata" and
 
 
 Next we will define how the GraphQL API should be extended:
 Next we will define how the GraphQL API should be extended:
 
 
-```ts 
+```TypeScript
 import gql from 'graphql-tag';
 import gql from 'graphql-tag';
 
 
-export class RandomCatPlugin implements VendurePlugin {
-
-    private schemaExtension = gql`
-        extend type Mutation {
-            addRandomCat(id: ID!): Product!
-        }
-    `;
-
-    configure(config) {
-        // as above
-    }
-}
+const schemaExtension = gql`
+  extend type Mutation {
+    addRandomCat(id: ID!): Product!
+  }
+`;
 ```
 ```
 
 
-We will use this private `schemaExtension` variable in a later step.
+We will use this `schemaExtension` variable in a later step.
 
 
 ### Step 4: Create a resolver
 ### Step 4: Create a resolver
 
 
 Now that we've defined the new mutation, we'll need a resolver function to handle it. To do this, we'll create a new resolver class, following the [Nest GraphQL resolver architecture](https://docs.nestjs.com/graphql/resolvers-map). In short, this will be a class which has the `@Resolver()` decorator and features a method to handle our new mutation.
 Now that we've defined the new mutation, we'll need a resolver function to handle it. To do this, we'll create a new resolver class, following the [Nest GraphQL resolver architecture](https://docs.nestjs.com/graphql/resolvers-map). In short, this will be a class which has the `@Resolver()` decorator and features a method to handle our new mutation.
 
 
-```ts 
+```TypeScript
 import { Args, Mutation, Resolver } from '@nestjs/graphql';
 import { Args, Mutation, Resolver } from '@nestjs/graphql';
 import { Ctx, Allow, ProductService, RequestContext } from '@vendure/core';
 import { Ctx, Allow, ProductService, RequestContext } from '@vendure/core';
 import { Permission } from '@vendure/common/lib/generated-types';
 import { Permission } from '@vendure/common/lib/generated-types';
@@ -103,17 +92,17 @@ import { Permission } from '@vendure/common/lib/generated-types';
 @Resolver()
 @Resolver()
 export class RandomCatResolver {
 export class RandomCatResolver {
 
 
-    constructor(private productService: ProductService, private catFetcher: CatFetcher) {}
+  constructor(private productService: ProductService, private catFetcher: CatFetcher) {}
 
 
-    @Mutation()
-    @Allow(Permission.UpdateCatalog)
-    async addRandomCat(@Ctx() ctx: RequestContext, @Args() args) {
-        const catImageUrl = await this.catFetcher.fetchCat();
-        return this.productService.update(ctx, {
-            id: args.id,
-            customFields: { catImageUrl },
-        });
-    }
+  @Mutation()
+  @Allow(Permission.UpdateCatalog)
+  async addRandomCat(@Ctx() ctx: RequestContext, @Args() args) {
+    const catImageUrl = await this.catFetcher.fetchCat();
+    return this.productService.update(ctx, {
+      id: args.id,
+      customFields: { catImageUrl },
+    });
+  }
 }
 }
 ```
 ```
 
 
@@ -126,55 +115,70 @@ Some explanations of this code are in order:
 * The `@Ctx()` decorator injects the current `RequestContext` into the resolver. This provides information about the current request such as the current Session, User and Channel. It is required by most of the internal service methods.
 * The `@Ctx()` decorator injects the current `RequestContext` into the resolver. This provides information about the current request such as the current Session, User and Channel. It is required by most of the internal service methods.
 * The `@Args()` decorator injects the arguments passed to the mutation as an object.
 * The `@Args()` decorator injects the arguments passed to the mutation as an object.
 
 
-### Step 5: Export any providers used in the resolver
+### Step 5: Import the PluginCommonModule
 
 
-In order that out resolver is able to use Nest's dependency injection to inject and instance of `CatFetcher`, we must export it via the [`defineProviders` method](({{< relref "vendure-plugin" >}}#defineproviders)) in our plugin:
+In the RandomCatResolver we make use of the ProductService. This is one of the built-in services which Vendure uses internally to interact with the database. It can be injected into our resolver only if we first import the [PluginCommonModule]({{< relref "plugin-common-module" >}}), which is a module which exports a number of providers which are usually needed by plugins.
 
 
-```ts 
-export class RandomCatPlugin implements VendurePlugin {
+```TypeScript
+@VendurePlugin({
+  imports: [PluginCommonModule],
+  configuration: config => {
+    // omitted
+  }
+})
+export class RandomCatPlugin {}
+``` 
 
 
-    // ...
+### Step 6: Declare any providers used in the resolver
 
 
-    defineProviders() {
-        return [CatFetcher];
-    }
-}
-```
+In order that out resolver is able to use Nest's dependency injection to inject and instance of `CatFetcher`, we must add it to the `providers` array in our plugin:
 
 
-### Step 6: Extend the GraphQL API
+```TypeScript
+@VendurePlugin({
+  imports: [PluginCommonModule],
+  providers: [CatFetcher],
+  configuration: config => {
+    // omitted
+  }
+})
+export class RandomCatPlugin {}
+```
 
 
-Now that we've defined the new mutation and we have a resolver capable of handling it, we just need to tell Vendure to extend the API. This is done with the [`extendAdminAPI` method]({{< relref "vendure-plugin" >}}#extendadminapi). If we wanted to extend the Shop API, we'd use the [`extendShopAPI` method]({{< relref "vendure-plugin" >}}#extendshopapi) method instead.
+### Step 7: Extend the GraphQL API
 
 
-```ts 
-export class RandomCatPlugin implements VendurePlugin {
+Now that we've defined the new mutation and we have a resolver capable of handling it, we just need to tell Vendure to extend the API. This is done with the [`adminApiExtensions` metadata property]({{< relref "vendure-plugin-metadata" >}}#adminapiextensions). If we wanted to extend the Shop API, we'd use the [`adminApiExtensions` metadata property]({{< relref "vendure-plugin-metadata" >}}#shopapiextensions) instead.
 
 
-    // ...
-    
-    extendAdminAPI() {
-        return {
-            schema: this.schemaExtension,
-            resolvers: [RandomCatResolver],
-        };
-    }
-}
+```TypeScript
+@VendurePlugin({
+  imports: [PluginCommonModule],
+  providers: [CatFetcher],
+  adminApiExtensions: {
+    schema: schemaExtension,
+    resolvers: [RandomCatResolver],
+  },
+  configuration: config => {
+    // omitted
+  }
+})
+export class RandomCatPlugin {}
 ```
 ```
 
 
-### Step 7: Add the plugin to the Vendure config
+### Step 8: Add the plugin to the Vendure config
 
 
 Finally we need to add an instance of our plugin to the config object with which we bootstrap out Vendure server:
 Finally we need to add an instance of our plugin to the config object with which we bootstrap out Vendure server:
 
 
-```ts 
+```TypeScript
 import { bootstrap } from '@vendure/core';
 import { bootstrap } from '@vendure/core';
 
 
 bootstrap({
 bootstrap({
     // .. config options
     // .. config options
     plugins: [
     plugins: [
-        new RandomCatPlugin(),
+        RandomCatPlugin,
     ],
     ],
 });
 });
 ```
 ```
 
 
-### Step 8: Test the plugin
+### Step 9: Test the plugin
 
 
 Once we have started the Vendure server with the new config, we should be able to send the following GraphQL query to the Admin API:
 Once we have started the Vendure server with the new config, we should be able to send the following GraphQL query to the Admin API:
 
 
@@ -192,7 +196,7 @@ mutation {
 
 
 which should yield the following response:
 which should yield the following response:
 
 
-```JSON 
+```JSON
 {
 {
   "data": {
   "data": {
     "addRandomCat": {
     "addRandomCat": {
@@ -208,7 +212,7 @@ which should yield the following response:
 
 
 ### Full example plugin
 ### Full example plugin
 
 
-```ts
+```TypeScript
 import { Injectable } from '@nestjs/common';
 import { Injectable } from '@nestjs/common';
 import { Args, Mutation, Resolver } from '@nestjs/graphql';
 import { Args, Mutation, Resolver } from '@nestjs/graphql';
 import gql from 'graphql-tag';
 import gql from 'graphql-tag';
@@ -216,60 +220,55 @@ import http from 'http';
 import { Allow, Ctx, ProductService, RequestContext, VendureConfig, VendurePlugin } from '@vendure/core';
 import { Allow, Ctx, ProductService, RequestContext, VendureConfig, VendurePlugin } from '@vendure/core';
 import { Permission } from '@vendure/common/lib/generated-types';
 import { Permission } from '@vendure/common/lib/generated-types';
 
 
-export class RandomCatPlugin implements VendurePlugin {
-
-    private schemaExtension = gql`
-        extend type Mutation {
-            addRandomCat(id: ID!): Product!
-        }
-    `;
-
-    configure(config: Required<VendureConfig>) {
-        config.customFields.Product.push({
-            type: 'string',
-            name: 'catImageUrl',
-        });
-        return config;
-    }
-
-    defineProviders() {
-        return [CatFetcher];
+const schemaExtension = gql`
+    extend type Mutation {
+        addRandomCat(id: ID!): Product!
     }
     }
-
-    extendAdminAPI() {
-        return {
-            schema: this.schemaExtension,
-            resolvers: [RandomCatResolver],
-        };
-    }
-}
+`;
+
+@VendurePlugin({
+  imports: [PluginCommonModule],
+  providers: [CatFetcher],
+  adminApiExtensions: {
+    schema: schemaExtension,
+    resolvers: [RandomCatResolver],
+  },
+  configuration: config => {
+    config.customFields.Product.push({
+      type: 'string',
+      name: 'catImageUrl',
+    });
+    return config;
+  }
+})
+export class RandomCatPlugin {}
 
 
 @Injectable()
 @Injectable()
 export class CatFetcher {
 export class CatFetcher {
-    /** Fetch a random cat image url from random.cat */
-    fetchCat(): Promise<string> {
-        return new Promise((resolve) => {
-            http.get('http://aws.random.cat/meow', (resp) => {
-                let data = '';
-                resp.on('data', chunk => data += chunk);
-                resp.on('end', () => resolve(JSON.parse(data).file));
-            });
-        });
-    }
+  /** Fetch a random cat image url from random.cat */
+  fetchCat(): Promise<string> {
+    return new Promise((resolve) => {
+      http.get('http://aws.random.cat/meow', (resp) => {
+        let data = '';
+        resp.on('data', chunk => data += chunk);
+        resp.on('end', () => resolve(JSON.parse(data).file));
+      });
+    });
+  }
 }
 }
 
 
 @Resolver()
 @Resolver()
 export class RandomCatResolver {
 export class RandomCatResolver {
-    constructor(private productService: ProductService, private catFetcher: CatFetcher) {}
-
-    @Mutation()
-    @Allow(Permission.UpdateCatalog)
-    async addRandomCat(@Ctx() ctx: RequestContext, @Args() args) {
-        const catImageUrl = await this.catFetcher.fetchCat();
-        return this.productService.update(ctx, {
-            id: args.id,
-            customFields: { catImageUrl },
-        });
-    }
+  constructor(private productService: ProductService, private catFetcher: CatFetcher) {}
+
+  @Mutation()
+  @Allow(Permission.UpdateCatalog)
+  async addRandomCat(@Ctx() ctx: RequestContext, @Args() args) {
+    const catImageUrl = await this.catFetcher.fetchCat();
+    return this.productService.update(ctx, {
+      id: args.id,
+      customFields: { catImageUrl },
+    });
+  }
 }
 }
 ```
 ```

+ 1 - 0
packages/admin-ui-plugin/package.json

@@ -9,6 +9,7 @@
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "build": "rimraf lib && node build.js && yarn compile",
     "build": "rimraf lib && node build.js && yarn compile",
+    "watch": "tsc -p ./tsconfig.build.json --watch",
     "compile": "tsc -p ./tsconfig.build.json"
     "compile": "tsc -p ./tsconfig.build.json"
   },
   },
   "publishConfig": {
   "publishConfig": {

+ 34 - 12
packages/admin-ui-plugin/src/plugin.ts

@@ -1,5 +1,11 @@
-import { AdminUiConfig } from '@vendure/common/lib/shared-types';
-import { createProxyHandler, InjectorFn, VendureConfig, VendurePlugin } from '@vendure/core';
+import { AdminUiConfig, Type } from '@vendure/common/lib/shared-types';
+import {
+    createProxyHandler,
+    OnVendureBootstrap,
+    OnVendureClose,
+    VendureConfig,
+    VendurePlugin,
+} from '@vendure/core';
 import express from 'express';
 import express from 'express';
 import fs from 'fs-extra';
 import fs from 'fs-extra';
 import { Server } from 'http';
 import { Server } from 'http';
@@ -66,19 +72,31 @@ export interface AdminUiOptions {
  * const config: VendureConfig = {
  * const config: VendureConfig = {
  *   // Add an instance of the plugin to the plugins array
  *   // Add an instance of the plugin to the plugins array
  *   plugins: [
  *   plugins: [
- *     new AdminUiPlugin({ port: 3002 }),
+ *     AdminUiPlugin.init({ port: 3002 }),
  *   ],
  *   ],
  * };
  * };
  * ```
  * ```
  *
  *
  * @docsCategory AdminUiPlugin
  * @docsCategory AdminUiPlugin
  */
  */
-export class AdminUiPlugin implements VendurePlugin {
+@VendurePlugin({
+    configuration: (config: Required<VendureConfig>) => AdminUiPlugin.configure(config),
+})
+export class AdminUiPlugin implements OnVendureBootstrap, OnVendureClose {
+    private static options: AdminUiOptions;
     private server: Server;
     private server: Server;
-    constructor(private options: AdminUiOptions) {}
+
+    /**
+     * @description
+     * Set the plugin options
+     */
+    static init(options: AdminUiOptions): Type<AdminUiPlugin> {
+        this.options = options;
+        return AdminUiPlugin;
+    }
 
 
     /** @internal */
     /** @internal */
-    async configure(config: Required<VendureConfig>): Promise<Required<VendureConfig>> {
+    static async configure(config: Required<VendureConfig>): Promise<Required<VendureConfig>> {
         const route = 'admin';
         const route = 'admin';
         config.middleware.push({
         config.middleware.push({
             handler: createProxyHandler({ ...this.options, route, label: 'Admin UI' }),
             handler: createProxyHandler({ ...this.options, route, label: 'Admin UI' }),
@@ -91,18 +109,18 @@ export class AdminUiPlugin implements VendurePlugin {
     }
     }
 
 
     /** @internal */
     /** @internal */
-    onBootstrap(inject: InjectorFn): void | Promise<void> {
-        const adminUiPath = this.getAdminUiPath();
+    onVendureBootstrap() {
+        const adminUiPath = AdminUiPlugin.getAdminUiPath();
         const assetServer = express();
         const assetServer = express();
         assetServer.use(express.static(adminUiPath));
         assetServer.use(express.static(adminUiPath));
         assetServer.use((req, res) => {
         assetServer.use((req, res) => {
             res.sendFile(path.join(adminUiPath, 'index.html'));
             res.sendFile(path.join(adminUiPath, 'index.html'));
         });
         });
-        this.server = assetServer.listen(this.options.port);
+        this.server = assetServer.listen(AdminUiPlugin.options.port);
     }
     }
 
 
     /** @internal */
     /** @internal */
-    onClose(): Promise<void> {
+    onVendureClose(): Promise<void> {
         return new Promise(resolve => this.server.close(() => resolve()));
         return new Promise(resolve => this.server.close(() => resolve()));
     }
     }
 
 
@@ -110,7 +128,11 @@ export class AdminUiPlugin implements VendurePlugin {
      * Overwrites the parts of the admin-ui app's `vendure-ui-config.json` file relating to connecting to
      * Overwrites the parts of the admin-ui app's `vendure-ui-config.json` file relating to connecting to
      * the server admin API.
      * the server admin API.
      */
      */
-    private async overwriteAdminUiConfig(host: string | 'auto', port: number | 'auto', adminApiPath: string) {
+    private static async overwriteAdminUiConfig(
+        host: string | 'auto',
+        port: number | 'auto',
+        adminApiPath: string,
+    ) {
         const adminUiConfigPath = path.join(this.getAdminUiPath(), 'vendure-ui-config.json');
         const adminUiConfigPath = path.join(this.getAdminUiPath(), 'vendure-ui-config.json');
         const adminUiConfig = await fs.readFile(adminUiConfigPath, 'utf-8');
         const adminUiConfig = await fs.readFile(adminUiConfigPath, 'utf-8');
         const config: AdminUiConfig = JSON.parse(adminUiConfig);
         const config: AdminUiConfig = JSON.parse(adminUiConfig);
@@ -120,7 +142,7 @@ export class AdminUiPlugin implements VendurePlugin {
         await fs.writeFile(adminUiConfigPath, JSON.stringify(config, null, 2));
         await fs.writeFile(adminUiConfigPath, JSON.stringify(config, null, 2));
     }
     }
 
 
-    private getAdminUiPath(): string {
+    private static getAdminUiPath(): string {
         // attempt to read from the path location on a production npm install
         // attempt to read from the path location on a production npm install
         const prodPath = path.join(__dirname, '../admin-ui');
         const prodPath = path.join(__dirname, '../admin-ui');
         if (fs.existsSync(path.join(prodPath, 'index.html'))) {
         if (fs.existsSync(path.join(prodPath, 'index.html'))) {

+ 1 - 0
packages/asset-server-plugin/package.json

@@ -8,6 +8,7 @@
   ],
   ],
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
+    "watch": "tsc -p ./tsconfig.build.json --watch",
     "build": "rimraf lib && tsc -p ./tsconfig.build.json"
     "build": "rimraf lib && tsc -p ./tsconfig.build.json"
   },
   },
   "publishConfig": {
   "publishConfig": {

+ 50 - 27
packages/asset-server-plugin/src/plugin.ts

@@ -1,4 +1,13 @@
-import { AssetStorageStrategy, createProxyHandler, InjectorFn, LocalAssetStorageStrategy, VendureConfig, VendurePlugin } from '@vendure/core';
+import { Type } from '@vendure/common/lib/shared-types';
+import {
+    AssetStorageStrategy,
+    createProxyHandler,
+    LocalAssetStorageStrategy,
+    OnVendureBootstrap,
+    OnVendureClose,
+    VendureConfig,
+    VendurePlugin,
+} from '@vendure/core';
 import express, { NextFunction, Request, Response } from 'express';
 import express, { NextFunction, Request, Response } from 'express';
 import { Server } from 'http';
 import { Server } from 'http';
 import path from 'path';
 import path from 'path';
@@ -117,7 +126,7 @@ export interface AssetServerOptions {
  * const config: VendureConfig = {
  * const config: VendureConfig = {
  *   // Add an instance of the plugin to the plugins array
  *   // Add an instance of the plugin to the plugins array
  *   plugins: [
  *   plugins: [
- *     new AssetServerPlugin({
+ *     AssetServerPlugin.init({
  *       route: 'assets',
  *       route: 'assets',
  *       assetUploadDir: path.join(__dirname, 'assets'),
  *       assetUploadDir: path.join(__dirname, 'assets'),
  *       port: 4000,
  *       port: 4000,
@@ -176,34 +185,34 @@ export interface AssetServerOptions {
  *
  *
  * @docsCategory AssetServerPlugin
  * @docsCategory AssetServerPlugin
  */
  */
-export class AssetServerPlugin implements VendurePlugin {
+@VendurePlugin({
+    configuration: (config: Required<VendureConfig>) => AssetServerPlugin.configure(config),
+})
+export class AssetServerPlugin implements OnVendureBootstrap, OnVendureClose {
     private server: Server;
     private server: Server;
-    private assetStorage: AssetStorageStrategy;
+    private static assetStorage: AssetStorageStrategy;
     private readonly cacheDir = 'cache';
     private readonly cacheDir = 'cache';
-    private readonly presets: ImageTransformPreset[] = [
+    private presets: ImageTransformPreset[] = [
         { name: 'tiny', width: 50, height: 50, mode: 'crop' },
         { name: 'tiny', width: 50, height: 50, mode: 'crop' },
         { name: 'thumb', width: 150, height: 150, mode: 'crop' },
         { name: 'thumb', width: 150, height: 150, mode: 'crop' },
         { name: 'small', width: 300, height: 300, mode: 'resize' },
         { name: 'small', width: 300, height: 300, mode: 'resize' },
         { name: 'medium', width: 500, height: 500, mode: 'resize' },
         { name: 'medium', width: 500, height: 500, mode: 'resize' },
         { name: 'large', width: 800, height: 800, mode: 'resize' },
         { name: 'large', width: 800, height: 800, mode: 'resize' },
     ];
     ];
+    private static options: AssetServerOptions;
 
 
-    constructor(private options: AssetServerOptions) {
-        if (options.presets) {
-            for (const preset of options.presets) {
-                const existingIndex = this.presets.findIndex(p => p.name === preset.name);
-                if (-1 < existingIndex) {
-                    this.presets.splice(existingIndex, 1, preset);
-                } else {
-                    this.presets.push(preset);
-                }
-            }
-        }
+    /**
+     * @description
+     * Set the plugin options.
+     */
+    static init(options: AssetServerOptions): Type<AssetServerPlugin> {
+        AssetServerPlugin.options = options;
+        return this;
     }
     }
 
 
     /** @internal */
     /** @internal */
-    configure(config: Required<VendureConfig>) {
-        this.assetStorage = this.createAssetStorageStrategy();
+    static configure(config: Required<VendureConfig>) {
+        this.assetStorage = this.createAssetStorageStrategy(this.options);
         config.assetOptions.assetPreviewStrategy = new SharpAssetPreviewStrategy({
         config.assetOptions.assetPreviewStrategy = new SharpAssetPreviewStrategy({
             maxWidth: this.options.previewMaxWidth || 1600,
             maxWidth: this.options.previewMaxWidth || 1600,
             maxHeight: this.options.previewMaxHeight || 1600,
             maxHeight: this.options.previewMaxHeight || 1600,
@@ -217,26 +226,37 @@ export class AssetServerPlugin implements VendurePlugin {
     }
     }
 
 
     /** @internal */
     /** @internal */
-    onBootstrap(inject: InjectorFn): void | Promise<void> {
+    onVendureBootstrap(): void | Promise<void> {
+        if (AssetServerPlugin.options.presets) {
+            for (const preset of AssetServerPlugin.options.presets) {
+                const existingIndex = this.presets.findIndex(p => p.name === preset.name);
+                if (-1 < existingIndex) {
+                    this.presets.splice(existingIndex, 1, preset);
+                } else {
+                    this.presets.push(preset);
+                }
+            }
+        }
         this.createAssetServer();
         this.createAssetServer();
     }
     }
 
 
     /** @internal */
     /** @internal */
-    onClose(): Promise<void> {
+    onVendureClose(): Promise<void> {
         return new Promise(resolve => {
         return new Promise(resolve => {
             this.server.close(() => resolve());
             this.server.close(() => resolve());
         });
         });
     }
     }
 
 
-    private createAssetStorageStrategy() {
+    private static createAssetStorageStrategy(options: AssetServerOptions) {
+        const { assetUrlPrefix, assetUploadDir, route } = options;
         const toAbsoluteUrlFn = (request: Request, identifier: string): string => {
         const toAbsoluteUrlFn = (request: Request, identifier: string): string => {
             if (!identifier) {
             if (!identifier) {
                 return '';
                 return '';
             }
             }
-            const prefix = this.options.assetUrlPrefix || `${request.protocol}://${request.get('host')}/${this.options.route}/`;
+            const prefix = assetUrlPrefix || `${request.protocol}://${request.get('host')}/${route}/`;
             return identifier.startsWith(prefix) ? identifier : `${prefix}${identifier}`;
             return identifier.startsWith(prefix) ? identifier : `${prefix}${identifier}`;
         };
         };
-        return new LocalAssetStorageStrategy(this.options.assetUploadDir, toAbsoluteUrlFn);
+        return new LocalAssetStorageStrategy(assetUploadDir, toAbsoluteUrlFn);
     }
     }
 
 
     /**
     /**
@@ -245,7 +265,7 @@ export class AssetServerPlugin implements VendurePlugin {
     private createAssetServer() {
     private createAssetServer() {
         const assetServer = express();
         const assetServer = express();
         assetServer.use(this.serveStaticFile(), this.generateTransformedImage());
         assetServer.use(this.serveStaticFile(), this.generateTransformedImage());
-        this.server = assetServer.listen(this.options.port);
+        this.server = assetServer.listen(AssetServerPlugin.options.port);
     }
     }
 
 
     /**
     /**
@@ -253,7 +273,10 @@ export class AssetServerPlugin implements VendurePlugin {
      */
      */
     private serveStaticFile() {
     private serveStaticFile() {
         return (req: Request, res: Response) => {
         return (req: Request, res: Response) => {
-            const filePath = path.join(this.options.assetUploadDir, this.getFileNameFromRequest(req));
+            const filePath = path.join(
+                AssetServerPlugin.options.assetUploadDir,
+                this.getFileNameFromRequest(req),
+            );
             res.sendFile(filePath);
             res.sendFile(filePath);
         };
         };
     }
     }
@@ -269,7 +292,7 @@ export class AssetServerPlugin implements VendurePlugin {
                 if (req.query) {
                 if (req.query) {
                     let file: Buffer;
                     let file: Buffer;
                     try {
                     try {
-                        file = await this.assetStorage.readFileToBuffer(req.path);
+                        file = await AssetServerPlugin.assetStorage.readFileToBuffer(req.path);
                     } catch (err) {
                     } catch (err) {
                         res.status(404).send('Resource not found');
                         res.status(404).send('Resource not found');
                         return;
                         return;
@@ -277,7 +300,7 @@ export class AssetServerPlugin implements VendurePlugin {
                     const image = await transformImage(file, req.query, this.presets || []);
                     const image = await transformImage(file, req.query, this.presets || []);
                     const imageBuffer = await image.toBuffer();
                     const imageBuffer = await image.toBuffer();
                     const cachedFileName = this.getFileNameFromRequest(req);
                     const cachedFileName = this.getFileNameFromRequest(req);
-                    await this.assetStorage.writeFileFromBuffer(cachedFileName, imageBuffer);
+                    await AssetServerPlugin.assetStorage.writeFileFromBuffer(cachedFileName, imageBuffer);
                     res.set('Content-Type', `image/${(await image.metadata()).format}`);
                     res.set('Content-Type', `image/${(await image.metadata()).format}`);
                     res.send(imageBuffer);
                     res.send(imageBuffer);
                 }
                 }

+ 180 - 122
packages/core/e2e/default-search-plugin.e2e-spec.ts

@@ -47,7 +47,7 @@ describe('Default search plugin', () => {
                 customerCount: 1,
                 customerCount: 1,
             },
             },
             {
             {
-                plugins: [new DefaultSearchPlugin()],
+                plugins: [DefaultSearchPlugin],
             },
             },
         );
         );
         await adminClient.init();
         await adminClient.init();
@@ -59,30 +59,39 @@ describe('Default search plugin', () => {
     });
     });
 
 
     async function testGroupByProduct(client: SimpleGraphQLClient) {
     async function testGroupByProduct(client: SimpleGraphQLClient) {
-        const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(SEARCH_PRODUCTS_SHOP, {
-            input: {
-                groupByProduct: true,
+        const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+            SEARCH_PRODUCTS_SHOP,
+            {
+                input: {
+                    groupByProduct: true,
+                },
             },
             },
-        });
+        );
         expect(result.search.totalItems).toBe(20);
         expect(result.search.totalItems).toBe(20);
     }
     }
 
 
     async function testNoGrouping(client: SimpleGraphQLClient) {
     async function testNoGrouping(client: SimpleGraphQLClient) {
-        const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(SEARCH_PRODUCTS_SHOP, {
-            input: {
-                groupByProduct: false,
+        const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+            SEARCH_PRODUCTS_SHOP,
+            {
+                input: {
+                    groupByProduct: false,
+                },
             },
             },
-        });
+        );
         expect(result.search.totalItems).toBe(34);
         expect(result.search.totalItems).toBe(34);
     }
     }
 
 
     async function testMatchSearchTerm(client: SimpleGraphQLClient) {
     async function testMatchSearchTerm(client: SimpleGraphQLClient) {
-        const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(SEARCH_PRODUCTS_SHOP, {
-            input: {
-                term: 'camera',
-                groupByProduct: true,
+        const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+            SEARCH_PRODUCTS_SHOP,
+            {
+                input: {
+                    term: 'camera',
+                    groupByProduct: true,
+                },
             },
             },
-        });
+        );
         expect(result.search.items.map(i => i.productName)).toEqual([
         expect(result.search.items.map(i => i.productName)).toEqual([
             'Instant Camera',
             'Instant Camera',
             'Camera Lens',
             'Camera Lens',
@@ -91,12 +100,15 @@ describe('Default search plugin', () => {
     }
     }
 
 
     async function testMatchFacetIds(client: SimpleGraphQLClient) {
     async function testMatchFacetIds(client: SimpleGraphQLClient) {
-        const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(SEARCH_PRODUCTS_SHOP, {
-            input: {
-                facetValueIds: ['T_1', 'T_2'],
-                groupByProduct: true,
+        const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+            SEARCH_PRODUCTS_SHOP,
+            {
+                input: {
+                    facetValueIds: ['T_1', 'T_2'],
+                    groupByProduct: true,
+                },
             },
             },
-        });
+        );
         expect(result.search.items.map(i => i.productName)).toEqual([
         expect(result.search.items.map(i => i.productName)).toEqual([
             'Laptop',
             'Laptop',
             'Curvy Monitor',
             'Curvy Monitor',
@@ -108,12 +120,15 @@ describe('Default search plugin', () => {
     }
     }
 
 
     async function testMatchCollectionId(client: SimpleGraphQLClient) {
     async function testMatchCollectionId(client: SimpleGraphQLClient) {
-        const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(SEARCH_PRODUCTS_SHOP, {
-            input: {
-                collectionId: 'T_2',
-                groupByProduct: true,
+        const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+            SEARCH_PRODUCTS_SHOP,
+            {
+                input: {
+                    collectionId: 'T_2',
+                    groupByProduct: true,
+                },
             },
             },
-        });
+        );
         expect(result.search.items.map(i => i.productName)).toEqual([
         expect(result.search.items.map(i => i.productName)).toEqual([
             'Spiky Cactus',
             'Spiky Cactus',
             'Orchid',
             'Orchid',
@@ -122,12 +137,15 @@ describe('Default search plugin', () => {
     }
     }
 
 
     async function testSinglePrices(client: SimpleGraphQLClient) {
     async function testSinglePrices(client: SimpleGraphQLClient) {
-        const result = await client.query<SearchGetPrices.Query, SearchGetPrices.Variables>(SEARCH_GET_PRICES, {
-            input: {
-                groupByProduct: false,
-                take: 3,
-            } as SearchInput,
-        });
+        const result = await client.query<SearchGetPrices.Query, SearchGetPrices.Variables>(
+            SEARCH_GET_PRICES,
+            {
+                input: {
+                    groupByProduct: false,
+                    take: 3,
+                } as SearchInput,
+            },
+        );
         expect(result.search.items).toEqual([
         expect(result.search.items).toEqual([
             {
             {
                 price: { value: 129900 },
                 price: { value: 129900 },
@@ -145,12 +163,15 @@ describe('Default search plugin', () => {
     }
     }
 
 
     async function testPriceRanges(client: SimpleGraphQLClient) {
     async function testPriceRanges(client: SimpleGraphQLClient) {
-        const result = await client.query<SearchGetPrices.Query, SearchGetPrices.Variables>(SEARCH_GET_PRICES, {
-            input: {
-                groupByProduct: true,
-                take: 3,
-            } as SearchInput,
-        });
+        const result = await client.query<SearchGetPrices.Query, SearchGetPrices.Variables>(
+            SEARCH_GET_PRICES,
+            {
+                input: {
+                    groupByProduct: true,
+                    take: 3,
+                } as SearchInput,
+            },
+        );
         expect(result.search.items).toEqual([
         expect(result.search.items).toEqual([
             {
             {
                 price: { min: 129900, max: 229900 },
                 price: { min: 129900, max: 229900 },
@@ -183,11 +204,14 @@ describe('Default search plugin', () => {
         it('price ranges', () => testPriceRanges(shopClient));
         it('price ranges', () => testPriceRanges(shopClient));
 
 
         it('returns correct facetValues when not grouped by product', async () => {
         it('returns correct facetValues when not grouped by product', async () => {
-            const result = await shopClient.query<SearchFacetValues.Query, SearchFacetValues.Variables>(SEARCH_GET_FACET_VALUES, {
-                input: {
-                    groupByProduct: false,
+            const result = await shopClient.query<SearchFacetValues.Query, SearchFacetValues.Variables>(
+                SEARCH_GET_FACET_VALUES,
+                {
+                    input: {
+                        groupByProduct: false,
+                    },
                 },
                 },
-            });
+            );
             expect(result.search.facetValues).toEqual([
             expect(result.search.facetValues).toEqual([
                 { count: 21, facetValue: { id: 'T_1', name: 'electronics' } },
                 { count: 21, facetValue: { id: 'T_1', name: 'electronics' } },
                 { count: 17, facetValue: { id: 'T_2', name: 'computers' } },
                 { count: 17, facetValue: { id: 'T_2', name: 'computers' } },
@@ -199,11 +223,14 @@ describe('Default search plugin', () => {
         });
         });
 
 
         it('returns correct facetValues when grouped by product', async () => {
         it('returns correct facetValues when grouped by product', async () => {
-            const result = await shopClient.query<SearchFacetValues.Query, SearchFacetValues.Variables>(SEARCH_GET_FACET_VALUES, {
-                input: {
-                    groupByProduct: true,
+            const result = await shopClient.query<SearchFacetValues.Query, SearchFacetValues.Variables>(
+                SEARCH_GET_FACET_VALUES,
+                {
+                    input: {
+                        groupByProduct: true,
+                    },
                 },
                 },
-            });
+            );
             expect(result.search.facetValues).toEqual([
             expect(result.search.facetValues).toEqual([
                 { count: 10, facetValue: { id: 'T_1', name: 'electronics' } },
                 { count: 10, facetValue: { id: 'T_1', name: 'electronics' } },
                 { count: 6, facetValue: { id: 'T_2', name: 'computers' } },
                 { count: 6, facetValue: { id: 'T_2', name: 'computers' } },
@@ -215,18 +242,22 @@ describe('Default search plugin', () => {
         });
         });
 
 
         it('omits facetValues of private facets', async () => {
         it('omits facetValues of private facets', async () => {
-            const { createFacet } = await adminClient.query<CreateFacet.Mutation, CreateFacet.Variables>(CREATE_FACET, {
-                input: {
-                    code: 'profit-margin',
-                    isPrivate: true,
-                    translations: [
-                        { languageCode: LanguageCode.en, name: 'Profit Margin' },
-                    ],
-                    values: [
-                        { code: 'massive', translations: [{ languageCode: LanguageCode.en, name: 'massive' }] },
-                    ],
+            const { createFacet } = await adminClient.query<CreateFacet.Mutation, CreateFacet.Variables>(
+                CREATE_FACET,
+                {
+                    input: {
+                        code: 'profit-margin',
+                        isPrivate: true,
+                        translations: [{ languageCode: LanguageCode.en, name: 'Profit Margin' }],
+                        values: [
+                            {
+                                code: 'massive',
+                                translations: [{ languageCode: LanguageCode.en, name: 'massive' }],
+                            },
+                        ],
+                    },
                 },
                 },
-            });
+            );
             await adminClient.query<UpdateProduct.Mutation, UpdateProduct.Variables>(UPDATE_PRODUCT, {
             await adminClient.query<UpdateProduct.Mutation, UpdateProduct.Variables>(UPDATE_PRODUCT, {
                 input: {
                 input: {
                     id: 'T_2',
                     id: 'T_2',
@@ -235,11 +266,14 @@ describe('Default search plugin', () => {
                 },
                 },
             });
             });
 
 
-            const result = await shopClient.query<SearchFacetValues.Query, SearchFacetValues.Variables>(SEARCH_GET_FACET_VALUES, {
-                input: {
-                    groupByProduct: true,
+            const result = await shopClient.query<SearchFacetValues.Query, SearchFacetValues.Variables>(
+                SEARCH_GET_FACET_VALUES,
+                {
+                    input: {
+                        groupByProduct: true,
+                    },
                 },
                 },
-            });
+            );
             expect(result.search.facetValues).toEqual([
             expect(result.search.facetValues).toEqual([
                 { count: 10, facetValue: { id: 'T_1', name: 'electronics' } },
                 { count: 10, facetValue: { id: 'T_1', name: 'electronics' } },
                 { count: 6, facetValue: { id: 'T_2', name: 'computers' } },
                 { count: 6, facetValue: { id: 'T_2', name: 'computers' } },
@@ -251,44 +285,52 @@ describe('Default search plugin', () => {
         });
         });
 
 
         it('encodes the productId and productVariantId', async () => {
         it('encodes the productId and productVariantId', async () => {
-            const result = await shopClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(SEARCH_PRODUCTS_SHOP, {
-                input: {
-                    groupByProduct: false,
-                    take: 1,
-                },
-            });
-            expect(pick(result.search.items[0], ['productId', 'productVariantId'])).toEqual(
+            const result = await shopClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+                SEARCH_PRODUCTS_SHOP,
                 {
                 {
-                    productId: 'T_1',
-                    productVariantId: 'T_1',
+                    input: {
+                        groupByProduct: false,
+                        take: 1,
+                    },
                 },
                 },
             );
             );
+            expect(pick(result.search.items[0], ['productId', 'productVariantId'])).toEqual({
+                productId: 'T_1',
+                productVariantId: 'T_1',
+            });
         });
         });
 
 
         it('omits results for disabled ProductVariants', async () => {
         it('omits results for disabled ProductVariants', async () => {
-            await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(UPDATE_PRODUCT_VARIANTS, {
-                input: [
-                    { id: 'T_3', enabled: false },
-                ],
-            });
+            await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
+                UPDATE_PRODUCT_VARIANTS,
+                {
+                    input: [{ id: 'T_3', enabled: false }],
+                },
+            );
             await awaitRunningJobs();
             await awaitRunningJobs();
-            const result = await shopClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(SEARCH_PRODUCTS_SHOP, {
-                input: {
-                    groupByProduct: false,
-                    take: 3,
+            const result = await shopClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+                SEARCH_PRODUCTS_SHOP,
+                {
+                    input: {
+                        groupByProduct: false,
+                        take: 3,
+                    },
                 },
                 },
-            });
+            );
             expect(result.search.items.map(i => i.productVariantId)).toEqual(['T_1', 'T_2', 'T_4']);
             expect(result.search.items.map(i => i.productVariantId)).toEqual(['T_1', 'T_2', 'T_4']);
         });
         });
 
 
         it('encodes collectionIds', async () => {
         it('encodes collectionIds', async () => {
-            const result = await shopClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(SEARCH_PRODUCTS_SHOP, {
-                input: {
-                    groupByProduct: false,
-                    term: 'cactus',
-                    take: 1,
+            const result = await shopClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+                SEARCH_PRODUCTS_SHOP,
+                {
+                    input: {
+                        groupByProduct: false,
+                        term: 'cactus',
+                        take: 1,
+                    },
                 },
                 },
-            });
+            );
 
 
             expect(result.search.items[0].collectionIds).toEqual(['T_2']);
             expect(result.search.items[0].collectionIds).toEqual(['T_2']);
         });
         });
@@ -386,7 +428,7 @@ describe('Default search plugin', () => {
             const { createCollection } = await adminClient.query<
             const { createCollection } = await adminClient.query<
                 CreateCollection.Mutation,
                 CreateCollection.Mutation,
                 CreateCollection.Variables
                 CreateCollection.Variables
-                >(CREATE_COLLECTION, {
+            >(CREATE_COLLECTION, {
                 input: {
                 input: {
                     translations: [
                     translations: [
                         {
                         {
@@ -442,12 +484,15 @@ describe('Default search plugin', () => {
                 },
                 },
             });
             });
             await awaitRunningJobs();
             await awaitRunningJobs();
-            const result = await adminClient.query<SearchGetPrices.Query, SearchGetPrices.Variables>(SEARCH_GET_PRICES, {
-                input: {
-                    groupByProduct: true,
-                    term: 'laptop',
-                } as SearchInput,
-            });
+            const result = await adminClient.query<SearchGetPrices.Query, SearchGetPrices.Variables>(
+                SEARCH_GET_PRICES,
+                {
+                    input: {
+                        groupByProduct: true,
+                        term: 'laptop',
+                    } as SearchInput,
+                },
+            );
             expect(result.search.items).toEqual([
             expect(result.search.items).toEqual([
                 {
                 {
                     price: { min: 129900, max: 229900 },
                     price: { min: 129900, max: 229900 },
@@ -457,12 +502,15 @@ describe('Default search plugin', () => {
         });
         });
 
 
         it('returns disabled field when not grouped', async () => {
         it('returns disabled field when not grouped', async () => {
-            const result = await adminClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(SEARCH_PRODUCTS, {
-                input: {
-                    groupByProduct: false,
-                    take: 3,
+            const result = await adminClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+                SEARCH_PRODUCTS,
+                {
+                    input: {
+                        groupByProduct: false,
+                        take: 3,
+                    },
                 },
                 },
-            });
+            );
             expect(result.search.items.map(pick(['productVariantId', 'enabled']))).toEqual([
             expect(result.search.items.map(pick(['productVariantId', 'enabled']))).toEqual([
                 { productVariantId: 'T_1', enabled: true },
                 { productVariantId: 'T_1', enabled: true },
                 { productVariantId: 'T_2', enabled: true },
                 { productVariantId: 'T_2', enabled: true },
@@ -471,19 +519,22 @@ describe('Default search plugin', () => {
         });
         });
 
 
         it('when grouped, disabled is false if at least one variant is enabled', async () => {
         it('when grouped, disabled is false if at least one variant is enabled', async () => {
-            await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(UPDATE_PRODUCT_VARIANTS, {
-                input: [
-                    { id: 'T_1', enabled: false },
-                    { id: 'T_2', enabled: false },
-                ],
-            });
+            await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
+                UPDATE_PRODUCT_VARIANTS,
+                {
+                    input: [{ id: 'T_1', enabled: false }, { id: 'T_2', enabled: false }],
+                },
+            );
             await awaitRunningJobs();
             await awaitRunningJobs();
-            const result = await adminClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(SEARCH_PRODUCTS, {
-                input: {
-                    groupByProduct: true,
-                    take: 3,
+            const result = await adminClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+                SEARCH_PRODUCTS,
+                {
+                    input: {
+                        groupByProduct: true,
+                        take: 3,
+                    },
                 },
                 },
-            });
+            );
             expect(result.search.items.map(pick(['productId', 'enabled']))).toEqual([
             expect(result.search.items.map(pick(['productId', 'enabled']))).toEqual([
                 { productId: 'T_1', enabled: true },
                 { productId: 'T_1', enabled: true },
                 { productId: 'T_2', enabled: true },
                 { productId: 'T_2', enabled: true },
@@ -492,18 +543,22 @@ describe('Default search plugin', () => {
         });
         });
 
 
         it('when grouped, disabled is true if all variants are disabled', async () => {
         it('when grouped, disabled is true if all variants are disabled', async () => {
-            await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(UPDATE_PRODUCT_VARIANTS, {
-                input: [
-                    { id: 'T_4', enabled: false },
-                ],
-            });
+            await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
+                UPDATE_PRODUCT_VARIANTS,
+                {
+                    input: [{ id: 'T_4', enabled: false }],
+                },
+            );
             await awaitRunningJobs();
             await awaitRunningJobs();
-            const result = await adminClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(SEARCH_PRODUCTS, {
-                input: {
-                    groupByProduct: true,
-                    take: 3,
+            const result = await adminClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+                SEARCH_PRODUCTS,
+                {
+                    input: {
+                        groupByProduct: true,
+                        take: 3,
+                    },
                 },
                 },
-            });
+            );
             expect(result.search.items.map(pick(['productId', 'enabled']))).toEqual([
             expect(result.search.items.map(pick(['productId', 'enabled']))).toEqual([
                 { productId: 'T_1', enabled: false },
                 { productId: 'T_1', enabled: false },
                 { productId: 'T_2', enabled: true },
                 { productId: 'T_2', enabled: true },
@@ -519,12 +574,15 @@ describe('Default search plugin', () => {
                 },
                 },
             });
             });
             await awaitRunningJobs();
             await awaitRunningJobs();
-            const result = await adminClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(SEARCH_PRODUCTS, {
-                input: {
-                    groupByProduct: true,
-                    take: 3,
+            const result = await adminClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+                SEARCH_PRODUCTS,
+                {
+                    input: {
+                        groupByProduct: true,
+                        take: 3,
+                    },
                 },
                 },
-            });
+            );
             expect(result.search.items.map(pick(['productId', 'enabled']))).toEqual([
             expect(result.search.items.map(pick(['productId', 'enabled']))).toEqual([
                 { productId: 'T_1', enabled: false },
                 { productId: 'T_1', enabled: false },
                 { productId: 'T_2', enabled: true },
                 { productId: 'T_2', enabled: true },

+ 78 - 50
packages/core/e2e/fixtures/test-plugins.ts

@@ -1,31 +1,43 @@
+import { Injectable, OnApplicationBootstrap, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
 import { Query, Resolver } from '@nestjs/graphql';
 import { Query, Resolver } from '@nestjs/graphql';
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 import gql from 'graphql-tag';
 import gql from 'graphql-tag';
 
 
-import { APIExtensionDefinition, InjectorFn, VendureConfig, VendurePlugin } from '../../src/config';
+import { VendureConfig } from '../../src/config';
+import { ConfigModule } from '../../src/config/config.module';
 import { ConfigService } from '../../src/config/config.service';
 import { ConfigService } from '../../src/config/config.service';
+import {
+    OnVendureBootstrap,
+    OnVendureClose,
+    OnVendureWorkerBootstrap,
+    OnVendureWorkerClose,
+    VendurePlugin,
+} from '../../src/plugin/vendure-plugin';
 
 
-export class TestAPIExtensionPlugin implements VendurePlugin {
-    extendShopAPI(): APIExtensionDefinition {
-        return {
-            resolvers: [TestShopPluginResolver],
-            schema: gql`
-                extend type Query {
-                    baz: [String]!
-                }
-            `,
-        };
+export class TestPluginWithAllLifecycleHooks
+    implements OnVendureBootstrap, OnVendureWorkerBootstrap, OnVendureClose, OnVendureWorkerClose {
+    private static onBootstrapFn: any;
+    private static onWorkerBootstrapFn: any;
+    private static onCloseFn: any;
+    private static onWorkerCloseFn: any;
+    static init(bootstrapFn: any, workerBootstrapFn: any, closeFn: any, workerCloseFn: any) {
+        this.onBootstrapFn = bootstrapFn;
+        this.onWorkerBootstrapFn = workerBootstrapFn;
+        this.onCloseFn = closeFn;
+        this.onWorkerCloseFn = workerCloseFn;
+        return this;
     }
     }
-
-    extendAdminAPI(): APIExtensionDefinition {
-        return {
-            resolvers: [TestAdminPluginResolver],
-            schema: gql`
-                extend type Query {
-                    foo: [String]!
-                }
-            `,
-        };
+    onVendureBootstrap(): void | Promise<void> {
+        TestPluginWithAllLifecycleHooks.onBootstrapFn();
+    }
+    onVendureWorkerBootstrap(): void | Promise<void> {
+        TestPluginWithAllLifecycleHooks.onWorkerBootstrapFn();
+    }
+    onVendureClose(): void | Promise<void> {
+        TestPluginWithAllLifecycleHooks.onCloseFn();
+    }
+    onVendureWorkerClose(): void | Promise<void> {
+        TestPluginWithAllLifecycleHooks.onWorkerCloseFn();
     }
     }
 }
 }
 
 
@@ -45,23 +57,27 @@ export class TestShopPluginResolver {
     }
     }
 }
 }
 
 
-export class TestPluginWithProvider implements VendurePlugin {
-    extendShopAPI(): APIExtensionDefinition {
-        return {
-            resolvers: [TestResolverWithInjection],
-            schema: gql`
-                extend type Query {
-                    names: [String]!
-                }
-            `,
-        };
-    }
-
-    defineProviders() {
-        return [NameService];
-    }
-}
+@VendurePlugin({
+    shopApiExtensions: {
+        resolvers: [TestShopPluginResolver],
+        schema: gql`
+            extend type Query {
+                baz: [String]!
+            }
+        `,
+    },
+    adminApiExtensions: {
+        resolvers: [TestAdminPluginResolver],
+        schema: gql`
+            extend type Query {
+                foo: [String]!
+            }
+        `,
+    },
+})
+export class TestAPIExtensionPlugin {}
 
 
+@Injectable()
 export class NameService {
 export class NameService {
     getNames(): string[] {
     getNames(): string[] {
         return ['seon', 'linda', 'hong'];
         return ['seon', 'linda', 'hong'];
@@ -78,24 +94,36 @@ export class TestResolverWithInjection {
     }
     }
 }
 }
 
 
-export class TestPluginWithConfigAndBootstrap implements VendurePlugin {
-    constructor(private boostrapWasCalled: (arg: any) => void) {}
+@VendurePlugin({
+    providers: [NameService],
+    shopApiExtensions: {
+        resolvers: [TestResolverWithInjection],
+        schema: gql`
+            extend type Query {
+                names: [String]!
+            }
+        `,
+    },
+})
+export class TestPluginWithProvider {}
 
 
-    configure(config: Required<VendureConfig>): Required<VendureConfig> {
+@VendurePlugin({
+    imports: [ConfigModule],
+    configuration(config: Required<VendureConfig>): Required<VendureConfig> {
         // tslint:disable-next-line:no-non-null-assertion
         // tslint:disable-next-line:no-non-null-assertion
         config.defaultLanguageCode = LanguageCode.zh;
         config.defaultLanguageCode = LanguageCode.zh;
         return config;
         return config;
+    },
+})
+export class TestPluginWithConfigAndBootstrap implements OnVendureBootstrap {
+    private static boostrapWasCalled: any;
+    static setup(boostrapWasCalled: (arg: any) => void) {
+        TestPluginWithConfigAndBootstrap.boostrapWasCalled = boostrapWasCalled;
+        return TestPluginWithConfigAndBootstrap;
     }
     }
+    constructor(private configService: ConfigService) {}
 
 
-    onBootstrap(inject: InjectorFn) {
-        const configService = inject(ConfigService);
-        this.boostrapWasCalled(configService);
-    }
-}
-
-export class TestPluginWithOnClose implements VendurePlugin {
-    constructor(private onCloseCallback: () => void) {}
-    onClose() {
-        this.onCloseCallback();
+    onVendureBootstrap() {
+        TestPluginWithConfigAndBootstrap.boostrapWasCalled(this.configService);
     }
     }
 }
 }

+ 34 - 9
packages/core/e2e/plugin.e2e-spec.ts

@@ -7,8 +7,8 @@ import { ConfigService } from '../src/config/config.service';
 import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
 import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
 import {
 import {
     TestAPIExtensionPlugin,
     TestAPIExtensionPlugin,
+    TestPluginWithAllLifecycleHooks,
     TestPluginWithConfigAndBootstrap,
     TestPluginWithConfigAndBootstrap,
-    TestPluginWithOnClose,
     TestPluginWithProvider,
     TestPluginWithProvider,
 } from './fixtures/test-plugins';
 } from './fixtures/test-plugins';
 import { TestAdminClient, TestShopClient } from './test-client';
 import { TestAdminClient, TestShopClient } from './test-client';
@@ -19,7 +19,10 @@ describe('Plugins', () => {
     const shopClient = new TestShopClient();
     const shopClient = new TestShopClient();
     const server = new TestServer();
     const server = new TestServer();
     const bootstrapMockFn = jest.fn();
     const bootstrapMockFn = jest.fn();
+    const onBootstrapFn = jest.fn();
+    const onWorkerBootstrapFn = jest.fn();
     const onCloseFn = jest.fn();
     const onCloseFn = jest.fn();
+    const onWorkerCloseFn = jest.fn();
 
 
     beforeAll(async () => {
     beforeAll(async () => {
         const token = await server.init(
         const token = await server.init(
@@ -29,10 +32,15 @@ describe('Plugins', () => {
             },
             },
             {
             {
                 plugins: [
                 plugins: [
-                    new TestPluginWithConfigAndBootstrap(bootstrapMockFn),
-                    new TestAPIExtensionPlugin(),
-                    new TestPluginWithProvider(),
-                    new TestPluginWithOnClose(onCloseFn),
+                    TestPluginWithAllLifecycleHooks.init(
+                        onBootstrapFn,
+                        onWorkerBootstrapFn,
+                        onCloseFn,
+                        onWorkerCloseFn,
+                    ),
+                    TestPluginWithConfigAndBootstrap.setup(bootstrapMockFn),
+                    TestAPIExtensionPlugin,
+                    TestPluginWithProvider,
                 ],
                 ],
             },
             },
         );
         );
@@ -44,7 +52,15 @@ describe('Plugins', () => {
         await server.destroy();
         await server.destroy();
     });
     });
 
 
-    it('can modify the config in configure() and inject in onBootstrap()', () => {
+    it('calls onVendureBootstrap', () => {
+        expect(onBootstrapFn).toHaveBeenCalled();
+    });
+
+    it('calls onWorkerVendureBootstrap', () => {
+        expect(onWorkerBootstrapFn).toHaveBeenCalled();
+    });
+
+    it('can modify the config in configure()', () => {
         expect(bootstrapMockFn).toHaveBeenCalled();
         expect(bootstrapMockFn).toHaveBeenCalled();
         const configService: ConfigService = bootstrapMockFn.mock.calls[0][0];
         const configService: ConfigService = bootstrapMockFn.mock.calls[0][0];
         expect(configService instanceof ConfigService).toBe(true);
         expect(configService instanceof ConfigService).toBe(true);
@@ -78,8 +94,17 @@ describe('Plugins', () => {
         expect(result.names).toEqual(['seon', 'linda', 'hong']);
         expect(result.names).toEqual(['seon', 'linda', 'hong']);
     });
     });
 
 
-    it('calls onClose method when app is closed', async () => {
-        await server.destroy();
-        expect(onCloseFn).toHaveBeenCalled();
+    describe('on app close', () => {
+        beforeAll(async () => {
+            await server.destroy();
+        });
+
+        it('calls onVendureClose', () => {
+            expect(onCloseFn).toHaveBeenCalled();
+        });
+
+        it('calls onWorkerVendureClose', () => {
+            expect(onWorkerCloseFn).toHaveBeenCalled();
+        });
     });
     });
 });
 });

+ 31 - 15
packages/core/e2e/shop-auth.e2e-spec.ts

@@ -1,19 +1,26 @@
 /* tslint:disable:no-non-null-assertion */
 /* tslint:disable:no-non-null-assertion */
+import { OnModuleInit } from '@nestjs/common';
 import { RegisterCustomerInput } from '@vendure/common/lib/generated-shop-types';
 import { RegisterCustomerInput } from '@vendure/common/lib/generated-shop-types';
 import { pick } from '@vendure/common/lib/pick';
 import { pick } from '@vendure/common/lib/pick';
 import { DocumentNode } from 'graphql';
 import { DocumentNode } from 'graphql';
 import gql from 'graphql-tag';
 import gql from 'graphql-tag';
 import path from 'path';
 import path from 'path';
 
 
-import { InjectorFn, VendurePlugin } from '../src/config/vendure-plugin/vendure-plugin';
 import { EventBus } from '../src/event-bus/event-bus';
 import { EventBus } from '../src/event-bus/event-bus';
+import { EventBusModule } from '../src/event-bus/event-bus.module';
 import { AccountRegistrationEvent } from '../src/event-bus/events/account-registration-event';
 import { AccountRegistrationEvent } from '../src/event-bus/events/account-registration-event';
 import { IdentifierChangeEvent } from '../src/event-bus/events/identifier-change-event';
 import { IdentifierChangeEvent } from '../src/event-bus/events/identifier-change-event';
 import { IdentifierChangeRequestEvent } from '../src/event-bus/events/identifier-change-request-event';
 import { IdentifierChangeRequestEvent } from '../src/event-bus/events/identifier-change-request-event';
 import { PasswordResetEvent } from '../src/event-bus/events/password-reset-event';
 import { PasswordResetEvent } from '../src/event-bus/events/password-reset-event';
+import { VendurePlugin } from '../src/plugin/vendure-plugin';
 
 
 import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
 import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
-import { CreateAdministrator, CreateRole, GetCustomer, Permission } from './graphql/generated-e2e-admin-types';
+import {
+    CreateAdministrator,
+    CreateRole,
+    GetCustomer,
+    Permission,
+} from './graphql/generated-e2e-admin-types';
 import {
 import {
     GetActiveCustomer,
     GetActiveCustomer,
     RefreshToken,
     RefreshToken,
@@ -53,7 +60,7 @@ describe('Shop auth & accounts', () => {
                 customerCount: 2,
                 customerCount: 2,
             },
             },
             {
             {
-                plugins: [new TestEmailPlugin()],
+                plugins: [TestEmailPlugin],
             },
             },
         );
         );
         await shopClient.init();
         await shopClient.init();
@@ -391,12 +398,18 @@ describe('Shop auth & accounts', () => {
         it(
         it(
             'throws with bad token',
             'throws with bad token',
             assertThrowsWithMessage(async () => {
             assertThrowsWithMessage(async () => {
-                await shopClient.query<UpdateEmailAddress.Mutation, UpdateEmailAddress.Variables>(UPDATE_EMAIL_ADDRESS, { token: 'bad token' });
+                await shopClient.query<UpdateEmailAddress.Mutation, UpdateEmailAddress.Variables>(
+                    UPDATE_EMAIL_ADDRESS,
+                    { token: 'bad token' },
+                );
             }, 'Identifier change token not recognized'),
             }, 'Identifier change token not recognized'),
         );
         );
 
 
         it('verify the new email address', async () => {
         it('verify the new email address', async () => {
-            const result = await shopClient.query<UpdateEmailAddress.Mutation, UpdateEmailAddress.Variables>(UPDATE_EMAIL_ADDRESS, { token: emailUpdateToken });
+            const result = await shopClient.query<UpdateEmailAddress.Mutation, UpdateEmailAddress.Variables>(
+                UPDATE_EMAIL_ADDRESS,
+                { token: emailUpdateToken },
+            );
             expect(result.updateCustomerEmailAddress).toBe(true);
             expect(result.updateCustomerEmailAddress).toBe(true);
 
 
             expect(sendEmailFn).toHaveBeenCalled();
             expect(sendEmailFn).toHaveBeenCalled();
@@ -506,7 +519,7 @@ describe('Expiring tokens', () => {
                 customerCount: 1,
                 customerCount: 1,
             },
             },
             {
             {
-                plugins: [new TestEmailPlugin()],
+                plugins: [TestEmailPlugin],
                 authOptions: {
                 authOptions: {
                     verificationTokenDuration: '1ms',
                     verificationTokenDuration: '1ms',
                 },
                 },
@@ -596,7 +609,7 @@ describe('Registration without email verification', () => {
                 customerCount: 1,
                 customerCount: 1,
             },
             },
             {
             {
-                plugins: [new TestEmailPlugin()],
+                plugins: [TestEmailPlugin],
                 authOptions: {
                 authOptions: {
                     requireVerification: false,
                     requireVerification: false,
                 },
                 },
@@ -672,7 +685,7 @@ describe('Updating email address without email verification', () => {
                 customerCount: 1,
                 customerCount: 1,
             },
             },
             {
             {
-                plugins: [new TestEmailPlugin()],
+                plugins: [TestEmailPlugin],
                 authOptions: {
                 authOptions: {
                     requireVerification: false,
                     requireVerification: false,
                 },
                 },
@@ -720,19 +733,22 @@ describe('Updating email address without email verification', () => {
  * This mock plugin simulates an EmailPlugin which would send emails
  * This mock plugin simulates an EmailPlugin which would send emails
  * on the registration & password reset events.
  * on the registration & password reset events.
  */
  */
-class TestEmailPlugin implements VendurePlugin {
-    onBootstrap(inject: InjectorFn) {
-        const eventBus = inject(EventBus);
-        eventBus.subscribe(AccountRegistrationEvent, event => {
+@VendurePlugin({
+    imports: [EventBusModule],
+})
+class TestEmailPlugin implements OnModuleInit {
+    constructor(private eventBus: EventBus) {}
+    onModuleInit() {
+        this.eventBus.subscribe(AccountRegistrationEvent, event => {
             sendEmailFn(event);
             sendEmailFn(event);
         });
         });
-        eventBus.subscribe(PasswordResetEvent, event => {
+        this.eventBus.subscribe(PasswordResetEvent, event => {
             sendEmailFn(event);
             sendEmailFn(event);
         });
         });
-        eventBus.subscribe(IdentifierChangeRequestEvent, event => {
+        this.eventBus.subscribe(IdentifierChangeRequestEvent, event => {
             sendEmailFn(event);
             sendEmailFn(event);
         });
         });
-        eventBus.subscribe(IdentifierChangeEvent, event => {
+        this.eventBus.subscribe(IdentifierChangeEvent, event => {
             sendEmailFn(event);
             sendEmailFn(event);
         });
         });
     }
     }

+ 9 - 7
packages/core/e2e/test-server.ts

@@ -7,8 +7,9 @@ import { ConnectionOptions } from 'typeorm';
 import { SqljsConnectionOptions } from 'typeorm/driver/sqljs/SqljsConnectionOptions';
 import { SqljsConnectionOptions } from 'typeorm/driver/sqljs/SqljsConnectionOptions';
 
 
 import { populateForTesting, PopulateOptions } from '../mock-data/populate-for-testing';
 import { populateForTesting, PopulateOptions } from '../mock-data/populate-for-testing';
-import { preBootstrapConfig, runPluginOnBootstrapMethods } from '../src/bootstrap';
+import { preBootstrapConfig } from '../src/bootstrap';
 import { Mutable } from '../src/common/types/common-types';
 import { Mutable } from '../src/common/types/common-types';
+import { DefaultLogger } from '../src/config/logger/default-logger';
 import { Logger } from '../src/config/logger/vendure-logger';
 import { Logger } from '../src/config/logger/vendure-logger';
 import { VendureConfig } from '../src/config/vendure-config';
 import { VendureConfig } from '../src/config/vendure-config';
 
 
@@ -36,9 +37,6 @@ export class TestServer {
     ): Promise<void> {
     ): Promise<void> {
         setTestEnvironment();
         setTestEnvironment();
         const testingConfig = { ...testConfig, ...customConfig };
         const testingConfig = { ...testConfig, ...customConfig };
-        if (options.logging) {
-            (testingConfig.dbConnectionOptions as Mutable<ConnectionOptions>).logging = true;
-        }
         const dbFilePath = this.getDbFilePath();
         const dbFilePath = this.getDbFilePath();
         (testingConfig.dbConnectionOptions as Mutable<SqljsConnectionOptions>).location = dbFilePath;
         (testingConfig.dbConnectionOptions as Mutable<SqljsConnectionOptions>).location = dbFilePath;
         if (!fs.existsSync(dbFilePath)) {
         if (!fs.existsSync(dbFilePath)) {
@@ -108,13 +106,16 @@ export class TestServer {
     /**
     /**
      * Bootstraps an instance of the Vendure server for testing against.
      * Bootstraps an instance of the Vendure server for testing against.
      */
      */
-    private async bootstrapForTesting(userConfig: Partial<VendureConfig>): Promise<[INestApplication, INestMicroservice | undefined]> {
+    private async bootstrapForTesting(
+        userConfig: Partial<VendureConfig>,
+    ): Promise<[INestApplication, INestMicroservice | undefined]> {
         const config = await preBootstrapConfig(userConfig);
         const config = await preBootstrapConfig(userConfig);
+        Logger.useLogger(config.logger);
         const appModule = await import('../src/app.module');
         const appModule = await import('../src/app.module');
         try {
         try {
-            const app = await NestFactory.create(appModule.AppModule, {cors: config.cors, logger: false});
+            DefaultLogger.hideNestBoostrapLogs();
+            const app = await NestFactory.create(appModule.AppModule, { cors: config.cors, logger: false });
             let worker: INestMicroservice | undefined;
             let worker: INestMicroservice | undefined;
-            await runPluginOnBootstrapMethods(config, app);
             await app.listen(config.port);
             await app.listen(config.port);
             if (config.workerOptions.runInMainProcess) {
             if (config.workerOptions.runInMainProcess) {
                 const workerModule = await import('../src/worker/worker.module');
                 const workerModule = await import('../src/worker/worker.module');
@@ -125,6 +126,7 @@ export class TestServer {
                 });
                 });
                 await worker.listenAsync();
                 await worker.listenAsync();
             }
             }
+            DefaultLogger.restoreOriginalLogLevel();
             return [app, worker];
             return [app, worker];
         } catch (e) {
         } catch (e) {
             console.log(e);
             console.log(e);

+ 16 - 10
packages/core/src/api/api-internal-modules.ts

@@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
 
 
 import { ConfigModule } from '../config/config.module';
 import { ConfigModule } from '../config/config.module';
 import { DataImportModule } from '../data-import/data-import.module';
 import { DataImportModule } from '../data-import/data-import.module';
-import { PluginModule } from '../plugin/plugin.module';
+import { createDynamicGraphQlModulesForPlugins } from '../plugin/dynamic-plugin-api.module';
 import { ServiceModule } from '../service/service.module';
 import { ServiceModule } from '../service/service.module';
 
 
 import { IdCodecService } from './common/id-codec.service';
 import { IdCodecService } from './common/id-codec.service';
@@ -37,7 +37,10 @@ import { OrderLineEntityResolver } from './resolvers/entity/order-line-entity.re
 import { PaymentEntityResolver } from './resolvers/entity/payment-entity.resolver';
 import { PaymentEntityResolver } from './resolvers/entity/payment-entity.resolver';
 import { ProductEntityResolver } from './resolvers/entity/product-entity.resolver';
 import { ProductEntityResolver } from './resolvers/entity/product-entity.resolver';
 import { ProductOptionGroupEntityResolver } from './resolvers/entity/product-option-group-entity.resolver';
 import { ProductOptionGroupEntityResolver } from './resolvers/entity/product-option-group-entity.resolver';
-import { ProductVariantAdminEntityResolver, ProductVariantEntityResolver } from './resolvers/entity/product-variant-entity.resolver';
+import {
+    ProductVariantAdminEntityResolver,
+    ProductVariantEntityResolver,
+} from './resolvers/entity/product-variant-entity.resolver';
 import { RefundEntityResolver } from './resolvers/entity/refund-entity.resolver';
 import { RefundEntityResolver } from './resolvers/entity/refund-entity.resolver';
 import { ShopAuthResolver } from './resolvers/shop/shop-auth.resolver';
 import { ShopAuthResolver } from './resolvers/shop/shop-auth.resolver';
 import { ShopCustomerResolver } from './resolvers/shop/shop-customer.resolver';
 import { ShopCustomerResolver } from './resolvers/shop/shop-customer.resolver';
@@ -92,9 +95,7 @@ export const entityResolvers = [
     RefundEntityResolver,
     RefundEntityResolver,
 ];
 ];
 
 
-export const adminEntityResolvers = [
-    ProductVariantAdminEntityResolver,
-];
+export const adminEntityResolvers = [ProductVariantAdminEntityResolver];
 
 
 /**
 /**
  * The internal module containing some shared providers used by more than
  * The internal module containing some shared providers used by more than
@@ -111,8 +112,13 @@ export class ApiSharedModule {}
  * The internal module containing the Admin GraphQL API resolvers
  * The internal module containing the Admin GraphQL API resolvers
  */
  */
 @Module({
 @Module({
-    imports: [ApiSharedModule, PluginModule.forRoot(), ServiceModule.forRoot(), DataImportModule],
-    providers: [...adminResolvers, ...entityResolvers, ...adminEntityResolvers, ...PluginModule.adminApiResolvers()],
+    imports: [
+        ApiSharedModule,
+        ServiceModule.forRoot(),
+        DataImportModule,
+        ...createDynamicGraphQlModulesForPlugins('admin'),
+    ],
+    providers: [...adminResolvers, ...entityResolvers, ...adminEntityResolvers],
     exports: [...adminResolvers],
     exports: [...adminResolvers],
 })
 })
 export class AdminApiModule {}
 export class AdminApiModule {}
@@ -121,8 +127,8 @@ export class AdminApiModule {}
  * The internal module containing the Shop GraphQL API resolvers
  * The internal module containing the Shop GraphQL API resolvers
  */
  */
 @Module({
 @Module({
-    imports: [ApiSharedModule, PluginModule.forRoot(), ServiceModule.forRoot()],
-    providers: [...shopResolvers, ...entityResolvers, ...PluginModule.shopApiResolvers()],
-    exports: shopResolvers,
+    imports: [ApiSharedModule, ServiceModule.forRoot(), ...createDynamicGraphQlModulesForPlugins('shop')],
+    providers: [...shopResolvers, ...entityResolvers],
+    exports: [...shopResolvers],
 })
 })
 export class ShopApiModule {}
 export class ShopApiModule {}

+ 4 - 4
packages/core/src/api/common/get-api-type.ts

@@ -6,16 +6,16 @@ import { GraphQLResolveInfo } from 'graphql';
  *
  *
  * @docsCategory request
  * @docsCategory request
  */
  */
-export type ApiType = 'admin' | 'shop';
+export type ApiType = 'admin' | 'shop' | 'custom';
 
 
 /**
 /**
  * Inspects the GraphQL "info" resolver argument to determine which API
  * Inspects the GraphQL "info" resolver argument to determine which API
  * the request came through.
  * the request came through.
  */
  */
-export function getApiType(info: GraphQLResolveInfo): ApiType {
-    const query = info.schema.getQueryType();
+export function getApiType(info?: GraphQLResolveInfo): ApiType {
+    const query = info && info.schema.getQueryType();
     if (query) {
     if (query) {
         return !!query.getFields().administrators ? 'admin' : 'shop';
         return !!query.getFields().administrators ? 'admin' : 'shop';
     }
     }
-    return 'shop';
+    return 'custom';
 }
 }

+ 32 - 0
packages/core/src/api/common/parse-context.ts

@@ -0,0 +1,32 @@
+import { ExecutionContext } from '@nestjs/common';
+import { GqlExecutionContext } from '@nestjs/graphql';
+import { Request, Response } from 'express';
+import { GraphQLResolveInfo } from 'graphql';
+
+/**
+ * Parses in the Nest ExecutionContext of the incoming request, accounting for both
+ * GraphQL & REST requests.
+ */
+export function parseContext(
+    context: ExecutionContext,
+): { req: Request; res: Response; isGraphQL: boolean; info?: GraphQLResolveInfo } {
+    const graphQlContext = GqlExecutionContext.create(context);
+    const restContext = GqlExecutionContext.create(context);
+    const info = graphQlContext.getInfo();
+    let req: Request;
+    let res: Response;
+    if (info) {
+        const ctx = graphQlContext.getContext();
+        req = ctx.req;
+        res = ctx.res;
+    } else {
+        req = context.switchToHttp().getRequest();
+        res = context.switchToHttp().getResponse();
+    }
+    return {
+        req,
+        res,
+        info,
+        isGraphQL: !!info,
+    };
+}

+ 1 - 1
packages/core/src/api/common/request-context.service.ts

@@ -28,7 +28,7 @@ export class RequestContextService {
      */
      */
     async fromRequest(
     async fromRequest(
         req: Request,
         req: Request,
-        info: GraphQLResolveInfo,
+        info?: GraphQLResolveInfo,
         requiredPermissions?: Permission[],
         requiredPermissions?: Permission[],
         session?: Session,
         session?: Session,
     ): Promise<RequestContext> {
     ): Promise<RequestContext> {

+ 12 - 6
packages/core/src/api/config/configure-graphql-module.ts

@@ -1,6 +1,7 @@
 import { DynamicModule } from '@nestjs/common';
 import { DynamicModule } from '@nestjs/common';
 import { GqlModuleOptions, GraphQLModule, GraphQLTypesLoader } from '@nestjs/graphql';
 import { GqlModuleOptions, GraphQLModule, GraphQLTypesLoader } from '@nestjs/graphql';
 import { StockMovementType } from '@vendure/common/lib/generated-types';
 import { StockMovementType } from '@vendure/common/lib/generated-types';
+import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 import { GraphQLUpload } from 'apollo-server-core';
 import { GraphQLUpload } from 'apollo-server-core';
 import { extendSchema, printSchema } from 'graphql';
 import { extendSchema, printSchema } from 'graphql';
 import { GraphQLDateTime } from 'graphql-iso-date';
 import { GraphQLDateTime } from 'graphql-iso-date';
@@ -11,14 +12,19 @@ import { ConfigModule } from '../../config/config.module';
 import { ConfigService } from '../../config/config.service';
 import { ConfigService } from '../../config/config.service';
 import { I18nModule } from '../../i18n/i18n.module';
 import { I18nModule } from '../../i18n/i18n.module';
 import { I18nService } from '../../i18n/i18n.service';
 import { I18nService } from '../../i18n/i18n.service';
-import { getPluginAPIExtensions } from '../../plugin/plugin-utils';
+import { getDynamicGraphQlModulesForPlugins } from '../../plugin/dynamic-plugin-api.module';
+import { getPluginAPIExtensions } from '../../plugin/plugin-metadata';
 import { ApiSharedModule } from '../api-internal-modules';
 import { ApiSharedModule } from '../api-internal-modules';
 import { IdCodecService } from '../common/id-codec.service';
 import { IdCodecService } from '../common/id-codec.service';
 import { IdEncoderExtension } from '../middleware/id-encoder-extension';
 import { IdEncoderExtension } from '../middleware/id-encoder-extension';
 import { TranslateErrorExtension } from '../middleware/translate-errors-extension';
 import { TranslateErrorExtension } from '../middleware/translate-errors-extension';
 
 
 import { generateListOptions } from './generate-list-options';
 import { generateListOptions } from './generate-list-options';
-import { addGraphQLCustomFields, addOrderLineCustomFieldsInput, addServerConfigCustomFields } from './graphql-custom-fields';
+import {
+    addGraphQLCustomFields,
+    addOrderLineCustomFieldsInput,
+    addServerConfigCustomFields,
+} from './graphql-custom-fields';
 
 
 export interface GraphQLApiOptions {
 export interface GraphQLApiOptions {
     apiType: 'shop' | 'admin';
     apiType: 'shop' | 'admin';
@@ -106,7 +112,7 @@ async function createGraphQLOptions(
     return {
     return {
         path: '/' + options.apiPath,
         path: '/' + options.apiPath,
         typeDefs: await createTypeDefs(options.apiType),
         typeDefs: await createTypeDefs(options.apiType),
-        include: [options.resolverModule],
+        include: [options.resolverModule, ...getDynamicGraphQlModulesForPlugins(options.apiType)],
         resolvers: {
         resolvers: {
             JSON: GraphQLJSON,
             JSON: GraphQLJSON,
             DateTime: GraphQLDateTime,
             DateTime: GraphQLDateTime,
@@ -157,9 +163,9 @@ async function createGraphQLOptions(
         schema = addGraphQLCustomFields(schema, customFields, apiType === 'shop');
         schema = addGraphQLCustomFields(schema, customFields, apiType === 'shop');
         schema = addServerConfigCustomFields(schema, customFields);
         schema = addServerConfigCustomFields(schema, customFields);
         schema = addOrderLineCustomFieldsInput(schema, customFields.OrderLine || []);
         schema = addOrderLineCustomFieldsInput(schema, customFields.OrderLine || []);
-        const pluginSchemaExtensions = getPluginAPIExtensions(configService.plugins, apiType).map(
-            e => e.schema,
-        );
+        const pluginSchemaExtensions = getPluginAPIExtensions(configService.plugins, apiType)
+            .map(e => e.schema)
+            .filter(notNullOrUndefined);
 
 
         for (const documentNode of pluginSchemaExtensions) {
         for (const documentNode of pluginSchemaExtensions) {
             schema = extendSchema(schema, documentNode);
             schema = extendSchema(schema, documentNode);

+ 8 - 2
packages/core/src/api/decorators/request-context.decorator.ts

@@ -6,6 +6,12 @@ import { REQUEST_CONTEXT_KEY } from '../common/request-context.service';
  * Resolver param decorator which extracts the RequestContext from the incoming
  * Resolver param decorator which extracts the RequestContext from the incoming
  * request object.
  * request object.
  */
  */
-export const Ctx = createParamDecorator((data, [root, args, ctx]) => {
-    return ctx.req[REQUEST_CONTEXT_KEY];
+export const Ctx = createParamDecorator((data, arg) => {
+    if (Array.isArray(arg)) {
+        // GraphQL request
+        return arg[2].req[REQUEST_CONTEXT_KEY];
+    } else {
+        // REST request
+        return arg[REQUEST_CONTEXT_KEY];
+    }
 });
 });

+ 13 - 9
packages/core/src/api/middleware/asset-interceptor.ts

@@ -1,5 +1,4 @@
 import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
 import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
-import { GqlExecutionContext } from '@nestjs/graphql';
 import { Type } from '@vendure/common/lib/shared-types';
 import { Type } from '@vendure/common/lib/shared-types';
 import { Observable } from 'rxjs';
 import { Observable } from 'rxjs';
 import { map } from 'rxjs/operators';
 import { map } from 'rxjs/operators';
@@ -7,6 +6,7 @@ import { map } from 'rxjs/operators';
 import { AssetStorageStrategy } from '../../config/asset-storage-strategy/asset-storage-strategy';
 import { AssetStorageStrategy } from '../../config/asset-storage-strategy/asset-storage-strategy';
 import { ConfigService } from '../../config/config.service';
 import { ConfigService } from '../../config/config.service';
 import { Asset } from '../../entity/asset/asset.entity';
 import { Asset } from '../../entity/asset/asset.entity';
+import { parseContext } from '../common/parse-context';
 
 
 /**
 /**
  * Transforms outputs so that any Asset instances are run through the {@link AssetStorageStrategy.toAbsoluteUrl}
  * Transforms outputs so that any Asset instances are run through the {@link AssetStorageStrategy.toAbsoluteUrl}
@@ -30,20 +30,19 @@ export class AssetInterceptor implements NestInterceptor {
         if (toAbsoluteUrl === undefined) {
         if (toAbsoluteUrl === undefined) {
             return next.handle();
             return next.handle();
         }
         }
-        const ctx = GqlExecutionContext.create(context).getContext();
-        const request = ctx.req;
+        const { req } = parseContext(context);
         return next.handle().pipe(
         return next.handle().pipe(
             map(data => {
             map(data => {
                 if (data instanceof Asset) {
                 if (data instanceof Asset) {
-                    data.preview = toAbsoluteUrl(request, data.preview);
-                    data.source = toAbsoluteUrl(request, data.source);
+                    data.preview = toAbsoluteUrl(req, data.preview);
+                    data.source = toAbsoluteUrl(req, data.source);
                 } else {
                 } else {
                     visitType(data, [Asset, 'productPreview', 'productVariantPreview'], asset => {
                     visitType(data, [Asset, 'productPreview', 'productVariantPreview'], asset => {
                         if (asset instanceof Asset) {
                         if (asset instanceof Asset) {
-                            asset.preview = toAbsoluteUrl(request, asset.preview);
-                            asset.source = toAbsoluteUrl(request, asset.source);
+                            asset.preview = toAbsoluteUrl(req, asset.preview);
+                            asset.source = toAbsoluteUrl(req, asset.source);
                         } else {
                         } else {
-                            asset = toAbsoluteUrl(request, asset);
+                            asset = toAbsoluteUrl(req, asset);
                         }
                         }
                         return asset;
                         return asset;
                     });
                     });
@@ -58,7 +57,12 @@ export class AssetInterceptor implements NestInterceptor {
  * Traverses the object and when encountering a property with a value which
  * Traverses the object and when encountering a property with a value which
  * is an instance of class T, invokes the visitor function on that value.
  * is an instance of class T, invokes the visitor function on that value.
  */
  */
-function visitType<T>(obj: any, types: Array<Type<T> | string>, visit: (instance: T | string) => T | string, seen: Set<any> = new Set()) {
+function visitType<T>(
+    obj: any,
+    types: Array<Type<T> | string>,
+    visit: (instance: T | string) => T | string,
+    seen: Set<any> = new Set(),
+) {
     const keys = Object.keys(obj || {});
     const keys = Object.keys(obj || {});
     for (const key of keys) {
     for (const key of keys) {
         const value = obj[key];
         const value = obj[key];

+ 2 - 5
packages/core/src/api/middleware/auth-guard.ts

@@ -10,6 +10,7 @@ import { ConfigService } from '../../config/config.service';
 import { Session } from '../../entity/session/session.entity';
 import { Session } from '../../entity/session/session.entity';
 import { AuthService } from '../../service/services/auth.service';
 import { AuthService } from '../../service/services/auth.service';
 import { extractAuthToken } from '../common/extract-auth-token';
 import { extractAuthToken } from '../common/extract-auth-token';
+import { parseContext } from '../common/parse-context';
 import { REQUEST_CONTEXT_KEY, RequestContextService } from '../common/request-context.service';
 import { REQUEST_CONTEXT_KEY, RequestContextService } from '../common/request-context.service';
 import { setAuthToken } from '../common/set-auth-token';
 import { setAuthToken } from '../common/set-auth-token';
 import { PERMISSIONS_METADATA_KEY } from '../decorators/allow.decorator';
 import { PERMISSIONS_METADATA_KEY } from '../decorators/allow.decorator';
@@ -30,11 +31,7 @@ export class AuthGuard implements CanActivate {
     ) {}
     ) {}
 
 
     async canActivate(context: ExecutionContext): Promise<boolean> {
     async canActivate(context: ExecutionContext): Promise<boolean> {
-        const graphQlContext = GqlExecutionContext.create(context);
-        const ctx = graphQlContext.getContext();
-        const info = graphQlContext.getInfo<GraphQLResolveInfo>();
-        const req: Request = ctx.req;
-        const res: Response = ctx.res;
+        const { req, res, info } = parseContext(context);
         const authDisabled = this.configService.authOptions.disableAuth;
         const authDisabled = this.configService.authOptions.disableAuth;
         const permissions = this.reflector.get<Permission[]>(PERMISSIONS_METADATA_KEY, context.getHandler());
         const permissions = this.reflector.get<Permission[]>(PERMISSIONS_METADATA_KEY, context.getHandler());
         const isPublic = !!permissions && permissions.includes(Permission.Public);
         const isPublic = !!permissions && permissions.includes(Permission.Public);

+ 12 - 8
packages/core/src/api/middleware/id-interceptor.ts

@@ -4,6 +4,7 @@ import { GqlExecutionContext } from '@nestjs/graphql';
 import { Observable } from 'rxjs';
 import { Observable } from 'rxjs';
 
 
 import { IdCodecService } from '../common/id-codec.service';
 import { IdCodecService } from '../common/id-codec.service';
+import { parseContext } from '../common/parse-context';
 import { DECODE_METADATA_KEY } from '../decorators/decode.decorator';
 import { DECODE_METADATA_KEY } from '../decorators/decode.decorator';
 
 
 /**
 /**
@@ -18,14 +19,17 @@ export class IdInterceptor implements NestInterceptor {
     constructor(private idCodecService: IdCodecService, private readonly reflector: Reflector) {}
     constructor(private idCodecService: IdCodecService, private readonly reflector: Reflector) {}
 
 
     intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> {
     intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> {
-        const args = GqlExecutionContext.create(context).getArgs();
-        const transformKeys = this.reflector.get<string[]>(DECODE_METADATA_KEY, context.getHandler());
-        const gqlRoot = context.getArgByIndex(0);
-        if (!gqlRoot) {
-            // Only need to decode ids if this is a root query/mutation.
-            // Internal (property-resolver) requests can then be assumed to
-            // be already decoded.
-            Object.assign(args, this.idCodecService.decode(args, transformKeys));
+        const { isGraphQL } = parseContext(context);
+        if (isGraphQL) {
+            const args = GqlExecutionContext.create(context).getArgs();
+            const transformKeys = this.reflector.get<string[]>(DECODE_METADATA_KEY, context.getHandler());
+            const gqlRoot = context.getArgByIndex(0);
+            if (!gqlRoot) {
+                // Only need to decode ids if this is a root query/mutation.
+                // Internal (property-resolver) requests can then be assumed to
+                // be already decoded.
+                Object.assign(args, this.idCodecService.decode(args, transformKeys));
+            }
         }
         }
         return next.handle();
         return next.handle();
     }
     }

+ 41 - 18
packages/core/src/api/middleware/validate-custom-fields-interceptor.ts

@@ -21,6 +21,7 @@ import {
     LocaleStringCustomFieldConfig,
     LocaleStringCustomFieldConfig,
     StringCustomFieldConfig,
     StringCustomFieldConfig,
 } from '../../config/custom-field/custom-field-types';
 } from '../../config/custom-field/custom-field-types';
+import { parseContext } from '../common/parse-context';
 import { RequestContext } from '../common/request-context';
 import { RequestContext } from '../common/request-context';
 import { REQUEST_CONTEXT_KEY } from '../common/request-context.service';
 import { REQUEST_CONTEXT_KEY } from '../common/request-context.service';
 import { validateCustomFieldValue } from '../common/validate-custom-field-value';
 import { validateCustomFieldValue } from '../common/validate-custom-field-value';
@@ -43,37 +44,52 @@ export class ValidateCustomFieldsInterceptor implements NestInterceptor {
     }
     }
 
 
     intercept(context: ExecutionContext, next: CallHandler<any>) {
     intercept(context: ExecutionContext, next: CallHandler<any>) {
-        const gqlExecutionContext = GqlExecutionContext.create(context);
-        const { operation, schema } = gqlExecutionContext.getInfo<GraphQLResolveInfo>();
-        const variables = gqlExecutionContext.getArgs();
-        const ctx: RequestContext = gqlExecutionContext.getContext().req[REQUEST_CONTEXT_KEY];
+        const { isGraphQL } = parseContext(context);
+        if (isGraphQL) {
+            const gqlExecutionContext = GqlExecutionContext.create(context);
+            const { operation, schema } = gqlExecutionContext.getInfo<GraphQLResolveInfo>();
+            const variables = gqlExecutionContext.getArgs();
+            const ctx: RequestContext = gqlExecutionContext.getContext().req[REQUEST_CONTEXT_KEY];
 
 
-        if (operation.operation === 'mutation') {
-            const inputTypeNames = this.getArgumentMap(operation, schema);
-            Object.entries(inputTypeNames).forEach(([inputName, typeName]) => {
-                if (this.inputsWithCustomFields.has(typeName)) {
-                    if (variables[inputName]) {
-                        this.validateInput(typeName, ctx.languageCode, variables[inputName]);
+            if (operation.operation === 'mutation') {
+                const inputTypeNames = this.getArgumentMap(operation, schema);
+                Object.entries(inputTypeNames).forEach(([inputName, typeName]) => {
+                    if (this.inputsWithCustomFields.has(typeName)) {
+                        if (variables[inputName]) {
+                            this.validateInput(typeName, ctx.languageCode, variables[inputName]);
+                        }
                     }
                     }
-                }
-            });
+                });
+            }
         }
         }
         return next.handle();
         return next.handle();
     }
     }
 
 
-    private validateInput(typeName: string, languageCode: LanguageCode, variableValues?: { [key: string]: any }) {
+    private validateInput(
+        typeName: string,
+        languageCode: LanguageCode,
+        variableValues?: { [key: string]: any },
+    ) {
         if (variableValues) {
         if (variableValues) {
             const entityName = typeName.replace(/(Create|Update)(.+)Input/, '$2');
             const entityName = typeName.replace(/(Create|Update)(.+)Input/, '$2');
             const customFieldConfig = this.configService.customFields[entityName as keyof CustomFields];
             const customFieldConfig = this.configService.customFields[entityName as keyof CustomFields];
             if (customFieldConfig) {
             if (customFieldConfig) {
                 if (variableValues.customFields) {
                 if (variableValues.customFields) {
-                    this.validateCustomFieldsObject(customFieldConfig, languageCode, variableValues.customFields);
+                    this.validateCustomFieldsObject(
+                        customFieldConfig,
+                        languageCode,
+                        variableValues.customFields,
+                    );
                 }
                 }
                 const translations = variableValues.translations;
                 const translations = variableValues.translations;
                 if (Array.isArray(translations)) {
                 if (Array.isArray(translations)) {
                     for (const translation of translations) {
                     for (const translation of translations) {
                         if (translation.customFields) {
                         if (translation.customFields) {
-                            this.validateCustomFieldsObject(customFieldConfig, languageCode, translation.customFields);
+                            this.validateCustomFieldsObject(
+                                customFieldConfig,
+                                languageCode,
+                                translation.customFields,
+                            );
                         }
                         }
                     }
                     }
                 }
                 }
@@ -81,7 +97,11 @@ export class ValidateCustomFieldsInterceptor implements NestInterceptor {
         }
         }
     }
     }
 
 
-    private validateCustomFieldsObject(customFieldConfig: CustomFieldConfig[], languageCode: LanguageCode, customFieldsObject: { [key: string]: any; }) {
+    private validateCustomFieldsObject(
+        customFieldConfig: CustomFieldConfig[],
+        languageCode: LanguageCode,
+        customFieldsObject: { [key: string]: any },
+    ) {
         for (const [key, value] of Object.entries(customFieldsObject)) {
         for (const [key, value] of Object.entries(customFieldsObject)) {
             const config = customFieldConfig.find(c => c.name === key);
             const config = customFieldConfig.find(c => c.name === key);
             if (config) {
             if (config) {
@@ -90,12 +110,15 @@ export class ValidateCustomFieldsInterceptor implements NestInterceptor {
         }
         }
     }
     }
 
 
-    private getArgumentMap(operation: OperationDefinitionNode, schema: GraphQLSchema): { [inputName: string]: string; } {
+    private getArgumentMap(
+        operation: OperationDefinitionNode,
+        schema: GraphQLSchema,
+    ): { [inputName: string]: string } {
         const mutationType = schema.getMutationType();
         const mutationType = schema.getMutationType();
         if (!mutationType) {
         if (!mutationType) {
             return {};
             return {};
         }
         }
-        const map: { [inputName: string]: string; } = {};
+        const map: { [inputName: string]: string } = {};
 
 
         for (const selection of operation.selectionSet.selections) {
         for (const selection of operation.selectionSet.selections) {
             if (selection.kind === 'Field') {
             if (selection.kind === 'Field') {

+ 2 - 16
packages/core/src/api/resolvers/entity/payment-entity.resolver.ts

@@ -1,31 +1,17 @@
 import { Parent, ResolveProperty, Resolver } from '@nestjs/graphql';
 import { Parent, ResolveProperty, Resolver } from '@nestjs/graphql';
 
 
-import { Translated } from '../../../common/types/locale-types';
-import { Collection } from '../../../entity/collection/collection.entity';
-import { OrderItem } from '../../../entity/order-item/order-item.entity';
 import { Payment } from '../../../entity/payment/payment.entity';
 import { Payment } from '../../../entity/payment/payment.entity';
-import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
-import { Product } from '../../../entity/product/product.entity';
 import { Refund } from '../../../entity/refund/refund.entity';
 import { Refund } from '../../../entity/refund/refund.entity';
-import { CollectionService } from '../../../service/services/collection.service';
 import { OrderService } from '../../../service/services/order.service';
 import { OrderService } from '../../../service/services/order.service';
-import { ProductVariantService } from '../../../service/services/product-variant.service';
-import { ApiType } from '../../common/get-api-type';
 import { RequestContext } from '../../common/request-context';
 import { RequestContext } from '../../common/request-context';
-import { Api } from '../../decorators/api.decorator';
 import { Ctx } from '../../decorators/request-context.decorator';
 import { Ctx } from '../../decorators/request-context.decorator';
 
 
 @Resolver('Payment')
 @Resolver('Payment')
 export class PaymentEntityResolver {
 export class PaymentEntityResolver {
-    constructor(
-        private orderService: OrderService,
-    ) {}
+    constructor(private orderService: OrderService) {}
 
 
     @ResolveProperty()
     @ResolveProperty()
-    async refunds(
-        @Ctx() ctx: RequestContext,
-        @Parent() payment: Payment,
-    ): Promise<Refund[]> {
+    async refunds(@Ctx() ctx: RequestContext, @Parent() payment: Payment): Promise<Refund[]> {
         if (payment.refunds) {
         if (payment.refunds) {
             return payment.refunds;
             return payment.refunds;
         } else {
         } else {

+ 2 - 15
packages/core/src/api/resolvers/entity/refund-entity.resolver.ts

@@ -1,30 +1,17 @@
 import { Parent, ResolveProperty, Resolver } from '@nestjs/graphql';
 import { Parent, ResolveProperty, Resolver } from '@nestjs/graphql';
 
 
-import { Translated } from '../../../common/types/locale-types';
-import { Collection } from '../../../entity/collection/collection.entity';
 import { OrderItem } from '../../../entity/order-item/order-item.entity';
 import { OrderItem } from '../../../entity/order-item/order-item.entity';
-import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
-import { Product } from '../../../entity/product/product.entity';
 import { Refund } from '../../../entity/refund/refund.entity';
 import { Refund } from '../../../entity/refund/refund.entity';
-import { CollectionService } from '../../../service/services/collection.service';
 import { OrderService } from '../../../service/services/order.service';
 import { OrderService } from '../../../service/services/order.service';
-import { ProductVariantService } from '../../../service/services/product-variant.service';
-import { ApiType } from '../../common/get-api-type';
 import { RequestContext } from '../../common/request-context';
 import { RequestContext } from '../../common/request-context';
-import { Api } from '../../decorators/api.decorator';
 import { Ctx } from '../../decorators/request-context.decorator';
 import { Ctx } from '../../decorators/request-context.decorator';
 
 
 @Resolver('Refund')
 @Resolver('Refund')
 export class RefundEntityResolver {
 export class RefundEntityResolver {
-    constructor(
-        private orderService: OrderService,
-    ) {}
+    constructor(private orderService: OrderService) {}
 
 
     @ResolveProperty()
     @ResolveProperty()
-    async orderItems(
-        @Ctx() ctx: RequestContext,
-        @Parent() refund: Refund,
-    ): Promise<OrderItem[]> {
+    async orderItems(@Ctx() ctx: RequestContext, @Parent() refund: Refund): Promise<OrderItem[]> {
         if (refund.orderItems) {
         if (refund.orderItems) {
             return refund.orderItems;
             return refund.orderItems;
         } else {
         } else {

+ 5 - 13
packages/core/src/app.module.ts

@@ -1,4 +1,4 @@
-import { MiddlewareConsumer, Module, NestModule, OnApplicationShutdown, OnModuleDestroy } from '@nestjs/common';
+import { MiddlewareConsumer, Module, NestModule, OnApplicationShutdown } from '@nestjs/common';
 import cookieSession = require('cookie-session');
 import cookieSession = require('cookie-session');
 import { RequestHandler } from 'express';
 import { RequestHandler } from 'express';
 
 
@@ -8,13 +8,13 @@ import { ConfigService } from './config/config.service';
 import { Logger } from './config/logger/vendure-logger';
 import { Logger } from './config/logger/vendure-logger';
 import { I18nModule } from './i18n/i18n.module';
 import { I18nModule } from './i18n/i18n.module';
 import { I18nService } from './i18n/i18n.service';
 import { I18nService } from './i18n/i18n.service';
+import { PluginModule } from './plugin/plugin.module';
 
 
 @Module({
 @Module({
-    imports: [ConfigModule, I18nModule, ApiModule],
+    imports: [ConfigModule, I18nModule, ApiModule, PluginModule],
 })
 })
-export class AppModule implements NestModule, OnModuleDestroy, OnApplicationShutdown {
-    constructor(private configService: ConfigService,
-                private i18nService: I18nService) {}
+export class AppModule implements NestModule, OnApplicationShutdown {
+    constructor(private configService: ConfigService, private i18nService: I18nService) {}
 
 
     configure(consumer: MiddlewareConsumer) {
     configure(consumer: MiddlewareConsumer) {
         const { adminApiPath, shopApiPath } = this.configService;
         const { adminApiPath, shopApiPath } = this.configService;
@@ -39,14 +39,6 @@ export class AppModule implements NestModule, OnModuleDestroy, OnApplicationShut
         }
         }
     }
     }
 
 
-    async onModuleDestroy() {
-        for (const plugin of this.configService.plugins) {
-            if (plugin.onClose) {
-                await plugin.onClose();
-            }
-        }
-    }
-
     onApplicationShutdown(signal?: string) {
     onApplicationShutdown(signal?: string) {
         if (signal) {
         if (signal) {
             Logger.info('Received shutdown signal:' + signal);
             Logger.info('Received shutdown signal:' + signal);

+ 11 - 37
packages/core/src/bootstrap.ts

@@ -12,6 +12,12 @@ import { Logger } from './config/logger/vendure-logger';
 import { VendureConfig } from './config/vendure-config';
 import { VendureConfig } from './config/vendure-config';
 import { registerCustomEntityFields } from './entity/register-custom-entity-fields';
 import { registerCustomEntityFields } from './entity/register-custom-entity-fields';
 import { validateCustomFieldsConfig } from './entity/validate-custom-fields-config';
 import { validateCustomFieldsConfig } from './entity/validate-custom-fields-config';
+import {
+    getConfigurationFunction,
+    getEntitiesFromPlugins,
+    getPluginModules,
+    hasLifecycleMethod,
+} from './plugin/plugin-metadata';
 import { logProxyMiddlewares } from './plugin/plugin-utils';
 import { logProxyMiddlewares } from './plugin/plugin-utils';
 
 
 export type VendureBootstrapFunction = (config: VendureConfig) => Promise<INestApplication>;
 export type VendureBootstrapFunction = (config: VendureConfig) => Promise<INestApplication>;
@@ -48,7 +54,6 @@ export async function bootstrap(userConfig: Partial<VendureConfig>): Promise<INe
     });
     });
     DefaultLogger.restoreOriginalLogLevel();
     DefaultLogger.restoreOriginalLogLevel();
     app.useLogger(new Logger());
     app.useLogger(new Logger());
-    await runPluginOnBootstrapMethods(config, app);
     await app.listen(config.port, config.hostname);
     await app.listen(config.port, config.hostname);
     app.enableShutdownHooks();
     app.enableShutdownHooks();
     if (config.workerOptions.runInMainProcess) {
     if (config.workerOptions.runInMainProcess) {
@@ -126,7 +131,7 @@ export async function preBootstrapConfig(
     // base VendureEntity to be correctly configured with the primary key type
     // base VendureEntity to be correctly configured with the primary key type
     // specified in the EntityIdStrategy.
     // specified in the EntityIdStrategy.
     // tslint:disable-next-line:whitespace
     // tslint:disable-next-line:whitespace
-    const pluginEntities = getEntitiesFromPlugins(userConfig);
+    const pluginEntities = getEntitiesFromPlugins(userConfig.plugins);
     const entities = await getAllEntities(userConfig);
     const entities = await getAllEntities(userConfig);
     const { coreSubscribersMap } = await import('./entity/subscribers');
     const { coreSubscribersMap } = await import('./entity/subscribers');
     setConfig({
     setConfig({
@@ -154,40 +159,21 @@ async function runPluginConfigurations(
     config: ReadOnlyRequired<VendureConfig>,
     config: ReadOnlyRequired<VendureConfig>,
 ): Promise<ReadOnlyRequired<VendureConfig>> {
 ): Promise<ReadOnlyRequired<VendureConfig>> {
     for (const plugin of config.plugins) {
     for (const plugin of config.plugins) {
-        if (plugin.configure) {
-            config = (await plugin.configure(config)) as ReadOnlyRequired<VendureConfig>;
+        const configFn = getConfigurationFunction(plugin);
+        if (typeof configFn === 'function') {
+            config = await configFn(config);
         }
         }
     }
     }
     return config;
     return config;
 }
 }
 
 
-/**
- * Run the onBootstrap() method of any configured plugins.
- */
-export async function runPluginOnBootstrapMethods(
-    config: ReadOnlyRequired<VendureConfig>,
-    app: INestApplication,
-): Promise<void> {
-    function inject<T>(type: Type<T>): T {
-        return app.get(type);
-    }
-
-    for (const plugin of config.plugins) {
-        if (plugin.onBootstrap) {
-            await plugin.onBootstrap(inject);
-            const pluginName = plugin.constructor && plugin.constructor.name || '(anonymous plugin)';
-            Logger.verbose(`Bootstrapped plugin ${pluginName}`);
-        }
-    }
-}
-
 /**
 /**
  * Returns an array of core entities and any additional entities defined in plugins.
  * Returns an array of core entities and any additional entities defined in plugins.
  */
  */
 async function getAllEntities(userConfig: Partial<VendureConfig>): Promise<Array<Type<any>>> {
 async function getAllEntities(userConfig: Partial<VendureConfig>): Promise<Array<Type<any>>> {
     const { coreEntitiesMap } = await import('./entity/entities');
     const { coreEntitiesMap } = await import('./entity/entities');
     const coreEntities = Object.values(coreEntitiesMap) as Array<Type<any>>;
     const coreEntities = Object.values(coreEntitiesMap) as Array<Type<any>>;
-    const pluginEntities = getEntitiesFromPlugins(userConfig);
+    const pluginEntities = getEntitiesFromPlugins(userConfig.plugins);
 
 
     const allEntities: Array<Type<any>> = coreEntities;
     const allEntities: Array<Type<any>> = coreEntities;
 
 
@@ -203,18 +189,6 @@ async function getAllEntities(userConfig: Partial<VendureConfig>): Promise<Array
     return [...coreEntities, ...pluginEntities];
     return [...coreEntities, ...pluginEntities];
 }
 }
 
 
-/**
- * Collects all entities defined in plugins into a single array.
- */
-function getEntitiesFromPlugins(userConfig: Partial<VendureConfig>): Array<Type<any>> {
-    if (!userConfig.plugins) {
-        return [];
-    }
-    return userConfig.plugins
-        .map(p => (p.defineEntities ? p.defineEntities() : []))
-        .reduce((all, entities) => [...all, ...entities], []);
-}
-
 /**
 /**
  * Monkey-patches the app's .close() method to also close the worker microservice
  * Monkey-patches the app's .close() method to also close the worker microservice
  * instance too.
  * instance too.

+ 3 - 6
packages/core/src/config/config.service.ts

@@ -1,4 +1,4 @@
-import { Injectable } from '@nestjs/common';
+import { DynamicModule, Injectable, Type } from '@nestjs/common';
 import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface';
 import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface';
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { RequestHandler } from 'express';
 import { RequestHandler } from 'express';
@@ -22,7 +22,6 @@ import {
     VendureConfig,
     VendureConfig,
     WorkerOptions,
     WorkerOptions,
 } from './vendure-config';
 } from './vendure-config';
-import { VendurePlugin } from './vendure-plugin/vendure-plugin';
 
 
 @Injectable()
 @Injectable()
 export class ConfigService implements VendureConfig {
 export class ConfigService implements VendureConfig {
@@ -32,9 +31,7 @@ export class ConfigService implements VendureConfig {
         this.activeConfig = getConfig();
         this.activeConfig = getConfig();
         if (this.activeConfig.authOptions.disableAuth) {
         if (this.activeConfig.authOptions.disableAuth) {
             // tslint:disable-next-line
             // tslint:disable-next-line
-            Logger.warn(
-                'Auth has been disabled. This should never be the case for a production system!',
-            );
+            Logger.warn('Auth has been disabled. This should never be the case for a production system!');
         }
         }
     }
     }
 
 
@@ -114,7 +111,7 @@ export class ConfigService implements VendureConfig {
         return this.activeConfig.middleware;
         return this.activeConfig.middleware;
     }
     }
 
 
-    get plugins(): VendurePlugin[] {
+    get plugins(): Array<DynamicModule | Type<any>> {
         return this.activeConfig.plugins;
         return this.activeConfig.plugins;
     }
     }
 
 

+ 0 - 1
packages/core/src/config/index.ts

@@ -21,4 +21,3 @@ export * from './shipping-method/default-shipping-eligibility-checker';
 export * from './shipping-method/shipping-calculator';
 export * from './shipping-method/shipping-calculator';
 export * from './shipping-method/shipping-eligibility-checker';
 export * from './shipping-method/shipping-eligibility-checker';
 export * from './vendure-config';
 export * from './vendure-config';
-export * from './vendure-plugin/vendure-plugin';

+ 2 - 2
packages/core/src/config/vendure-config.ts

@@ -1,3 +1,4 @@
+import { DynamicModule, Type } from '@nestjs/common';
 import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface';
 import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface';
 import { ClientOptions, Transport } from '@nestjs/microservices';
 import { ClientOptions, Transport } from '@nestjs/microservices';
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { LanguageCode } from '@vendure/common/lib/generated-types';
@@ -23,7 +24,6 @@ import { ShippingCalculator } from './shipping-method/shipping-calculator';
 import { ShippingEligibilityChecker } from './shipping-method/shipping-eligibility-checker';
 import { ShippingEligibilityChecker } from './shipping-method/shipping-eligibility-checker';
 import { TaxCalculationStrategy } from './tax/tax-calculation-strategy';
 import { TaxCalculationStrategy } from './tax/tax-calculation-strategy';
 import { TaxZoneStrategy } from './tax/tax-zone-strategy';
 import { TaxZoneStrategy } from './tax/tax-zone-strategy';
-import { VendurePlugin } from './vendure-plugin/vendure-plugin';
 
 
 /**
 /**
  * @description
  * @description
@@ -489,7 +489,7 @@ export interface VendureConfig {
      *
      *
      * @default []
      * @default []
      */
      */
-    plugins?: VendurePlugin[];
+    plugins?: Array<DynamicModule | Type<any>>;
     /**
     /**
      * @description
      * @description
      * Which port the Vendure server should listen on.
      * Which port the Vendure server should listen on.

+ 0 - 108
packages/core/src/config/vendure-plugin/vendure-plugin.ts

@@ -1,108 +0,0 @@
-import { Provider } from '@nestjs/common';
-import { Type } from '@vendure/common/lib/shared-types';
-import { DocumentNode } from 'graphql';
-
-import { VendureConfig } from '../vendure-config';
-
-/**
- * @description
- * A function which allows any injectable provider to be injected into the `onBootstrap` method of a {@link VendurePlugin}.
- *
- * @docsCategory plugin
- */
-export type InjectorFn = <T>(type: Type<T>) => T;
-
-/**
- * @description
- * An object which allows a plugin to extend the Vendure GraphQL API.
- *
- * @docsCategory plugin
- * */
-export interface APIExtensionDefinition {
-    /**
-     * @description
-     * The schema extensions.
-     *
-     * @example
-     * ```TypeScript
-     * const schema = gql`extend type SearchReindexResponse {
-     *     timeTaken: Int!
-     *     indexedItemCount: Int!
-     * }`;
-     * ```
-     */
-    schema: DocumentNode;
-    /**
-     * @description
-     * An array of resolvers for the schema extensions. Should be defined as Nest GraphQL resolver
-     * classes, i.e. using the Nest `@Resolver()` decorator etc.
-     */
-    resolvers: Array<Type<any>>;
-}
-
-/**
- * @description
- * A VendurePlugin is a means of configuring and/or extending the functionality of the Vendure server. In its simplest form,
- * a plugin simply modifies the VendureConfig object. Although such configuration can be directly supplied to the bootstrap
- * function, using a plugin allows one to abstract away a set of related configuration.
- *
- * As well as configuring the app, a plugin may also extend the GraphQL schema by extending existing types or adding
- * entirely new types. Database entities and resolvers can also be defined to handle the extended GraphQL types.
- *
- * @docsCategory plugin
- */
-export interface VendurePlugin {
-    /**
-     * @description
-     * This method is called before the app bootstraps, and can modify the VendureConfig object and perform
-     * other (potentially async) tasks needed.
-     */
-    configure?(config: Required<VendureConfig>): Required<VendureConfig> | Promise<Required<VendureConfig>>;
-
-    /**
-     * @description
-     * This method is called after the app has bootstrapped. In this method, instances of services may be injected
-     * into the plugin. For example, the ProductService can be injected in order to enable operations on Product
-     * entities.
-     */
-    onBootstrap?(inject: InjectorFn): void | Promise<void>;
-
-    /**
-     * @description
-     * This method is called when the app closes. It can be used for any clean-up logic such as stopping servers.
-     */
-    onClose?(): void | Promise<void>;
-
-    /**
-     * @description
-     * The plugin may extend the default Vendure GraphQL shop api by implementing this method and providing extended
-     * schema definitions and any required resolvers.
-     */
-    extendShopAPI?(): APIExtensionDefinition;
-
-    /**
-     * @description
-     * The plugin may extend the default Vendure GraphQL admin api by implementing this method and providing extended
-     * schema definitions and any required resolvers.
-     */
-    extendAdminAPI?(): APIExtensionDefinition;
-
-    /**
-     * @description
-     * The plugin may define custom providers which can then be injected via the Nest DI container.
-     */
-    defineProviders?(): Provider[];
-
-    /**
-     * @description
-     * The plugin may define providers which are run in the Worker context, i.e. Nest microservice controllers.
-     */
-    defineWorkers?(): Array<Type<any>>;
-
-    /**
-     * @description
-     * The plugin may define custom database entities, which should be defined as classes annotated as per the
-     * TypeORM documentation.
-     */
-    defineEntities?(): Array<Type<any>>;
-}

+ 1 - 1
packages/core/src/data-import/data-import.module.ts

@@ -12,7 +12,7 @@ import { Populator } from './providers/populator/populator';
     // Important! PluginModule must be defined before ServiceModule
     // Important! PluginModule must be defined before ServiceModule
     // in order that overrides of Services (e.g. SearchService) are correctly
     // in order that overrides of Services (e.g. SearchService) are correctly
     // registered with the injector.
     // registered with the injector.
-    imports: [PluginModule.forRoot(), ServiceModule.forRoot(), ConfigModule],
+    imports: [PluginModule, ServiceModule.forRoot(), ConfigModule],
     exports: [ImportParser, Importer, Populator],
     exports: [ImportParser, Importer, Populator],
     providers: [ImportParser, Importer, Populator],
     providers: [ImportParser, Importer, Populator],
 })
 })

+ 1 - 0
packages/core/src/event-bus/index.ts

@@ -1,4 +1,5 @@
 export * from './event-bus';
 export * from './event-bus';
+export * from './event-bus.module';
 export * from './vendure-event';
 export * from './vendure-event';
 export * from './events/account-registration-event';
 export * from './events/account-registration-event';
 export * from './events/catalog-modification-event';
 export * from './events/catalog-modification-event';

+ 1 - 1
packages/core/src/plugin/default-search-plugin/constants.ts

@@ -1,4 +1,4 @@
-export const loggerCtx = 'DefaultSearchPlugin';
+export const workerLoggerCtx = 'DefaultSearchPlugin Worker';
 export enum Message {
 export enum Message {
     Reindex = 'Reindex',
     Reindex = 'Reindex',
     UpdateVariantsById = 'UpdateVariantsById',
     UpdateVariantsById = 'UpdateVariantsById',

+ 20 - 63
packages/core/src/plugin/default-search-plugin/default-search-plugin.ts

@@ -1,18 +1,14 @@
-import { Provider } from '@nestjs/common';
 import { SearchReindexResponse } from '@vendure/common/lib/generated-types';
 import { SearchReindexResponse } from '@vendure/common/lib/generated-types';
-import { CREATING_VENDURE_APP } from '@vendure/common/lib/shared-constants';
-import { Type } from '@vendure/common/lib/shared-types';
-import gql from 'graphql-tag';
 
 
 import { idsAreEqual } from '../../common/utils';
 import { idsAreEqual } from '../../common/utils';
-import { APIExtensionDefinition, VendurePlugin } from '../../config';
 import { ProductVariant } from '../../entity/product-variant/product-variant.entity';
 import { ProductVariant } from '../../entity/product-variant/product-variant.entity';
 import { Product } from '../../entity/product/product.entity';
 import { Product } from '../../entity/product/product.entity';
 import { EventBus } from '../../event-bus/event-bus';
 import { EventBus } from '../../event-bus/event-bus';
 import { CatalogModificationEvent } from '../../event-bus/events/catalog-modification-event';
 import { CatalogModificationEvent } from '../../event-bus/events/catalog-modification-event';
 import { CollectionModificationEvent } from '../../event-bus/events/collection-modification-event';
 import { CollectionModificationEvent } from '../../event-bus/events/collection-modification-event';
 import { TaxRateModificationEvent } from '../../event-bus/events/tax-rate-modification-event';
 import { TaxRateModificationEvent } from '../../event-bus/events/tax-rate-modification-event';
-import { SearchService } from '../../service/services/search.service';
+import { PluginCommonModule } from '../plugin-common.module';
+import { OnVendureBootstrap, VendurePlugin } from '../vendure-plugin';
 
 
 import { AdminFulltextSearchResolver, ShopFulltextSearchResolver } from './fulltext-search.resolver';
 import { AdminFulltextSearchResolver, ShopFulltextSearchResolver } from './fulltext-search.resolver';
 import { FulltextSearchService } from './fulltext-search.service';
 import { FulltextSearchService } from './fulltext-search.service';
@@ -54,72 +50,33 @@ export interface DefaultSearchReindexResponse extends SearchReindexResponse {
  *
  *
  * @docsCategory DefaultSearchPlugin
  * @docsCategory DefaultSearchPlugin
  */
  */
-export class DefaultSearchPlugin implements VendurePlugin {
+@VendurePlugin({
+    imports: [PluginCommonModule],
+    providers: [FulltextSearchService, SearchIndexService],
+    adminApiExtensions: { resolvers: [AdminFulltextSearchResolver] },
+    shopApiExtensions: { resolvers: [ShopFulltextSearchResolver] },
+    entities: [SearchIndexItem],
+    workers: [IndexerController],
+})
+export class DefaultSearchPlugin implements OnVendureBootstrap {
+    /** @internal */
+    constructor(private eventBus: EventBus, private searchIndexService: SearchIndexService) {}
 
 
     /** @internal */
     /** @internal */
-    async onBootstrap(inject: <T>(type: Type<T>) => T): Promise<void> {
-        const eventBus = inject(EventBus);
-        const searchIndexService = inject(SearchIndexService);
-        eventBus.subscribe(CatalogModificationEvent, event => {
+    async onVendureBootstrap() {
+        this.eventBus.subscribe(CatalogModificationEvent, event => {
             if (event.entity instanceof Product || event.entity instanceof ProductVariant) {
             if (event.entity instanceof Product || event.entity instanceof ProductVariant) {
-                return searchIndexService.updateProductOrVariant(event.ctx, event.entity).start();
+                return this.searchIndexService.updateProductOrVariant(event.ctx, event.entity).start();
             }
             }
         });
         });
-        eventBus.subscribe(CollectionModificationEvent, event => {
-            return searchIndexService.updateVariantsById(event.ctx, event.productVariantIds).start();
+        this.eventBus.subscribe(CollectionModificationEvent, event => {
+            return this.searchIndexService.updateVariantsById(event.ctx, event.productVariantIds).start();
         });
         });
-        eventBus.subscribe(TaxRateModificationEvent, event => {
+        this.eventBus.subscribe(TaxRateModificationEvent, event => {
             const defaultTaxZone = event.ctx.channel.defaultTaxZone;
             const defaultTaxZone = event.ctx.channel.defaultTaxZone;
             if (defaultTaxZone && idsAreEqual(defaultTaxZone.id, event.taxRate.zone.id)) {
             if (defaultTaxZone && idsAreEqual(defaultTaxZone.id, event.taxRate.zone.id)) {
-                return searchIndexService.reindex(event.ctx).start();
+                return this.searchIndexService.reindex(event.ctx).start();
             }
             }
         });
         });
     }
     }
-
-    /** @internal */
-    extendAdminAPI(): APIExtensionDefinition {
-        return {
-            resolvers: [AdminFulltextSearchResolver],
-            schema: gql`
-                extend type SearchReindexResponse {
-                    timeTaken: Int!
-                    indexedItemCount: Int!
-                }
-            `,
-        };
-    }
-
-    /** @internal */
-    extendShopAPI(): APIExtensionDefinition {
-        return {
-            resolvers: [ShopFulltextSearchResolver],
-            schema: gql`
-                extend type SearchReindexResponse {
-                    timeTaken: Int!
-                    indexedItemCount: Int!
-                }
-            `,
-        };
-    }
-
-    /** @internal */
-    defineEntities(): Array<Type<any>> {
-        return [SearchIndexItem];
-    }
-
-    /** @internal */
-    defineProviders(): Provider[] {
-        return [
-            FulltextSearchService,
-            SearchIndexService,
-            { provide: SearchService, useClass: FulltextSearchService },
-        ];
-    }
-
-    /** @internal */
-    defineWorkers(): Array<Type<any>> {
-        return [
-            IndexerController,
-        ];
-    }
 }
 }

+ 8 - 2
packages/core/src/plugin/default-search-plugin/fulltext-search.service.ts

@@ -24,7 +24,7 @@ import { SqliteSearchStrategy } from './search-strategy/sqlite-search-strategy';
  * SearchStrategy implementations for db-specific code.
  * SearchStrategy implementations for db-specific code.
  */
  */
 @Injectable()
 @Injectable()
-export class FulltextSearchService implements SearchService {
+export class FulltextSearchService {
     private searchStrategy: SearchStrategy;
     private searchStrategy: SearchStrategy;
     private readonly minTermLength = 2;
     private readonly minTermLength = 2;
 
 
@@ -35,14 +35,20 @@ export class FulltextSearchService implements SearchService {
         private facetValueService: FacetValueService,
         private facetValueService: FacetValueService,
         private productVariantService: ProductVariantService,
         private productVariantService: ProductVariantService,
         private searchIndexService: SearchIndexService,
         private searchIndexService: SearchIndexService,
+        private searchService: SearchService,
     ) {
     ) {
+        this.searchService.adopt(this);
         this.setSearchStrategy();
         this.setSearchStrategy();
     }
     }
 
 
     /**
     /**
      * Perform a fulltext search according to the provided input arguments.
      * Perform a fulltext search according to the provided input arguments.
      */
      */
-    async search(ctx: RequestContext, input: SearchInput, enabledOnly: boolean = false): Promise<Omit<SearchResponse, 'facetValues'>> {
+    async search(
+        ctx: RequestContext,
+        input: SearchInput,
+        enabledOnly: boolean = false,
+    ): Promise<Omit<SearchResponse, 'facetValues'>> {
         const items = await this.searchStrategy.getSearchResults(ctx, input, enabledOnly);
         const items = await this.searchStrategy.getSearchResults(ctx, input, enabledOnly);
         const totalItems = await this.searchStrategy.getTotalCount(ctx, input, enabledOnly);
         const totalItems = await this.searchStrategy.getTotalCount(ctx, input, enabledOnly);
         return {
         return {

+ 51 - 32
packages/core/src/plugin/default-search-plugin/indexer/indexer.controller.ts

@@ -17,7 +17,7 @@ import { translateDeep } from '../../../service/helpers/utils/translate-entity';
 import { ProductVariantService } from '../../../service/services/product-variant.service';
 import { ProductVariantService } from '../../../service/services/product-variant.service';
 import { TaxRateService } from '../../../service/services/tax-rate.service';
 import { TaxRateService } from '../../../service/services/tax-rate.service';
 import { AsyncQueue } from '../async-queue';
 import { AsyncQueue } from '../async-queue';
-import { loggerCtx, Message } from '../constants';
+import { Message, workerLoggerCtx } from '../constants';
 import { SearchIndexItem } from '../search-index-item.entity';
 import { SearchIndexItem } from '../search-index-item.entity';
 
 
 export const BATCH_SIZE = 1000;
 export const BATCH_SIZE = 1000;
@@ -57,17 +57,19 @@ export class IndexerController {
                 const timeStart = Date.now();
                 const timeStart = Date.now();
                 const qb = this.getSearchIndexQueryBuilder();
                 const qb = this.getSearchIndexQueryBuilder();
                 const count = await qb.where('variants__product.deletedAt IS NULL').getCount();
                 const count = await qb.where('variants__product.deletedAt IS NULL').getCount();
-                Logger.verbose(`Reindexing ${count} variants`, loggerCtx);
+                Logger.verbose(`Reindexing ${count} variants`, workerLoggerCtx);
                 const batches = Math.ceil(count / BATCH_SIZE);
                 const batches = Math.ceil(count / BATCH_SIZE);
 
 
                 // Ensure tax rates are up-to-date.
                 // Ensure tax rates are up-to-date.
                 await this.taxRateService.updateActiveTaxRates();
                 await this.taxRateService.updateActiveTaxRates();
 
 
-                await this.connection.getRepository(SearchIndexItem).delete({ languageCode: ctx.languageCode });
-                Logger.verbose('Deleted existing index items', loggerCtx);
+                await this.connection
+                    .getRepository(SearchIndexItem)
+                    .delete({ languageCode: ctx.languageCode });
+                Logger.verbose('Deleted existing index items', workerLoggerCtx);
 
 
                 for (let i = 0; i < batches; i++) {
                 for (let i = 0; i < batches; i++) {
-                    Logger.verbose(`Processing batch ${i + 1} of ${batches}`, loggerCtx);
+                    Logger.verbose(`Processing batch ${i + 1} of ${batches}`, workerLoggerCtx);
 
 
                     const variants = await qb
                     const variants = await qb
                         .where('variants__product.deletedAt IS NULL')
                         .where('variants__product.deletedAt IS NULL')
@@ -82,7 +84,7 @@ export class IndexerController {
                         duration: +new Date() - timeStart,
                         duration: +new Date() - timeStart,
                     });
                     });
                 }
                 }
-                Logger.verbose(`Completed reindexing!`);
+                Logger.verbose(`Completed reindexing`, workerLoggerCtx);
                 observer.next({
                 observer.next({
                     total: count,
                     total: count,
                     completed: count,
                     completed: count,
@@ -94,7 +96,13 @@ export class IndexerController {
     }
     }
 
 
     @MessagePattern(Message.UpdateVariantsById)
     @MessagePattern(Message.UpdateVariantsById)
-    updateVariantsById({ ctx: rawContext, ids }: { ctx: any, ids: ID[] }): Observable<ReindexMessageResponse> {
+    updateVariantsById({
+        ctx: rawContext,
+        ids,
+    }: {
+        ctx: any;
+        ids: ID[];
+    }): Observable<ReindexMessageResponse> {
         const ctx = RequestContext.fromObject(rawContext);
         const ctx = RequestContext.fromObject(rawContext);
 
 
         return new Observable(observer => {
         return new Observable(observer => {
@@ -109,9 +117,11 @@ export class IndexerController {
                         const end = begin + BATCH_SIZE;
                         const end = begin + BATCH_SIZE;
                         Logger.verbose(`Updating ids from index ${begin} to ${end}`);
                         Logger.verbose(`Updating ids from index ${begin} to ${end}`);
                         const batchIds = ids.slice(begin, end);
                         const batchIds = ids.slice(begin, end);
-                        const batch = await this.connection.getRepository(ProductVariant).findByIds(batchIds, {
-                            relations: variantRelations,
-                        });
+                        const batch = await this.connection
+                            .getRepository(ProductVariant)
+                            .findByIds(batchIds, {
+                                relations: variantRelations,
+                            });
                         const variants = this.hydrateVariants(ctx, batch);
                         const variants = this.hydrateVariants(ctx, batch);
                         await this.saveVariants(ctx, variants);
                         await this.saveVariants(ctx, variants);
                         observer.next({
                         observer.next({
@@ -136,7 +146,15 @@ export class IndexerController {
      * Updates the search index only for the affected entities.
      * Updates the search index only for the affected entities.
      */
      */
     @MessagePattern(Message.UpdateProductOrVariant)
     @MessagePattern(Message.UpdateProductOrVariant)
-    updateProductOrVariant({ ctx: rawContext, productId, variantId }: { ctx: any, productId?: ID, variantId?: ID }): Observable<boolean> {
+    updateProductOrVariant({
+        ctx: rawContext,
+        productId,
+        variantId,
+    }: {
+        ctx: any;
+        productId?: ID;
+        variantId?: ID;
+    }): Observable<boolean> {
         const ctx = RequestContext.fromObject(rawContext);
         const ctx = RequestContext.fromObject(rawContext);
         let updatedVariants: ProductVariant[] = [];
         let updatedVariants: ProductVariant[] = [];
         let removedVariantIds: ID[] = [];
         let removedVariantIds: ID[] = [];
@@ -155,7 +173,7 @@ export class IndexerController {
                                 relations: variantRelations,
                                 relations: variantRelations,
                             });
                             });
                         if (product.enabled === false) {
                         if (product.enabled === false) {
-                            updatedVariants.forEach(v => v.enabled = false);
+                            updatedVariants.forEach(v => (v.enabled = false));
                         }
                         }
                     }
                     }
                 }
                 }
@@ -167,7 +185,7 @@ export class IndexerController {
                     updatedVariants = [variant];
                     updatedVariants = [variant];
                 }
                 }
             }
             }
-            Logger.verbose(`Updating ${updatedVariants.length} variants`, loggerCtx);
+            Logger.verbose(`Updating ${updatedVariants.length} variants`, workerLoggerCtx);
             updatedVariants = this.hydrateVariants(ctx, updatedVariants);
             updatedVariants = this.hydrateVariants(ctx, updatedVariants);
             if (updatedVariants.length) {
             if (updatedVariants.length) {
                 await this.saveVariants(ctx, updatedVariants);
                 await this.saveVariants(ctx, updatedVariants);
@@ -198,25 +216,26 @@ export class IndexerController {
     }
     }
 
 
     private async saveVariants(ctx: RequestContext, variants: ProductVariant[]) {
     private async saveVariants(ctx: RequestContext, variants: ProductVariant[]) {
-        const items = variants.map((v: ProductVariant) =>
-            new SearchIndexItem({
-                sku: v.sku,
-                enabled: v.enabled,
-                slug: v.product.slug,
-                price: v.price,
-                priceWithTax: v.priceWithTax,
-                languageCode: ctx.languageCode,
-                productVariantId: v.id,
-                productId: v.product.id,
-                productName: v.product.name,
-                description: v.product.description,
-                productVariantName: v.name,
-                productPreview: v.product.featuredAsset ? v.product.featuredAsset.preview : '',
-                productVariantPreview: v.featuredAsset ? v.featuredAsset.preview : '',
-                facetIds: this.getFacetIds(v),
-                facetValueIds: this.getFacetValueIds(v),
-                collectionIds: v.collections.map(c => c.id.toString()),
-            }),
+        const items = variants.map(
+            (v: ProductVariant) =>
+                new SearchIndexItem({
+                    sku: v.sku,
+                    enabled: v.enabled,
+                    slug: v.product.slug,
+                    price: v.price,
+                    priceWithTax: v.priceWithTax,
+                    languageCode: ctx.languageCode,
+                    productVariantId: v.id,
+                    productId: v.product.id,
+                    productName: v.product.name,
+                    description: v.product.description,
+                    productVariantName: v.name,
+                    productPreview: v.product.featuredAsset ? v.product.featuredAsset.preview : '',
+                    productVariantPreview: v.featuredAsset ? v.featuredAsset.preview : '',
+                    facetIds: this.getFacetIds(v),
+                    facetValueIds: this.getFacetValueIds(v),
+                    collectionIds: v.collections.map(c => c.id.toString()),
+                }),
         );
         );
         await this.queue.push(() => this.connection.getRepository(SearchIndexItem).save(items));
         await this.queue.push(() => this.connection.getRepository(SearchIndexItem).save(items));
     }
     }

+ 53 - 0
packages/core/src/plugin/dynamic-plugin-api.module.ts

@@ -0,0 +1,53 @@
+import { DynamicModule } from '@nestjs/common';
+
+import { Type } from '../../../common/lib/shared-types';
+import { notNullOrUndefined } from '../../../common/lib/shared-utils';
+import { getConfig } from '../config/config-helpers';
+
+import { getModuleMetadata, graphQLResolversFor, isDynamicModule } from './plugin-metadata';
+
+const dynamicApiModuleClassMap: { [name: string]: Type<any> } = {};
+
+/**
+ * This function dynamically creates a Nest module to house any GraphQL resolvers defined by
+ * any configured plugins.
+ */
+export function createDynamicGraphQlModulesForPlugins(apiType: 'shop' | 'admin'): DynamicModule[] {
+    return getConfig()
+        .plugins.map(plugin => {
+            const pluginModule = isDynamicModule(plugin) ? plugin.module : plugin;
+            const resolvers = graphQLResolversFor(plugin, apiType) || [];
+
+            if (resolvers.length) {
+                const className = dynamicClassName(pluginModule, apiType);
+                dynamicApiModuleClassMap[className] = class {};
+                Object.defineProperty(dynamicApiModuleClassMap[className], 'name', { value: className });
+                const { imports, providers } = getModuleMetadata(pluginModule);
+                return {
+                    module: dynamicApiModuleClassMap[className],
+                    imports,
+                    providers: [...providers, ...resolvers],
+                };
+            }
+        })
+        .filter(notNullOrUndefined);
+}
+
+/**
+ * This function retrieves any dynamic modules which were created with createDynamicGraphQlModulesForPlugins.
+ */
+export function getDynamicGraphQlModulesForPlugins(apiType: 'shop' | 'admin'): Array<Type<any>> {
+    return getConfig()
+        .plugins.map(plugin => {
+            const pluginModule = isDynamicModule(plugin) ? plugin.module : plugin;
+            const resolvers = graphQLResolversFor(plugin, apiType) || [];
+
+            const className = dynamicClassName(pluginModule, apiType);
+            return dynamicApiModuleClassMap[className];
+        })
+        .filter(notNullOrUndefined);
+}
+
+function dynamicClassName(module: Type<any>, apiType: 'shop' | 'admin'): string {
+    return module.name + `Dynamic` + (apiType === 'shop' ? 'Shop' : 'Admin') + 'Module';
+}

+ 2 - 0
packages/core/src/plugin/index.ts

@@ -1,2 +1,4 @@
 export * from './default-search-plugin/default-search-plugin';
 export * from './default-search-plugin/default-search-plugin';
+export * from './vendure-plugin';
+export * from './plugin-common.module';
 export { createProxyHandler, ProxyOptions } from './plugin-utils';
 export { createProxyHandler, ProxyOptions } from './plugin-utils';

+ 41 - 0
packages/core/src/plugin/plugin-common.module.ts

@@ -0,0 +1,41 @@
+import { Module } from '@nestjs/common';
+import { ClientProxyFactory } from '@nestjs/microservices';
+
+import { ConfigModule } from '../config/config.module';
+import { ConfigService } from '../config/config.service';
+import { EventBusModule } from '../event-bus/event-bus.module';
+import { ServiceModule } from '../service/service.module';
+import { VENDURE_WORKER_CLIENT } from '../worker/constants';
+
+/**
+ * @description
+ * This module provides the common services, configuration, and event bus capabilities
+ * required by a typical plugin. It should be imported into plugins to avoid having to
+ * repeat the same boilerplate for each individual plugin.
+ *
+ * The PluginCommonModule exports:
+ *
+ * * EventBusModule, allowing the injection of the {@link EventBus} service.
+ * * ServiceModule allowing the injection of any of the various entity services such as ProductService, OrderService etc.
+ * * ConfigModule, allowing the injection of the ConfigService.
+ * * The `VENDURE_WORKER_CLIENT` token, allowing the injection of the Nest microservice ClientProxy.
+ * @docsCategory plugin
+ */
+@Module({
+    imports: [EventBusModule, ConfigModule, ServiceModule.forPlugin()],
+    providers: [
+        {
+            provide: VENDURE_WORKER_CLIENT,
+            useFactory: (configService: ConfigService) => {
+                return ClientProxyFactory.create({
+                    transport: configService.workerOptions.transport as any,
+                    options: configService.workerOptions.options as any,
+                });
+            },
+            inject: [ConfigService],
+        },
+        // TODO: Provide an injectable which defines whether in main or worker context
+    ],
+    exports: [EventBusModule, ConfigModule, ServiceModule.forPlugin(), VENDURE_WORKER_CLIENT],
+})
+export class PluginCommonModule {}

+ 90 - 0
packages/core/src/plugin/plugin-metadata.ts

@@ -0,0 +1,90 @@
+import { DynamicModule } from '@nestjs/common';
+import { METADATA } from '@nestjs/common/constants';
+import { Type } from '@vendure/common/lib/shared-types';
+
+import { notNullOrUndefined } from '../../../common/lib/shared-utils';
+
+import { APIExtensionDefinition, PluginConfigurationFn, PluginLifecycleMethods } from './vendure-plugin';
+
+export const PLUGIN_METADATA = {
+    CONFIGURATION: 'configuration',
+    SHOP_API_EXTENSIONS: 'shopApiExtensions',
+    ADMIN_API_EXTENSIONS: 'adminApiExtensions',
+    WORKERS: 'workers',
+    ENTITIES: 'entities',
+};
+
+export function getEntitiesFromPlugins(plugins?: Array<Type<any> | DynamicModule>): Array<Type<any>> {
+    if (!plugins) {
+        return [];
+    }
+    return plugins
+        .map(p => reflectMetadata(p, PLUGIN_METADATA.ENTITIES))
+        .reduce((all, entities) => [...all, ...(entities || [])], []);
+}
+
+export function getModuleMetadata(module: Type<any>) {
+    return {
+        controllers: Reflect.getMetadata(METADATA.CONTROLLERS, module) || [],
+        providers: Reflect.getMetadata(METADATA.PROVIDERS, module) || [],
+        imports: Reflect.getMetadata(METADATA.IMPORTS, module) || [],
+        exports: Reflect.getMetadata(METADATA.EXPORTS, module) || [],
+    };
+}
+
+export function getPluginAPIExtensions(
+    plugins: Array<Type<any> | DynamicModule>,
+    apiType: 'shop' | 'admin',
+): APIExtensionDefinition[] {
+    const extensions =
+        apiType === 'shop'
+            ? plugins.map(p => reflectMetadata(p, PLUGIN_METADATA.SHOP_API_EXTENSIONS))
+            : plugins.map(p => reflectMetadata(p, PLUGIN_METADATA.ADMIN_API_EXTENSIONS));
+
+    return extensions.filter(notNullOrUndefined);
+}
+
+export function getPluginModules(plugins: Array<Type<any> | DynamicModule>): Array<Type<any>> {
+    return plugins.map(p => (isDynamicModule(p) ? p.module : p));
+}
+
+export function hasLifecycleMethod<M extends keyof PluginLifecycleMethods>(
+    plugin: any,
+    lifecycleMethod: M,
+): plugin is { [key in M]: PluginLifecycleMethods[M] } {
+    return typeof (plugin as any)[lifecycleMethod] === 'function';
+}
+
+export function getWorkerControllers(plugin: Type<any> | DynamicModule) {
+    return reflectMetadata(plugin, PLUGIN_METADATA.WORKERS);
+}
+
+export function getConfigurationFunction(
+    plugin: Type<any> | DynamicModule,
+): PluginConfigurationFn | undefined {
+    return reflectMetadata(plugin, PLUGIN_METADATA.CONFIGURATION);
+}
+
+export function graphQLResolversFor(
+    plugin: Type<any> | DynamicModule,
+    apiType: 'shop' | 'admin',
+): Array<Type<any>> {
+    const apiExtensions =
+        apiType === 'shop'
+            ? reflectMetadata(plugin, PLUGIN_METADATA.SHOP_API_EXTENSIONS)
+            : reflectMetadata(plugin, PLUGIN_METADATA.ADMIN_API_EXTENSIONS);
+
+    return apiExtensions ? apiExtensions.resolvers : [];
+}
+
+function reflectMetadata(metatype: Type<any> | DynamicModule, metadataKey: string) {
+    if (isDynamicModule(metatype)) {
+        return Reflect.getMetadata(metadataKey, metatype.module);
+    } else {
+        return Reflect.getMetadata(metadataKey, metatype);
+    }
+}
+
+export function isDynamicModule(input: Type<any> | DynamicModule): input is DynamicModule {
+    return !!(input as DynamicModule).module;
+}

+ 23 - 31
packages/core/src/plugin/plugin-utils.ts

@@ -1,8 +1,7 @@
-import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 import { RequestHandler } from 'express';
 import { RequestHandler } from 'express';
 import proxy from 'http-proxy-middleware';
 import proxy from 'http-proxy-middleware';
 
 
-import { APIExtensionDefinition, Logger, VendureConfig, VendurePlugin } from '../config';
+import { Logger, VendureConfig } from '../config';
 
 
 /**
 /**
  * @description
  * @description
@@ -44,21 +43,24 @@ export interface ProxyOptions {
  *
  *
  * @example
  * @example
  * ```ts
  * ```ts
- * // Example usage in the `configure` method of a VendurePlugin.
+ * // Example usage in the `configuration` method of a VendurePlugin.
  * // Imagine that we have started a Node server on port 5678
  * // Imagine that we have started a Node server on port 5678
  * // running some service which we want to access via the `/my-plugin/`
  * // running some service which we want to access via the `/my-plugin/`
  * // route of the main Vendure server.
  * // route of the main Vendure server.
- * configure(config: Required<VendureConfig>): Required<VendureConfig> {
- *     config.middleware.push({
- *         handler: createProxyHandler({
- *             label: 'Admin UI',
- *             route: 'my-plugin',
- *             port: 5678,
- *         }),
- *         route: 'my-plugin',
- *     });
- *     return config;
- * }
+ * \@VendurePlugin({
+ *   configure: (config: Required<VendureConfig>) => {
+ *       config.middleware.push({
+ *           handler: createProxyHandler({
+ *               label: 'Admin UI',
+ *               route: 'my-plugin',
+ *               port: 5678,
+ *           }),
+ *           route: 'my-plugin',
+ *       });
+ *       return config;
+ *   }
+ * })
+ * export class MyPlugin {}
  * ```
  * ```
  *
  *
  * @docsCategory Plugin
  * @docsCategory Plugin
@@ -104,23 +106,13 @@ export function createProxyHandler(options: ProxyOptions): RequestHandler {
 export function logProxyMiddlewares(config: VendureConfig) {
 export function logProxyMiddlewares(config: VendureConfig) {
     for (const middleware of config.middleware || []) {
     for (const middleware of config.middleware || []) {
         if ((middleware.handler as any).proxyMiddleware) {
         if ((middleware.handler as any).proxyMiddleware) {
-            const { port, hostname, label, route } = (middleware.handler as any).proxyMiddleware as ProxyOptions;
-            Logger.info(`${label}: http://${config.hostname || 'localhost'}:${config.port}/${route}/ -> http://${hostname || 'localhost'}:${port}`);
+            const { port, hostname, label, route } = (middleware.handler as any)
+                .proxyMiddleware as ProxyOptions;
+            Logger.info(
+                `${label}: http://${config.hostname || 'localhost'}:${
+                    config.port
+                }/${route}/ -> http://${hostname || 'localhost'}:${port}`,
+            );
         }
         }
     }
     }
 }
 }
-
-/**
- * Given an array of VendurePlugins, returns a flattened array of all APIExtensionDefinitions.
- */
-export function getPluginAPIExtensions(
-    plugins: VendurePlugin[],
-    apiType: 'shop' | 'admin',
-): APIExtensionDefinition[] {
-    const extensions =
-        apiType === 'shop'
-            ? plugins.map(p => (p.extendShopAPI ? p.extendShopAPI() : undefined))
-            : plugins.map(p => (p.extendAdminAPI ? p.extendAdminAPI() : undefined));
-
-    return extensions.filter(notNullOrUndefined);
-}

+ 82 - 63
packages/core/src/plugin/plugin.module.ts

@@ -1,86 +1,105 @@
-import { DynamicModule, Module } from '@nestjs/common';
-import { ClientProxyFactory } from '@nestjs/microservices';
-import { Type } from '@vendure/common/lib/shared-types';
-import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
+import { DynamicModule, Inject, Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
+import { ModuleRef } from '@nestjs/core';
 
 
 import { getConfig } from '../config/config-helpers';
 import { getConfig } from '../config/config-helpers';
 import { ConfigModule } from '../config/config.module';
 import { ConfigModule } from '../config/config.module';
 import { ConfigService } from '../config/config.service';
 import { ConfigService } from '../config/config.service';
-import { EventBusModule } from '../event-bus/event-bus.module';
-import { ServiceModule } from '../service/service.module';
-import { VENDURE_WORKER_CLIENT } from '../worker/constants';
+import { Logger } from '../config/logger/vendure-logger';
 
 
-import { getPluginAPIExtensions } from './plugin-utils';
+import {
+    getPluginModules,
+    getWorkerControllers,
+    hasLifecycleMethod,
+    isDynamicModule,
+} from './plugin-metadata';
+import { PluginLifecycleMethods } from './vendure-plugin';
 
 
-const pluginProviders = getPluginProviders();
+export enum PluginProcessContext {
+    Main,
+    Worker,
+}
+
+const PLUGIN_PROCESS_CONTEXT = 'PLUGIN_PROCESS_CONTEXT';
 
 
 /**
 /**
  * This module collects and re-exports all providers defined in plugins so that they can be used in other
  * This module collects and re-exports all providers defined in plugins so that they can be used in other
- * modules.
+ * modules and in responsible for executing any lifecycle methods defined by the plugins.
  */
  */
 @Module({
 @Module({
-    imports: [
-        EventBusModule,
-        ConfigModule,
-    ],
-    providers: [
-        {
-            provide: VENDURE_WORKER_CLIENT,
-            useFactory: (configService: ConfigService) => {
-                return ClientProxyFactory.create({
-                    transport: configService.workerOptions.transport as any,
-                    options: configService.workerOptions.options as any,
-                });
-            },
-            inject: [ConfigService],
-        },
-        ...pluginProviders,
-    ],
-    exports: pluginProviders,
+    imports: [ConfigModule, ...getConfig().plugins],
+    providers: [{ provide: PLUGIN_PROCESS_CONTEXT, useValue: PluginProcessContext.Main }],
 })
 })
-export class PluginModule {
-    static shopApiResolvers(): Array<Type<any>> {
-        return graphQLResolversFor('shop');
-    }
-
-    static adminApiResolvers(): Array<Type<any>> {
-        return graphQLResolversFor('admin');
-    }
-
-    static forRoot(): DynamicModule {
+export class PluginModule implements OnModuleInit, OnModuleDestroy {
+    static forWorker(): DynamicModule {
         return {
         return {
             module: PluginModule,
             module: PluginModule,
-            imports: [ServiceModule.forRoot()],
+            providers: [{ provide: PLUGIN_PROCESS_CONTEXT, useValue: PluginProcessContext.Worker }],
+            imports: [...pluginsWithWorkerControllers()],
         };
         };
     }
     }
+    constructor(
+        @Inject(PLUGIN_PROCESS_CONTEXT) private processContext: PluginProcessContext,
+        private moduleRef: ModuleRef,
+        private configService: ConfigService,
+    ) {}
 
 
-    static forWorker(): DynamicModule {
-        return {
-            module: PluginModule,
-            imports: [ServiceModule.forWorker()],
-        };
+    async onModuleInit() {
+        if (this.processContext === PluginProcessContext.Main) {
+            this.runPluginLifecycleMethods('onVendureBootstrap', instance => {
+                const pluginName = instance.constructor.name || '(anonymous plugin)';
+                Logger.verbose(`Bootstrapped plugin ${pluginName}`);
+            });
+        }
+        if (this.processContext === PluginProcessContext.Worker) {
+            this.runPluginLifecycleMethods('onVendureWorkerBootstrap');
+        }
     }
     }
-}
 
 
-function getPluginProviders() {
-    const plugins = getConfig().plugins;
-    return plugins
-        .map(p => (p.defineProviders ? p.defineProviders() : undefined))
-        .filter(notNullOrUndefined)
-        .reduce((flattened, providers) => flattened.concat(providers), []);
-}
+    async onModuleDestroy() {
+        if (this.processContext === PluginProcessContext.Main) {
+            await this.runPluginLifecycleMethods('onVendureClose');
+        }
+        if (this.processContext === PluginProcessContext.Worker) {
+            this.runPluginLifecycleMethods('onVendureWorkerClose');
+        }
+    }
 
 
-function getWorkerControllers() {
-    const plugins = getConfig().plugins;
-    return plugins
-        .map(p => (p.defineWorkers ? p.defineWorkers() : undefined))
-        .filter(notNullOrUndefined)
-        .reduce((flattened, providers) => flattened.concat(providers), []);
+    private async runPluginLifecycleMethods(
+        lifecycleMethod: keyof PluginLifecycleMethods,
+        afterRun?: (instance: any) => void,
+    ) {
+        for (const plugin of getPluginModules(this.configService.plugins)) {
+            let instance: any;
+            try {
+                instance = this.moduleRef.get(plugin, { strict: false });
+            } catch (e) {
+                Logger.error(`Could not find ${plugin.name}`, undefined, e.stack);
+            }
+            if (instance) {
+                if (hasLifecycleMethod(instance, lifecycleMethod)) {
+                    await instance[lifecycleMethod]();
+                }
+                if (typeof afterRun === 'function') {
+                    afterRun(instance);
+                }
+            }
+        }
+    }
 }
 }
 
 
-function graphQLResolversFor(apiType: 'shop' | 'admin'): Array<Type<any>> {
-    const plugins = getConfig().plugins;
-    return getPluginAPIExtensions(plugins, apiType)
-        .map(extension => extension.resolvers)
-        .reduce((flattened, r) => [...flattened, ...r], []);
+function pluginsWithWorkerControllers(): DynamicModule[] {
+    return getConfig().plugins.map(plugin => {
+        const controllers = getWorkerControllers(plugin);
+        if (isDynamicModule(plugin)) {
+            return {
+                ...plugin,
+                controllers,
+            };
+        } else {
+            return {
+                module: plugin,
+                controllers,
+            };
+        }
+    });
 }
 }

+ 190 - 0
packages/core/src/plugin/vendure-plugin.ts

@@ -0,0 +1,190 @@
+import { Module } from '@nestjs/common';
+import { METADATA } from '@nestjs/common/constants';
+import { ModuleMetadata } from '@nestjs/common/interfaces';
+import { pick } from '@vendure/common/lib/pick';
+import { Type } from '@vendure/common/lib/shared-types';
+import { DocumentNode } from 'graphql';
+
+import { VendureConfig } from '../config/vendure-config';
+
+import { PLUGIN_METADATA } from './plugin-metadata';
+
+/**
+ * @description
+ * An object which allows a plugin to extend the Vendure GraphQL API.
+ *
+ * @docsCategory plugin
+ * */
+export interface APIExtensionDefinition {
+    /**
+     * @description
+     * Extensions to the schema.
+     *
+     * @example
+     * ```TypeScript
+     * const schema = gql`extend type SearchReindexResponse {
+     *     timeTaken: Int!
+     *     indexedItemCount: Int!
+     * }`;
+     * ```
+     */
+    schema?: DocumentNode;
+    /**
+     * @description
+     * An array of resolvers for the schema extensions. Should be defined as [Nestjs GraphQL resolver](https://docs.nestjs.com/graphql/resolvers-map)
+     * classes, i.e. using the Nest `\@Resolver()` decorator etc.
+     */
+    resolvers: Array<Type<any>>;
+}
+
+/**
+ * @description
+ * This method is called before the app bootstraps and should be used to perform any needed modifications to the {@link VendureConfig}.
+ *
+ * @docsCategory plugin
+ */
+export type PluginConfigurationFn = (
+    config: Required<VendureConfig>,
+) => Required<VendureConfig> | Promise<Required<VendureConfig>>;
+
+/**
+ * @description
+ * Defines the metadata of a Vendure plugin. This interface is an superset of the [Nestjs ModuleMetadata](https://docs.nestjs.com/modules)
+ * (which allows the definition of `imports`, `exports`, `providers` and `controllers`), which means
+ * that any Nestjs Module is a valid Vendure plugin. In addition, the VendurePluginMetadata allows the definition of
+ * extra properties specific to Vendure.
+ *
+ * @docsCategory plugin
+ */
+export interface VendurePluginMetadata extends ModuleMetadata {
+    /**
+     * @description
+     * A function which can modify the {@link VendureConfig} object before the server bootstraps.
+     */
+    configuration?: PluginConfigurationFn;
+    /**
+     * @description
+     * The plugin may extend the default Vendure GraphQL shop api by providing extended
+     * schema definitions and any required resolvers.
+     */
+    shopApiExtensions?: APIExtensionDefinition;
+    /**
+     * @description
+     * The plugin may extend the default Vendure GraphQL admin api by providing extended
+     * schema definitions and any required resolvers.
+     */
+    adminApiExtensions?: APIExtensionDefinition;
+    /**
+     * @description
+     * The plugin may define [Nestjs microservice controllers](https://docs.nestjs.com/microservices/basics#request-response)
+     * which are run in the Worker context.
+     */
+    workers?: Array<Type<any>>;
+    /**
+     * @description
+     * The plugin may define custom [TypeORM database entities](https://typeorm.io/#/entities).
+     */
+    entities?: Array<Type<any>>;
+}
+
+/**
+ * @description
+ * The VendurePlugin decorator is a means of configuring and/or extending the functionality of the Vendure server. A Vendure plugin is
+ * a [Nestjs Module](https://docs.nestjs.com/modules), with optional additional metadata defining things like extensions to the GraphQL API, custom
+ * configuration or new database entities.
+ *
+ * As well as configuring the app, a plugin may also extend the GraphQL schema by extending existing types or adding
+ * entirely new types. Database entities and resolvers can also be defined to handle the extended GraphQL types.
+ *
+ * @example
+ * ```TypeScript
+ * import { Controller, Get } from '\@nestjs/common';
+ * import { Ctx, PluginCommonModule, ProductService, RequestContext, VendurePlugin } from '\@vendure/core';
+ *
+ * \@Controller('products')
+ * export class ProductsController {
+ *     constructor(private productService: ProductService) {}
+ *
+ *     \@Get()
+ *     findAll(\@Ctx() ctx: RequestContext) {
+ *         return this.productService.findAll(ctx);
+ *     }
+ * }
+ *
+ *
+ * //A simple plugin which adds a REST endpoint for querying products.
+ * \@VendurePlugin({
+ *     imports: [PluginCommonModule],
+ *     controllers: [ProductsController],
+ * })
+ * export class RestPlugin {}
+ * ```
+ *
+ * @docsCategory plugin
+ */
+export function VendurePlugin(pluginMetadata: VendurePluginMetadata): ClassDecorator {
+    // tslint:disable-next-line:ban-types
+    return (target: Function) => {
+        for (const metadataProperty of Object.values(PLUGIN_METADATA)) {
+            const property = metadataProperty as keyof VendurePluginMetadata;
+            if (pluginMetadata[property] != null) {
+                Reflect.defineMetadata(property, pluginMetadata[property], target);
+            }
+        }
+        const nestModuleMetadata = pick(pluginMetadata, Object.values(METADATA) as any);
+        Module(nestModuleMetadata)(target);
+    };
+}
+
+/**
+ * @description
+ * A plugin which implements this interface can define logic to run when the Vendure server is initialized.
+ *
+ * For example, this could be used to call out to an external API or to set up {@link EventBus} listeners.
+ *
+ * @docsCategory plugin
+ */
+export interface OnVendureBootstrap {
+    onVendureBootstrap(): void | Promise<void>;
+}
+
+/**
+ * @description
+ * A plugin which implements this interface can define logic to run when the Vendure worker is initialized.
+ *
+ * For example, this could be used to start or connect to a server or databased used by the worker.
+ *
+ * @docsCategory plugin
+ */
+export interface OnVendureWorkerBootstrap {
+    onVendureWorkerBootstrap(): void | Promise<void>;
+}
+
+/**
+ * @description
+ * A plugin which implements this interface can define logic to run before Vendure server is closed.
+ *
+ * For example, this could be used to clean up any processes started by the {@link OnVendureBootstrap} method.
+ *
+ * @docsCategory plugin
+ */
+export interface OnVendureClose {
+    onVendureClose(): void | Promise<void>;
+}
+
+/**
+ * @description
+ * A plugin which implements this interface can define logic to run before Vendure worker is closed.
+ *
+ * For example, this could be used to close any open connections to external services.
+ *
+ * @docsCategory plugin
+ */
+export interface OnVendureWorkerClose {
+    onVendureWorkerClose(): void | Promise<void>;
+}
+
+export type PluginLifecycleMethods = OnVendureBootstrap &
+    OnVendureWorkerBootstrap &
+    OnVendureClose &
+    OnVendureWorkerClose;

+ 26 - 13
packages/core/src/service/service.module.ts

@@ -81,17 +81,12 @@ let defaultTypeOrmModule: DynamicModule;
 let workerTypeOrmModule: DynamicModule;
 let workerTypeOrmModule: DynamicModule;
 
 
 /**
 /**
- * The ServiceModule is responsible for the service layer, i.e. accessing the database
- * and implementing the main business logic of the application.
- *
- * The exported providers are used in the ApiModule, which is responsible for parsing requests
- * into a format suitable for the service layer logic.
+ * The ServiceCoreModule is imported internally by the ServiceModule. It is arranged in this way so that
+ * there is only a single instance of this module being instantiated, and thus the lifecycle hooks will
+ * only run a single time.
  */
  */
 @Module({
 @Module({
-    imports: [
-        ConfigModule,
-        EventBusModule,
-    ],
+    imports: [ConfigModule, EventBusModule],
     providers: [
     providers: [
         ...exportedProviders,
         ...exportedProviders,
         PasswordCiper,
         PasswordCiper,
@@ -109,7 +104,7 @@ let workerTypeOrmModule: DynamicModule;
     ],
     ],
     exports: exportedProviders,
     exports: exportedProviders,
 })
 })
-export class ServiceModule implements OnModuleInit {
+export class ServiceCoreModule implements OnModuleInit {
     constructor(
     constructor(
         private channelService: ChannelService,
         private channelService: ChannelService,
         private roleService: RoleService,
         private roleService: RoleService,
@@ -135,7 +130,20 @@ export class ServiceModule implements OnModuleInit {
         await this.shippingMethodService.initShippingMethods();
         await this.shippingMethodService.initShippingMethods();
         await this.paymentMethodService.initPaymentMethods();
         await this.paymentMethodService.initPaymentMethods();
     }
     }
+}
 
 
+/**
+ * The ServiceModule is responsible for the service layer, i.e. accessing the database
+ * and implementing the main business logic of the application.
+ *
+ * The exported providers are used in the ApiModule, which is responsible for parsing requests
+ * into a format suitable for the service layer logic.
+ */
+@Module({
+    imports: [ServiceCoreModule],
+    exports: [ServiceCoreModule],
+})
+export class ServiceModule {
     static forRoot(): DynamicModule {
     static forRoot(): DynamicModule {
         if (!defaultTypeOrmModule) {
         if (!defaultTypeOrmModule) {
             defaultTypeOrmModule = TypeOrmModule.forRootAsync({
             defaultTypeOrmModule = TypeOrmModule.forRootAsync({
@@ -148,9 +156,7 @@ export class ServiceModule implements OnModuleInit {
         }
         }
         return {
         return {
             module: ServiceModule,
             module: ServiceModule,
-            imports: [
-                defaultTypeOrmModule,
-            ],
+            imports: [defaultTypeOrmModule],
         };
         };
     }
     }
 
 
@@ -183,4 +189,11 @@ export class ServiceModule implements OnModuleInit {
             imports: [workerTypeOrmModule],
             imports: [workerTypeOrmModule],
         };
         };
     }
     }
+
+    static forPlugin(): DynamicModule {
+        return {
+            module: ServiceModule,
+            imports: [TypeOrmModule.forFeature()],
+        };
+    }
 }
 }

+ 13 - 0
packages/core/src/service/services/search.service.ts

@@ -17,7 +17,20 @@ import { Logger } from '../../config/logger/vendure-logger';
  */
  */
 @Injectable()
 @Injectable()
 export class SearchService {
 export class SearchService {
+    private override: Pick<SearchService, 'reindex'> | undefined;
+
+    /**
+     * Adopt a concrete search service implementation to pass through the
+     * calls to.
+     */
+    adopt(override: Pick<SearchService, 'reindex'>) {
+        this.override = override;
+    }
+
     async reindex(ctx: RequestContext): Promise<JobInfo> {
     async reindex(ctx: RequestContext): Promise<JobInfo> {
+        if (this.override) {
+            return this.override.reindex(ctx);
+        }
         if (!process.env.CI) {
         if (!process.env.CI) {
             Logger.warn(`The SearchService should be overridden by an appropriate search plugin.`);
             Logger.warn(`The SearchService should be overridden by an appropriate search plugin.`);
         }
         }

+ 1 - 16
packages/core/src/worker/worker.module.ts

@@ -1,8 +1,6 @@
 import { Module, OnApplicationShutdown, OnModuleDestroy } from '@nestjs/common';
 import { Module, OnApplicationShutdown, OnModuleDestroy } from '@nestjs/common';
 import { APP_INTERCEPTOR } from '@nestjs/core';
 import { APP_INTERCEPTOR } from '@nestjs/core';
-import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 
 
-import { getConfig } from '../config/config-helpers';
 import { ConfigModule } from '../config/config.module';
 import { ConfigModule } from '../config/config.module';
 import { Logger } from '../config/logger/vendure-logger';
 import { Logger } from '../config/logger/vendure-logger';
 import { PluginModule } from '../plugin/plugin.module';
 import { PluginModule } from '../plugin/plugin.module';
@@ -12,11 +10,7 @@ import { MessageInterceptor } from './message-interceptor';
 import { WorkerMonitor } from './worker-monitor';
 import { WorkerMonitor } from './worker-monitor';
 
 
 @Module({
 @Module({
-    imports: [
-        ConfigModule,
-        ServiceModule.forWorker(),
-        PluginModule.forWorker(),
-    ],
+    imports: [ConfigModule, ServiceModule.forWorker(), PluginModule.forWorker()],
     providers: [
     providers: [
         WorkerMonitor,
         WorkerMonitor,
         {
         {
@@ -24,7 +18,6 @@ import { WorkerMonitor } from './worker-monitor';
             useClass: MessageInterceptor,
             useClass: MessageInterceptor,
         },
         },
     ],
     ],
-    controllers: getWorkerControllers(),
 })
 })
 export class WorkerModule implements OnModuleDestroy, OnApplicationShutdown {
 export class WorkerModule implements OnModuleDestroy, OnApplicationShutdown {
     constructor(private monitor: WorkerMonitor) {}
     constructor(private monitor: WorkerMonitor) {}
@@ -38,11 +31,3 @@ export class WorkerModule implements OnModuleDestroy, OnApplicationShutdown {
         }
         }
     }
     }
 }
 }
-
-function getWorkerControllers() {
-    const plugins = getConfig().plugins;
-    return plugins
-        .map(p => (p.defineWorkers ? p.defineWorkers() : undefined))
-        .filter(notNullOrUndefined)
-        .reduce((flattened, controllers) => flattened.concat(controllers), []);
-}

+ 23 - 15
packages/create/src/create-vendure-app.ts

@@ -29,7 +29,8 @@ let projectName: string | undefined;
 
 
 // Set the environment variable which can then be used to
 // Set the environment variable which can then be used to
 // conditionally modify behaviour of core or plugins.
 // conditionally modify behaviour of core or plugins.
-const createEnvVar: import('@vendure/common/src/shared-constants').CREATING_VENDURE_APP = 'CREATING_VENDURE_APP';
+const createEnvVar: import('@vendure/common/src/shared-constants').CREATING_VENDURE_APP =
+    'CREATING_VENDURE_APP';
 process.env[createEnvVar] = 'true';
 process.env[createEnvVar] = 'true';
 
 
 program
 program
@@ -41,7 +42,7 @@ program
     })
     })
     .option(
     .option(
         '--log-level <logLevel>',
         '--log-level <logLevel>',
-        'Log level, either \'silent\', \'info\', or \'verbose\'',
+        "Log level, either 'silent', 'info', or 'verbose'",
         /^(silent|info|verbose)$/i,
         /^(silent|info|verbose)$/i,
         'silent',
         'silent',
     )
     )
@@ -62,7 +63,14 @@ async function createApp(name: string | undefined, useNpm: boolean, logLevel: Lo
 
 
     const root = path.resolve(name);
     const root = path.resolve(name);
     const appName = path.basename(root);
     const appName = path.basename(root);
-    const { dbType, usingTs, configSource, indexSource, indexWorkerSource, populateProducts } = await gatherUserResponses(root);
+    const {
+        dbType,
+        usingTs,
+        configSource,
+        indexSource,
+        indexWorkerSource,
+        populateProducts,
+    } = await gatherUserResponses(root);
 
 
     const useYarn = useNpm ? false : shouldUseYarn();
     const useYarn = useNpm ? false : shouldUseYarn();
     const originalDirectory = process.cwd();
     const originalDirectory = process.cwd();
@@ -78,7 +86,7 @@ async function createApp(name: string | undefined, useNpm: boolean, logLevel: Lo
         scripts: {
         scripts: {
             'run:server': usingTs ? 'ts-node index.ts' : 'node index.js',
             'run:server': usingTs ? 'ts-node index.ts' : 'node index.js',
             'run:worker': usingTs ? 'ts-node index-worker.ts' : 'node index-worker.js',
             'run:worker': usingTs ? 'ts-node index-worker.ts' : 'node index-worker.js',
-            'start': useYarn ? 'concurrently yarn:run:*' : 'concurrently npm:run:*',
+            start: useYarn ? 'concurrently yarn:run:*' : 'concurrently npm:run:*',
         },
         },
     };
     };
 
 
@@ -107,7 +115,8 @@ async function createApp(name: string | undefined, useNpm: boolean, logLevel: Lo
                                 return installPackages(root, useYarn, devDependencies, true, logLevel);
                                 return installPackages(root, useYarn, devDependencies, true, logLevel);
                             }
                             }
                         })
                         })
-                        .then(() => subscriber.complete());
+                        .then(() => subscriber.complete())
+                        .catch(err => subscriber.error(err));
                 });
                 });
             }) as any,
             }) as any,
         },
         },
@@ -146,7 +155,8 @@ async function createApp(name: string | undefined, useNpm: boolean, logLevel: Lo
                         .then(() => {
                         .then(() => {
                             subscriber.next(`Copied email templates`);
                             subscriber.next(`Copied email templates`);
                             subscriber.complete();
                             subscriber.complete();
-                        });
+                        })
+                        .catch(err => subscriber.error(err));
                 });
                 });
             },
             },
         },
         },
@@ -158,14 +168,12 @@ async function createApp(name: string | undefined, useNpm: boolean, logLevel: Lo
                         // register ts-node so that the config file can be loaded
                         // register ts-node so that the config file can be loaded
                         require(path.join(root, 'node_modules/ts-node')).register();
                         require(path.join(root, 'node_modules/ts-node')).register();
                     }
                     }
-                    const { populate } = await import(path.join(
-                        root,
-                        'node_modules/@vendure/core/dist/cli/populate',
-                    ));
-                    const { bootstrap } = await import(path.join(
-                        root,
-                        'node_modules/@vendure/core/dist/bootstrap',
-                    ));
+                    const { populate } = await import(
+                        path.join(root, 'node_modules/@vendure/core/dist/cli/populate')
+                    );
+                    const { bootstrap } = await import(
+                        path.join(root, 'node_modules/@vendure/core/dist/bootstrap')
+                    );
                     const { config } = await import(ctx.configFile);
                     const { config } = await import(ctx.configFile);
                     const assetsDir = path.join(__dirname, '../assets');
                     const assetsDir = path.join(__dirname, '../assets');
 
 
@@ -211,7 +219,7 @@ async function createApp(name: string | undefined, useNpm: boolean, logLevel: Lo
     try {
     try {
         await tasks.run();
         await tasks.run();
     } catch (e) {
     } catch (e) {
-        console.error(chalk.red(e));
+        console.error(chalk.red(JSON.stringify(e)));
         process.exit(1);
         process.exit(1);
     }
     }
     const startCommand = useYarn ? 'yarn start' : 'npm run start';
     const startCommand = useYarn ? 'yarn start' : 'npm run start';

+ 39 - 43
packages/create/src/helpers.ts

@@ -18,11 +18,7 @@ import { DbType, LogLevel } from './types';
 export function isSafeToCreateProjectIn(root: string, name: string) {
 export function isSafeToCreateProjectIn(root: string, name: string) {
     // These files should be allowed to remain on a failed install,
     // These files should be allowed to remain on a failed install,
     // but then silently removed during the next create.
     // but then silently removed during the next create.
-    const errorLogFilePatterns = [
-        'npm-debug.log',
-        'yarn-error.log',
-        'yarn-debug.log',
-    ];
+    const errorLogFilePatterns = ['npm-debug.log', 'yarn-error.log', 'yarn-debug.log'];
     const validFiles = [
     const validFiles = [
         '.DS_Store',
         '.DS_Store',
         'Thumbs.db',
         'Thumbs.db',
@@ -49,22 +45,16 @@ export function isSafeToCreateProjectIn(root: string, name: string) {
         // IntelliJ IDEA creates module files before CRA is launched
         // IntelliJ IDEA creates module files before CRA is launched
         .filter(file => !/\.iml$/.test(file))
         .filter(file => !/\.iml$/.test(file))
         // Don't treat log files from previous installation as conflicts
         // Don't treat log files from previous installation as conflicts
-        .filter(
-            file => !errorLogFilePatterns.some(pattern => file.indexOf(pattern) === 0),
-        );
+        .filter(file => !errorLogFilePatterns.some(pattern => file.indexOf(pattern) === 0));
 
 
     if (conflicts.length > 0) {
     if (conflicts.length > 0) {
-        console.log(
-            `The directory ${chalk.green(name)} contains files that could conflict:`,
-        );
+        console.log(`The directory ${chalk.green(name)} contains files that could conflict:`);
         console.log();
         console.log();
         for (const file of conflicts) {
         for (const file of conflicts) {
             console.log(`  ${file}`);
             console.log(`  ${file}`);
         }
         }
         console.log();
         console.log();
-        console.log(
-            'Either try using a new directory name, or remove the files listed above.',
-        );
+        console.log('Either try using a new directory name, or remove the files listed above.');
 
 
         return false;
         return false;
     }
     }
@@ -87,8 +77,8 @@ export function checkNodeVersion(requiredVersion: string) {
         console.error(
         console.error(
             chalk.red(
             chalk.red(
                 'You are running Node %s.\n' +
                 'You are running Node %s.\n' +
-                'Vendure requires Node %s or higher. \n' +
-                'Please update your version of Node.',
+                    'Vendure requires Node %s or higher. \n' +
+                    'Please update your version of Node.',
             ),
             ),
             process.version,
             process.version,
             requiredVersion,
             requiredVersion,
@@ -142,26 +132,24 @@ export function checkThatNpmCanReadCwd() {
     console.error(
     console.error(
         chalk.red(
         chalk.red(
             `Could not start an npm process in the right directory.\n\n` +
             `Could not start an npm process in the right directory.\n\n` +
-            `The current directory is: ${chalk.bold(cwd)}\n` +
-            `However, a newly started npm process runs in: ${chalk.bold(
-                npmCWD,
-            )}\n\n` +
-            `This is probably caused by a misconfigured system terminal shell.`,
+                `The current directory is: ${chalk.bold(cwd)}\n` +
+                `However, a newly started npm process runs in: ${chalk.bold(npmCWD)}\n\n` +
+                `This is probably caused by a misconfigured system terminal shell.`,
         ),
         ),
     );
     );
     if (process.platform === 'win32') {
     if (process.platform === 'win32') {
         console.error(
         console.error(
             chalk.red(`On Windows, this can usually be fixed by running:\n\n`) +
             chalk.red(`On Windows, this can usually be fixed by running:\n\n`) +
-            `  ${chalk.cyan(
-                'reg',
-            )} delete "HKCU\\Software\\Microsoft\\Command Processor" /v AutoRun /f\n` +
-            `  ${chalk.cyan(
-                'reg',
-            )} delete "HKLM\\Software\\Microsoft\\Command Processor" /v AutoRun /f\n\n` +
-            chalk.red(`Try to run the above two lines in the terminal.\n`) +
-            chalk.red(
-                `To learn more about this problem, read: https://blogs.msdn.microsoft.com/oldnewthing/20071121-00/?p=24433/`,
-            ),
+                `  ${chalk.cyan(
+                    'reg',
+                )} delete "HKCU\\Software\\Microsoft\\Command Processor" /v AutoRun /f\n` +
+                `  ${chalk.cyan(
+                    'reg',
+                )} delete "HKLM\\Software\\Microsoft\\Command Processor" /v AutoRun /f\n\n` +
+                chalk.red(`Try to run the above two lines in the terminal.\n`) +
+                chalk.red(
+                    `To learn more about this problem, read: https://blogs.msdn.microsoft.com/oldnewthing/20071121-00/?p=24433/`,
+                ),
         );
         );
     }
     }
     return false;
     return false;
@@ -171,7 +159,13 @@ export function checkThatNpmCanReadCwd() {
  * Install packages via npm or yarn.
  * Install packages via npm or yarn.
  * Based on the install function from https://github.com/facebook/create-react-app
  * Based on the install function from https://github.com/facebook/create-react-app
  */
  */
-export function installPackages(root: string, useYarn: boolean, dependencies: string[], isDev: boolean, logLevel: LogLevel): Promise<void> {
+export function installPackages(
+    root: string,
+    useYarn: boolean,
+    dependencies: string[],
+    isDev: boolean,
+    logLevel: LogLevel,
+): Promise<void> {
     return new Promise((resolve, reject) => {
     return new Promise((resolve, reject) => {
         let command: string;
         let command: string;
         let args: string[];
         let args: string[];
@@ -192,13 +186,7 @@ export function installPackages(root: string, useYarn: boolean, dependencies: st
             args.push(root);
             args.push(root);
         } else {
         } else {
             command = 'npm';
             command = 'npm';
-            args = [
-                'install',
-                '--save',
-                '--save-exact',
-                '--loglevel',
-                'error',
-            ].concat(dependencies);
+            args = ['install', '--save', '--save-exact', '--loglevel', 'error'].concat(dependencies);
             if (isDev) {
             if (isDev) {
                 args.push('--save-dev');
                 args.push('--save-dev');
             }
             }
@@ -212,6 +200,8 @@ export function installPackages(root: string, useYarn: boolean, dependencies: st
         child.on('close', code => {
         child.on('close', code => {
             if (code !== 0) {
             if (code !== 0) {
                 reject({
                 reject({
+                    message:
+                        'An error occurred when installing dependencies. Try running with `--log-level info` to diagnose.',
                     command: `${command} ${args.join(' ')}`,
                     command: `${command} ${args.join(' ')}`,
                 });
                 });
                 return;
                 return;
@@ -221,7 +211,10 @@ export function installPackages(root: string, useYarn: boolean, dependencies: st
     });
     });
 }
 }
 
 
-export function getDependencies(usingTs: boolean, dbType: DbType): { dependencies: string[]; devDependencies: string[]; } {
+export function getDependencies(
+    usingTs: boolean,
+    dbType: DbType,
+): { dependencies: string[]; devDependencies: string[] } {
     const dependencies = [
     const dependencies = [
         '@vendure/core',
         '@vendure/core',
         '@vendure/email-plugin',
         '@vendure/email-plugin',
@@ -234,7 +227,7 @@ export function getDependencies(usingTs: boolean, dbType: DbType): { dependencie
         devDependencies.push('ts-node');
         devDependencies.push('ts-node');
     }
     }
 
 
-    return {dependencies, devDependencies};
+    return { dependencies, devDependencies };
 }
 }
 
 
 /**
 /**
@@ -333,8 +326,11 @@ async function checkPostgresDbExists(options: any, root: string): Promise<true>
 }
 }
 
 
 function throwConnectionError(err: any) {
 function throwConnectionError(err: any) {
-    throw new Error(`Could not connect to the database. ` +
-        `Please check the connection settings in your Vendure config.\n[${err.message || err.toString()}]`);
+    throw new Error(
+        `Could not connect to the database. ` +
+            `Please check the connection settings in your Vendure config.\n[${err.message ||
+                err.toString()}]`,
+    );
 }
 }
 
 
 function throwDatabaseDoesNotExist(name: string) {
 function throwDatabaseDoesNotExist(name: string) {

+ 4 - 4
packages/create/templates/vendure-config.hbs

@@ -56,12 +56,13 @@ const path = require('path');
     },
     },
     customFields: {},
     customFields: {},
     plugins: [
     plugins: [
-        new AssetServerPlugin({
+        AssetServerPlugin.init({
             route: 'assets',
             route: 'assets',
             assetUploadDir: path.join(__dirname, 'vendure/assets'),
             assetUploadDir: path.join(__dirname, 'vendure/assets'),
             port: 3001,
             port: 3001,
         }),
         }),
-        new EmailPlugin({
+        DefaultSearchPlugin,
+        EmailPlugin.init({
             devMode: true,
             devMode: true,
             outputPath: path.join(__dirname, 'vendure/email/test-emails'),
             outputPath: path.join(__dirname, 'vendure/email/test-emails'),
             mailboxPort: 3003,
             mailboxPort: 3003,
@@ -74,8 +75,7 @@ const path = require('path');
                 changeEmailAddressUrl: 'http://localhost:8080/verify-email-address-change'
                 changeEmailAddressUrl: 'http://localhost:8080/verify-email-address-change'
             },
             },
         }),
         }),
-        new DefaultSearchPlugin(),
-        new AdminUiPlugin({ port: 3002 }),
+        AdminUiPlugin.init({ port: 3002 }),
     ],
     ],
 };
 };
 {{#if isTs}}
 {{#if isTs}}

+ 16 - 7
packages/dev-server/dev-config.ts

@@ -2,12 +2,20 @@
 import { AdminUiPlugin } from '@vendure/admin-ui-plugin';
 import { AdminUiPlugin } from '@vendure/admin-ui-plugin';
 import { AssetServerPlugin } from '@vendure/asset-server-plugin';
 import { AssetServerPlugin } from '@vendure/asset-server-plugin';
 import { ADMIN_API_PATH, API_PORT, SHOP_API_PATH } from '@vendure/common/lib/shared-constants';
 import { ADMIN_API_PATH, API_PORT, SHOP_API_PATH } from '@vendure/common/lib/shared-constants';
-import { DefaultLogger, DefaultSearchPlugin, examplePaymentHandler, LogLevel, VendureConfig } from '@vendure/core';
+import {
+    DefaultLogger,
+    DefaultSearchPlugin,
+    examplePaymentHandler,
+    LogLevel,
+    VendureConfig,
+} from '@vendure/core';
 import { ElasticsearchPlugin } from '@vendure/elasticsearch-plugin';
 import { ElasticsearchPlugin } from '@vendure/elasticsearch-plugin';
 import { defaultEmailHandlers, EmailPlugin } from '@vendure/email-plugin';
 import { defaultEmailHandlers, EmailPlugin } from '@vendure/email-plugin';
 import path from 'path';
 import path from 'path';
 import { ConnectionOptions } from 'typeorm';
 import { ConnectionOptions } from 'typeorm';
 
 
+import { RestPlugin } from './rest-plugin';
+
 /**
 /**
  * Config settings used during development
  * Config settings used during development
  */
  */
@@ -39,18 +47,19 @@ export const devConfig: VendureConfig = {
         importAssetsDir: path.join(__dirname, 'import-assets'),
         importAssetsDir: path.join(__dirname, 'import-assets'),
     },
     },
     plugins: [
     plugins: [
-        new AssetServerPlugin({
+        AssetServerPlugin.init({
             route: 'assets',
             route: 'assets',
             assetUploadDir: path.join(__dirname, 'assets'),
             assetUploadDir: path.join(__dirname, 'assets'),
             // assetUploadDir: path.join(__dirname, '../../../../kbart/artsupplies-vendure/server/vendure/assets'),
             // assetUploadDir: path.join(__dirname, '../../../../kbart/artsupplies-vendure/server/vendure/assets'),
             port: 5002,
             port: 5002,
         }),
         }),
-        new DefaultSearchPlugin(),
-        // new ElasticsearchPlugin({
+        DefaultSearchPlugin,
+        RestPlugin,
+        // ElasticsearchPlugin.init({
         //     host: 'http://192.168.99.100',
         //     host: 'http://192.168.99.100',
         //     port: 9200,
         //     port: 9200,
         // }),
         // }),
-        new EmailPlugin({
+        EmailPlugin.init({
             devMode: true,
             devMode: true,
             handlers: defaultEmailHandlers,
             handlers: defaultEmailHandlers,
             templatePath: path.join(__dirname, '../email-plugin/templates'),
             templatePath: path.join(__dirname, '../email-plugin/templates'),
@@ -62,7 +71,7 @@ export const devConfig: VendureConfig = {
                 changeEmailAddressUrl: 'http://localhost:4201/change-email-address',
                 changeEmailAddressUrl: 'http://localhost:4201/change-email-address',
             },
             },
         }),
         }),
-        new AdminUiPlugin({
+        AdminUiPlugin.init({
             port: 5001,
             port: 5001,
         }),
         }),
     ],
     ],
@@ -86,7 +95,7 @@ function getDbConfig(): ConnectionOptions {
             console.log('Using sqlite connection');
             console.log('Using sqlite connection');
             return {
             return {
                 type: 'sqlite',
                 type: 'sqlite',
-                database:  path.join(__dirname, 'vendure.sqlite'),
+                database: path.join(__dirname, 'vendure.sqlite'),
             };
             };
         case 'sqljs':
         case 'sqljs':
             console.log('Using sql.js connection');
             console.log('Using sql.js connection');

+ 21 - 0
packages/dev-server/rest-plugin.ts

@@ -0,0 +1,21 @@
+import { Controller, Get } from '@nestjs/common';
+import { Ctx, PluginCommonModule, ProductService, RequestContext, VendurePlugin } from '@vendure/core';
+
+@Controller('products')
+export class ProductsController {
+    constructor(private productService: ProductService) {}
+
+    @Get()
+    findAll(@Ctx() ctx: RequestContext) {
+        return this.productService.findAll(ctx);
+    }
+}
+
+/**
+ * A proof-of-concept plugin which adds a REST endpoint for querying products.
+ */
+@VendurePlugin({
+    imports: [PluginCommonModule],
+    controllers: [ProductsController],
+})
+export class RestPlugin {}

+ 19 - 14
packages/elasticsearch-plugin/src/elasticsearch.service.ts

@@ -18,12 +18,16 @@ import { ElasticsearchOptions } from './plugin';
 import { ProductIndexItem, SearchHit, SearchResponseBody, VariantIndexItem } from './types';
 import { ProductIndexItem, SearchHit, SearchResponseBody, VariantIndexItem } from './types';
 
 
 @Injectable()
 @Injectable()
-export class ElasticsearchService implements SearchService {
-
-    constructor(@Inject(ELASTIC_SEARCH_OPTIONS) private options: Required<ElasticsearchOptions>,
-                @Inject(ELASTIC_SEARCH_CLIENT) private client: Client,
-                private elasticsearchIndexService: ElasticsearchIndexService,
-                private facetValueService: FacetValueService) {}
+export class ElasticsearchService {
+    constructor(
+        @Inject(ELASTIC_SEARCH_OPTIONS) private options: Required<ElasticsearchOptions>,
+        @Inject(ELASTIC_SEARCH_CLIENT) private client: Client,
+        private searchService: SearchService,
+        private elasticsearchIndexService: ElasticsearchIndexService,
+        private facetValueService: FacetValueService,
+    ) {
+        searchService.adopt(this);
+    }
 
 
     checkConnection() {
     checkConnection() {
         return this.client.ping({}, { requestTimeout: 1000 });
         return this.client.ping({}, { requestTimeout: 1000 });
@@ -51,12 +55,16 @@ export class ElasticsearchService implements SearchService {
     /**
     /**
      * Perform a fulltext search according to the provided input arguments.
      * Perform a fulltext search according to the provided input arguments.
      */
      */
-    async search(ctx: RequestContext, input: SearchInput, enabledOnly: boolean = false): Promise<Omit<SearchResponse, 'facetValues'>> {
+    async search(
+        ctx: RequestContext,
+        input: SearchInput,
+        enabledOnly: boolean = false,
+    ): Promise<Omit<SearchResponse, 'facetValues'>> {
         const { indexPrefix } = this.options;
         const { indexPrefix } = this.options;
         const { groupByProduct } = input;
         const { groupByProduct } = input;
         const elasticSearchBody = buildElasticBody(input, enabledOnly);
         const elasticSearchBody = buildElasticBody(input, enabledOnly);
         if (groupByProduct) {
         if (groupByProduct) {
-            const { body }: { body: SearchResponseBody<ProductIndexItem>; } = await this.client.search({
+            const { body }: { body: SearchResponseBody<ProductIndexItem> } = await this.client.search({
                 index: indexPrefix + PRODUCT_INDEX_NAME,
                 index: indexPrefix + PRODUCT_INDEX_NAME,
                 type: PRODUCT_INDEX_TYPE,
                 type: PRODUCT_INDEX_TYPE,
                 body: elasticSearchBody,
                 body: elasticSearchBody,
@@ -66,7 +74,7 @@ export class ElasticsearchService implements SearchService {
                 totalItems: body.hits.total.value,
                 totalItems: body.hits.total.value,
             };
             };
         } else {
         } else {
-            const {body}: { body: SearchResponseBody<VariantIndexItem>; } = await this.client.search({
+            const { body }: { body: SearchResponseBody<VariantIndexItem> } = await this.client.search({
                 index: indexPrefix + VARIANT_INDEX_NAME,
                 index: indexPrefix + VARIANT_INDEX_NAME,
                 type: VARIANT_INDEX_TYPE,
                 type: VARIANT_INDEX_TYPE,
                 body: elasticSearchBody,
                 body: elasticSearchBody,
@@ -95,7 +103,7 @@ export class ElasticsearchService implements SearchService {
                 terms: { field: 'facetValueIds.keyword' },
                 terms: { field: 'facetValueIds.keyword' },
             },
             },
         };
         };
-        const { body }: { body: SearchResponseBody<VariantIndexItem>; } = await this.client.search({
+        const { body }: { body: SearchResponseBody<VariantIndexItem> } = await this.client.search({
             index: indexPrefix + VARIANT_INDEX_NAME,
             index: indexPrefix + VARIANT_INDEX_NAME,
             type: VARIANT_INDEX_TYPE,
             type: VARIANT_INDEX_TYPE,
             body: elasticSearchBody,
             body: elasticSearchBody,
@@ -103,10 +111,7 @@ export class ElasticsearchService implements SearchService {
 
 
         const buckets = body.aggregations ? body.aggregations.facetValue.buckets : [];
         const buckets = body.aggregations ? body.aggregations.facetValue.buckets : [];
 
 
-        const facetValues = await this.facetValueService.findByIds(
-            buckets.map(b => b.key),
-            ctx.languageCode,
-        );
+        const facetValues = await this.facetValueService.findByIds(buckets.map(b => b.key), ctx.languageCode);
         return facetValues.map((facetValue, index) => {
         return facetValues.map((facetValue, index) => {
             return {
             return {
                 facetValue,
                 facetValue,

+ 47 - 71
packages/elasticsearch-plugin/src/plugin.ts

@@ -1,12 +1,13 @@
 import { Client } from '@elastic/elasticsearch';
 import { Client } from '@elastic/elasticsearch';
-import { Provider } from '@nestjs/common';
 import {
 import {
-    APIExtensionDefinition,
     CatalogModificationEvent,
     CatalogModificationEvent,
     CollectionModificationEvent,
     CollectionModificationEvent,
     EventBus,
     EventBus,
     idsAreEqual,
     idsAreEqual,
     Logger,
     Logger,
+    OnVendureBootstrap,
+    OnVendureClose,
+    PluginCommonModule,
     Product,
     Product,
     ProductVariant,
     ProductVariant,
     SearchService,
     SearchService,
@@ -14,7 +15,6 @@ import {
     Type,
     Type,
     VendurePlugin,
     VendurePlugin,
 } from '@vendure/core';
 } from '@vendure/core';
-import { gql } from 'apollo-server-core';
 
 
 import { ELASTIC_SEARCH_CLIENT, ELASTIC_SEARCH_OPTIONS, loggerCtx } from './constants';
 import { ELASTIC_SEARCH_CLIENT, ELASTIC_SEARCH_OPTIONS, loggerCtx } from './constants';
 import { ElasticsearchIndexService } from './elasticsearch-index.service';
 import { ElasticsearchIndexService } from './elasticsearch-index.service';
@@ -60,7 +60,7 @@ export interface ElasticsearchOptions {
 /**
 /**
  * @description
  * @description
  * This plugin allows your product search to be powered by [Elasticsearch](https://github.com/elastic/elasticsearch) - a powerful Open Source search
  * This plugin allows your product search to be powered by [Elasticsearch](https://github.com/elastic/elasticsearch) - a powerful Open Source search
- * engine. This is a drop-in replacement for the {@link DefaultSearchPlugin}.
+ * engine. This is a drop-in replacement for the DefaultSearchPlugin.
  *
  *
  * ## Installation
  * ## Installation
  *
  *
@@ -77,7 +77,7 @@ export interface ElasticsearchOptions {
  * const config: VendureConfig = {
  * const config: VendureConfig = {
  *   // Add an instance of the plugin to the plugins array
  *   // Add an instance of the plugin to the plugins array
  *   plugins: [
  *   plugins: [
- *     new ElasticsearchPlugin({
+ *     ElasticsearchPlugin.init({
  *       host: 'http://localhost',
  *       host: 'http://localhost',
  *       port: 9200,
  *       port: 9200,
  *     }),
  *     }),
@@ -87,25 +87,46 @@ export interface ElasticsearchOptions {
  *
  *
  * @docsCategory ElasticsearchPlugin
  * @docsCategory ElasticsearchPlugin
  */
  */
-export class ElasticsearchPlugin implements VendurePlugin {
-    private readonly options: Required<ElasticsearchOptions>;
-    private readonly client: Client;
+@VendurePlugin({
+    imports: [PluginCommonModule],
+    providers: [
+        ElasticsearchIndexService,
+        ElasticsearchService,
+        { provide: ELASTIC_SEARCH_OPTIONS, useFactory: () => ElasticsearchPlugin.options },
+        { provide: ELASTIC_SEARCH_CLIENT, useFactory: () => ElasticsearchPlugin.client },
+    ],
+    adminApiExtensions: { resolvers: [AdminElasticSearchResolver] },
+    shopApiExtensions: { resolvers: [ShopElasticSearchResolver] },
+    workers: [ElasticsearchIndexerController],
+})
+export class ElasticsearchPlugin implements OnVendureBootstrap, OnVendureClose {
+    private static options: Required<ElasticsearchOptions>;
+    private static client: Client;
 
 
-    constructor(options: ElasticsearchOptions) {
-        this.options = { indexPrefix: 'vendure-', batchSize: 2000, ...options };
+    /** @internal */
+    constructor(
+        private eventBus: EventBus,
+        private elasticsearchService: ElasticsearchService,
+        private elasticsearchIndexService: ElasticsearchIndexService,
+    ) {}
+
+    /**
+     * Set the plugin options.
+     */
+    static init(options: ElasticsearchOptions): Type<ElasticsearchPlugin> {
         const { host, port } = options;
         const { host, port } = options;
+        this.options = { indexPrefix: 'vendure-', batchSize: 2000, ...options };
         this.client = new Client({
         this.client = new Client({
             node: `${host}:${port}`,
             node: `${host}:${port}`,
         });
         });
+        return ElasticsearchPlugin;
     }
     }
 
 
     /** @internal */
     /** @internal */
-    async onBootstrap(inject: <T>(type: Type<T>) => T): Promise<void> {
-        const elasticsearchService = inject(ElasticsearchService);
-        const elasticsearchIndexService = inject(ElasticsearchIndexService);
-        const { host, port } = this.options;
+    async onVendureBootstrap(): Promise<void> {
+        const { host, port } = ElasticsearchPlugin.options;
         try {
         try {
-            const pingResult = await elasticsearchService.checkConnection();
+            const pingResult = await this.elasticsearchService.checkConnection();
         } catch (e) {
         } catch (e) {
             Logger.error(`Could not connect to Elasticsearch instance at "${host}:${port}"`, loggerCtx);
             Logger.error(`Could not connect to Elasticsearch instance at "${host}:${port}"`, loggerCtx);
             Logger.error(JSON.stringify(e), loggerCtx);
             Logger.error(JSON.stringify(e), loggerCtx);
@@ -113,73 +134,28 @@ export class ElasticsearchPlugin implements VendurePlugin {
         }
         }
         Logger.info(`Sucessfully connected to Elasticsearch instance at "${host}:${port}"`, loggerCtx);
         Logger.info(`Sucessfully connected to Elasticsearch instance at "${host}:${port}"`, loggerCtx);
 
 
-        await elasticsearchService.createIndicesIfNotExists();
+        await this.elasticsearchService.createIndicesIfNotExists();
 
 
-        const eventBus = inject(EventBus);
-        eventBus.subscribe(CatalogModificationEvent, event => {
+        this.eventBus.subscribe(CatalogModificationEvent, event => {
             if (event.entity instanceof Product || event.entity instanceof ProductVariant) {
             if (event.entity instanceof Product || event.entity instanceof ProductVariant) {
-                return elasticsearchIndexService.updateProductOrVariant(event.ctx, event.entity).start();
+                return this.elasticsearchIndexService.updateProductOrVariant(event.ctx, event.entity).start();
             }
             }
         });
         });
-        eventBus.subscribe(CollectionModificationEvent, event => {
-            return elasticsearchIndexService.updateVariantsById(event.ctx, event.productVariantIds).start();
+        this.eventBus.subscribe(CollectionModificationEvent, event => {
+            return this.elasticsearchIndexService
+                .updateVariantsById(event.ctx, event.productVariantIds)
+                .start();
         });
         });
-        eventBus.subscribe(TaxRateModificationEvent, event => {
+        this.eventBus.subscribe(TaxRateModificationEvent, event => {
             const defaultTaxZone = event.ctx.channel.defaultTaxZone;
             const defaultTaxZone = event.ctx.channel.defaultTaxZone;
             if (defaultTaxZone && idsAreEqual(defaultTaxZone.id, event.taxRate.zone.id)) {
             if (defaultTaxZone && idsAreEqual(defaultTaxZone.id, event.taxRate.zone.id)) {
-                return elasticsearchService.reindex(event.ctx);
+                return this.elasticsearchService.reindex(event.ctx);
             }
             }
         });
         });
     }
     }
 
 
     /** @internal */
     /** @internal */
-    onClose() {
-        return this.client.close();
-    }
-
-    /** @internal */
-    extendAdminAPI(): APIExtensionDefinition {
-        return {
-            resolvers: [AdminElasticSearchResolver],
-            schema: gql`
-                extend type SearchReindexResponse {
-                    timeTaken: Int!
-                    indexedItemCount: Int!
-                }
-            `,
-        };
-    }
-
-    /** @internal */
-    extendShopAPI(): APIExtensionDefinition {
-        return {
-            resolvers: [ShopElasticSearchResolver],
-            schema: gql`
-                extend type SearchReindexResponse {
-                    timeTaken: Int!
-                    indexedItemCount: Int!
-                }
-            `,
-        };
-    }
-
-    /** @internal */
-    defineProviders(): Provider[] {
-        return [
-            { provide: ElasticsearchIndexService, useClass: ElasticsearchIndexService },
-            AdminElasticSearchResolver,
-            ShopElasticSearchResolver,
-            ElasticsearchService,
-            { provide: SearchService, useClass: ElasticsearchService },
-            { provide: ELASTIC_SEARCH_OPTIONS, useFactory: () => this.options },
-            { provide: ELASTIC_SEARCH_CLIENT, useFactory: () => this.client },
-        ];
-    }
-
-    /** @internal */
-    defineWorkers(): Array<Type<any>> {
-        return [
-            ElasticsearchIndexerController,
-        ];
+    onVendureClose() {
+        return ElasticsearchPlugin.client.close();
     }
     }
 }
 }

+ 53 - 34
packages/email-plugin/src/plugin.spec.ts

@@ -1,4 +1,5 @@
 /* tslint:disable:no-non-null-assertion */
 /* tslint:disable:no-non-null-assertion */
+import { Test, TestingModule } from '@nestjs/testing';
 import path from 'path';
 import path from 'path';
 
 
 import { LanguageCode } from '../../common/lib/generated-types';
 import { LanguageCode } from '../../common/lib/generated-types';
@@ -18,32 +19,32 @@ describe('EmailPlugin', () => {
     let eventBus: EventBus;
     let eventBus: EventBus;
     let onSend: jest.Mock;
     let onSend: jest.Mock;
 
 
-    async function initPluginWithHandlers(handlers: Array<EmailEventHandler<string, any>>, options?: Partial<EmailPluginOptions>) {
-        eventBus = new EventBus();
+    async function initPluginWithHandlers(
+        handlers: Array<EmailEventHandler<string, any>>,
+        options?: Partial<EmailPluginOptions>,
+    ) {
         onSend = jest.fn();
         onSend = jest.fn();
-        plugin = new EmailPlugin({
-            templatePath: path.join(__dirname, '../test-templates'),
-            transport: {
-                type: 'testing',
-                onSend,
-            },
-            handlers,
-            ...options,
-        });
-
-        const inject = (token: any): any => {
-            if (token === EventBus) {
-                return eventBus;
-            } else {
-                throw new Error(`Was not expecting to inject the token ${token}`);
-            }
-        };
-
-        await plugin.onBootstrap(inject);
+        const module = await Test.createTestingModule({
+            imports: [
+                EmailPlugin.init({
+                    templatePath: path.join(__dirname, '../test-templates'),
+                    transport: {
+                        type: 'testing',
+                        onSend,
+                    },
+                    handlers,
+                    ...options,
+                }),
+            ],
+        }).compile();
+
+        plugin = module.get(EmailPlugin);
+        eventBus = module.get(EventBus);
+        await plugin.onVendureBootstrap();
+        return module;
     }
     }
 
 
     describe('event filtering', () => {
     describe('event filtering', () => {
-
         const ctx = {
         const ctx = {
             channel: { code: DEFAULT_CHANNEL_CODE },
             channel: { code: DEFAULT_CHANNEL_CODE },
             languageCode: LanguageCode.en,
             languageCode: LanguageCode.en,
@@ -56,7 +57,7 @@ describe('EmailPlugin', () => {
                 .setRecipient(() => 'test@test.com')
                 .setRecipient(() => 'test@test.com')
                 .setSubject('test subject');
                 .setSubject('test subject');
 
 
-            await initPluginWithHandlers([handler]);
+            const module = await initPluginWithHandlers([handler]);
 
 
             eventBus.publish(new MockEvent(ctx, false));
             eventBus.publish(new MockEvent(ctx, false));
             await pause();
             await pause();
@@ -65,6 +66,7 @@ describe('EmailPlugin', () => {
             eventBus.publish(new MockEvent(ctx, true));
             eventBus.publish(new MockEvent(ctx, true));
             await pause();
             await pause();
             expect(onSend).toHaveBeenCalledTimes(1);
             expect(onSend).toHaveBeenCalledTimes(1);
+            await module.close();
         });
         });
 
 
         it('multiple filters', async () => {
         it('multiple filters', async () => {
@@ -75,7 +77,7 @@ describe('EmailPlugin', () => {
                 .setRecipient(() => 'test@test.com')
                 .setRecipient(() => 'test@test.com')
                 .setSubject('test subject');
                 .setSubject('test subject');
 
 
-            await initPluginWithHandlers([handler]);
+            const module = await initPluginWithHandlers([handler]);
 
 
             eventBus.publish(new MockEvent(ctx, true));
             eventBus.publish(new MockEvent(ctx, true));
             await pause();
             await pause();
@@ -84,6 +86,7 @@ describe('EmailPlugin', () => {
             eventBus.publish(new MockEvent({ ...ctx, user: 'joe' }, true));
             eventBus.publish(new MockEvent({ ...ctx, user: 'joe' }, true));
             await pause();
             await pause();
             expect(onSend).toHaveBeenCalledTimes(1);
             expect(onSend).toHaveBeenCalledTimes(1);
+            await module.close();
         });
         });
     });
     });
 
 
@@ -100,11 +103,12 @@ describe('EmailPlugin', () => {
                 .setSubject('Hello {{ subjectVar }}')
                 .setSubject('Hello {{ subjectVar }}')
                 .setTemplateVars(event => ({ subjectVar: 'foo' }));
                 .setTemplateVars(event => ({ subjectVar: 'foo' }));
 
 
-            await initPluginWithHandlers([handler]);
+            const module = await initPluginWithHandlers([handler]);
 
 
             eventBus.publish(new MockEvent(ctx, true));
             eventBus.publish(new MockEvent(ctx, true));
             await pause();
             await pause();
             expect(onSend.mock.calls[0][0].subject).toBe('Hello foo');
             expect(onSend.mock.calls[0][0].subject).toBe('Hello foo');
+            await module.close();
         });
         });
 
 
         it('interpolates body', async () => {
         it('interpolates body', async () => {
@@ -114,11 +118,12 @@ describe('EmailPlugin', () => {
                 .setSubject('Hello')
                 .setSubject('Hello')
                 .setTemplateVars(event => ({ testVar: 'this is the test var' }));
                 .setTemplateVars(event => ({ testVar: 'this is the test var' }));
 
 
-            await initPluginWithHandlers([handler]);
+            const module = await initPluginWithHandlers([handler]);
 
 
             eventBus.publish(new MockEvent(ctx, true));
             eventBus.publish(new MockEvent(ctx, true));
             await pause();
             await pause();
             expect(onSend.mock.calls[0][0].body).toContain('this is the test var');
             expect(onSend.mock.calls[0][0].body).toContain('this is the test var');
+            await module.close();
         });
         });
 
 
         it('interpolates globalTemplateVars', async () => {
         it('interpolates globalTemplateVars', async () => {
@@ -127,11 +132,14 @@ describe('EmailPlugin', () => {
                 .setRecipient(() => 'test@test.com')
                 .setRecipient(() => 'test@test.com')
                 .setSubject('Hello {{ globalVar }}');
                 .setSubject('Hello {{ globalVar }}');
 
 
-            await initPluginWithHandlers([handler], { globalTemplateVars: { globalVar: 'baz' } });
+            const module = await initPluginWithHandlers([handler], {
+                globalTemplateVars: { globalVar: 'baz' },
+            });
 
 
             eventBus.publish(new MockEvent(ctx, true));
             eventBus.publish(new MockEvent(ctx, true));
             await pause();
             await pause();
             expect(onSend.mock.calls[0][0].subject).toBe('Hello baz');
             expect(onSend.mock.calls[0][0].subject).toBe('Hello baz');
+            await module.close();
         });
         });
 
 
         it('globalTemplateVars available in setTemplateVars method', async () => {
         it('globalTemplateVars available in setTemplateVars method', async () => {
@@ -141,11 +149,14 @@ describe('EmailPlugin', () => {
                 .setSubject('Hello {{ testVar }}')
                 .setSubject('Hello {{ testVar }}')
                 .setTemplateVars((event, globals) => ({ testVar: globals.globalVar + ' quux' }));
                 .setTemplateVars((event, globals) => ({ testVar: globals.globalVar + ' quux' }));
 
 
-            await initPluginWithHandlers([handler], { globalTemplateVars: { globalVar: 'baz' } });
+            const module = await initPluginWithHandlers([handler], {
+                globalTemplateVars: { globalVar: 'baz' },
+            });
 
 
             eventBus.publish(new MockEvent(ctx, true));
             eventBus.publish(new MockEvent(ctx, true));
             await pause();
             await pause();
             expect(onSend.mock.calls[0][0].subject).toBe('Hello baz quux');
             expect(onSend.mock.calls[0][0].subject).toBe('Hello baz quux');
+            await module.close();
         });
         });
 
 
         it('setTemplateVars overrides globals', async () => {
         it('setTemplateVars overrides globals', async () => {
@@ -155,11 +166,12 @@ describe('EmailPlugin', () => {
                 .setSubject('Hello {{ name }}')
                 .setSubject('Hello {{ name }}')
                 .setTemplateVars((event, globals) => ({ name: 'quux' }));
                 .setTemplateVars((event, globals) => ({ name: 'quux' }));
 
 
-            await initPluginWithHandlers([handler], { globalTemplateVars: { name: 'baz' } });
+            const module = await initPluginWithHandlers([handler], { globalTemplateVars: { name: 'baz' } });
 
 
             eventBus.publish(new MockEvent(ctx, true));
             eventBus.publish(new MockEvent(ctx, true));
             await pause();
             await pause();
             expect(onSend.mock.calls[0][0].subject).toBe('Hello quux');
             expect(onSend.mock.calls[0][0].subject).toBe('Hello quux');
+            await module.close();
         });
         });
     });
     });
 
 
@@ -182,7 +194,7 @@ describe('EmailPlugin', () => {
                     subject: 'Servus, {{ name }}!',
                     subject: 'Servus, {{ name }}!',
                 });
                 });
 
 
-            await initPluginWithHandlers([handler]);
+            const module = await initPluginWithHandlers([handler]);
 
 
             eventBus.publish(new MockEvent({ ...ctx, languageCode: LanguageCode.ta }, true));
             eventBus.publish(new MockEvent({ ...ctx, languageCode: LanguageCode.ta }, true));
             await pause();
             await pause();
@@ -193,13 +205,20 @@ describe('EmailPlugin', () => {
             await pause();
             await pause();
             expect(onSend.mock.calls[1][0].subject).toBe('Servus, Test!');
             expect(onSend.mock.calls[1][0].subject).toBe('Servus, Test!');
             expect(onSend.mock.calls[1][0].body).toContain('German body.');
             expect(onSend.mock.calls[1][0].body).toContain('German body.');
+            await module.close();
         });
         });
     });
     });
 
 
     describe('orderConfirmationHandler', () => {
     describe('orderConfirmationHandler', () => {
-
+        let module: TestingModule;
         beforeEach(async () => {
         beforeEach(async () => {
-            await initPluginWithHandlers([orderConfirmationHandler], { templatePath: path.join(__dirname, '../templates') });
+            module = await initPluginWithHandlers([orderConfirmationHandler], {
+                templatePath: path.join(__dirname, '../templates'),
+            });
+        });
+
+        afterEach(async () => {
+            await module.close();
         });
         });
 
 
         const ctx = {
         const ctx = {
@@ -207,12 +226,12 @@ describe('EmailPlugin', () => {
             languageCode: LanguageCode.en,
             languageCode: LanguageCode.en,
         } as any;
         } as any;
 
 
-        const order = {
+        const order = ({
             code: 'ABCDE',
             code: 'ABCDE',
             customer: {
             customer: {
                 emailAddress: 'test@test.com',
                 emailAddress: 'test@test.com',
             },
             },
-        } as Partial<Order> as any;
+        } as Partial<Order>) as any;
 
 
         it('filters events with wrong order state', async () => {
         it('filters events with wrong order state', async () => {
             eventBus.publish(new OrderStateTransitionEvent('AddingItems', 'ArrangingPayment', ctx, order));
             eventBus.publish(new OrderStateTransitionEvent('AddingItems', 'ArrangingPayment', ctx, order));

+ 62 - 40
packages/email-plugin/src/plugin.ts

@@ -1,4 +1,15 @@
-import { createProxyHandler, EventBus, InternalServerError, Logger, Type, VendureConfig, VendurePlugin } from '@vendure/core';
+import {
+    createProxyHandler,
+    EventBus,
+    EventBusModule,
+    InternalServerError,
+    Logger,
+    OnVendureBootstrap,
+    OnVendureClose,
+    Type,
+    VendureConfig,
+    VendurePlugin,
+} from '@vendure/core';
 import fs from 'fs-extra';
 import fs from 'fs-extra';
 
 
 import { DevMailbox } from './dev-mailbox';
 import { DevMailbox } from './dev-mailbox';
@@ -6,7 +17,12 @@ import { EmailSender } from './email-sender';
 import { EmailEventHandler } from './event-listener';
 import { EmailEventHandler } from './event-listener';
 import { HandlebarsMjmlGenerator } from './handlebars-mjml-generator';
 import { HandlebarsMjmlGenerator } from './handlebars-mjml-generator';
 import { TemplateLoader } from './template-loader';
 import { TemplateLoader } from './template-loader';
-import { EmailPluginDevModeOptions, EmailPluginOptions, EmailTransportOptions, EventWithContext } from './types';
+import {
+    EmailPluginDevModeOptions,
+    EmailPluginOptions,
+    EmailTransportOptions,
+    EventWithContext,
+} from './types';
 
 
 /**
 /**
  * @description
  * @description
@@ -100,7 +116,7 @@ import { EmailPluginDevModeOptions, EmailPluginOptions, EmailTransportOptions, E
  * `outputPath` property.
  * `outputPath` property.
  *
  *
  * ```ts
  * ```ts
- * new EmailPlugin({
+ * EmailPlugin.init({
  *   devMode: true,
  *   devMode: true,
  *   handlers: defaultEmailHandlers,
  *   handlers: defaultEmailHandlers,
  *   templatePath: path.join(__dirname, 'vendure/email/templates'),
  *   templatePath: path.join(__dirname, 'vendure/email/templates'),
@@ -116,17 +132,46 @@ import { EmailPluginDevModeOptions, EmailPluginOptions, EmailTransportOptions, E
  *
  *
  * @docsCategory EmailPlugin
  * @docsCategory EmailPlugin
  */
  */
-export class EmailPlugin implements VendurePlugin {
-    private readonly transport: EmailTransportOptions;
-    private readonly options: EmailPluginOptions | EmailPluginDevModeOptions;
-    private eventBus: EventBus;
+@VendurePlugin({
+    imports: [EventBusModule],
+    configuration: (config: Required<VendureConfig>) => EmailPlugin.configure(config),
+})
+export class EmailPlugin implements OnVendureBootstrap, OnVendureClose {
+    private static options: EmailPluginOptions | EmailPluginDevModeOptions;
+    private transport: EmailTransportOptions;
     private templateLoader: TemplateLoader;
     private templateLoader: TemplateLoader;
     private emailSender: EmailSender;
     private emailSender: EmailSender;
     private generator: HandlebarsMjmlGenerator;
     private generator: HandlebarsMjmlGenerator;
     private devMailbox: DevMailbox | undefined;
     private devMailbox: DevMailbox | undefined;
 
 
-    constructor(options: EmailPluginOptions | EmailPluginDevModeOptions) {
+    /** @internal */
+    constructor(private eventBus: EventBus) {}
+
+    /**
+     * Set the plugin options.
+     */
+    static init(options: EmailPluginOptions | EmailPluginDevModeOptions): Type<EmailPlugin> {
         this.options = options;
         this.options = options;
+        return EmailPlugin;
+    }
+
+    /** @internal */
+    static configure(
+        config: Required<VendureConfig>,
+    ): Required<VendureConfig> | Promise<Required<VendureConfig>> {
+        if (isDevModeOptions(this.options) && this.options.mailboxPort !== undefined) {
+            const route = 'mailbox';
+            config.middleware.push({
+                handler: createProxyHandler({ port: this.options.mailboxPort, route, label: 'Dev Mailbox' }),
+                route,
+            });
+        }
+        return config;
+    }
+
+    /** @internal */
+    async onVendureBootstrap(): Promise<void> {
+        const options = EmailPlugin.options;
         if (isDevModeOptions(options)) {
         if (isDevModeOptions(options)) {
             this.transport = {
             this.transport = {
                 type: 'file',
                 type: 'file',
@@ -141,48 +186,32 @@ export class EmailPlugin implements VendurePlugin {
             }
             }
             this.transport = options.transport;
             this.transport = options.transport;
         }
         }
-    }
 
 
-    /** @internal */
-    configure(config: Required<VendureConfig>): Required<VendureConfig> | Promise<Required<VendureConfig>> {
-        if (isDevModeOptions(this.options) && this.options.mailboxPort !== undefined) {
-            const route = 'mailbox';
-            config.middleware.push({
-                handler: createProxyHandler({ port: this.options.mailboxPort, route, label: 'Dev Mailbox' }),
-                route,
-            });
-        }
-        return config;
-    }
-
-    /** @internal */
-    async onBootstrap(inject: <T>(type: Type<T>) => T): Promise<void> {
-        this.eventBus = inject(EventBus);
-        this.templateLoader = new TemplateLoader(this.options.templatePath);
+        this.templateLoader = new TemplateLoader(options.templatePath);
         this.emailSender = new EmailSender();
         this.emailSender = new EmailSender();
         this.generator = new HandlebarsMjmlGenerator();
         this.generator = new HandlebarsMjmlGenerator();
 
 
-        if (isDevModeOptions(this.options) && this.options.mailboxPort !== undefined) {
+        if (isDevModeOptions(options) && options.mailboxPort !== undefined) {
             this.devMailbox = new DevMailbox();
             this.devMailbox = new DevMailbox();
-            this.devMailbox.serve(this.options);
+            this.devMailbox.serve(options);
             this.devMailbox.handleMockEvent((handler, event) => this.handleEvent(handler, event));
             this.devMailbox.handleMockEvent((handler, event) => this.handleEvent(handler, event));
         }
         }
 
 
         await this.setupEventSubscribers();
         await this.setupEventSubscribers();
         if (this.generator.onInit) {
         if (this.generator.onInit) {
-            await this.generator.onInit.call(this.generator, this.options);
+            await this.generator.onInit.call(this.generator, options);
         }
         }
     }
     }
 
 
     /** @internal */
     /** @internal */
-    async onClose() {
+    async onVendureClose() {
         if (this.devMailbox) {
         if (this.devMailbox) {
             this.devMailbox.destroy();
             this.devMailbox.destroy();
         }
         }
     }
     }
 
 
     private async setupEventSubscribers() {
     private async setupEventSubscribers() {
-        for (const handler of this.options.handlers) {
+        for (const handler of EmailPlugin.options.handlers) {
             this.eventBus.subscribe(handler.event, event => {
             this.eventBus.subscribe(handler.event, event => {
                 return this.handleEvent(handler, event);
                 return this.handleEvent(handler, event);
             });
             });
@@ -198,19 +227,12 @@ export class EmailPlugin implements VendurePlugin {
     private async handleEvent(handler: EmailEventHandler, event: EventWithContext) {
     private async handleEvent(handler: EmailEventHandler, event: EventWithContext) {
         Logger.debug(`Handling event "${handler.type}"`, 'EmailPlugin');
         Logger.debug(`Handling event "${handler.type}"`, 'EmailPlugin');
         const { type } = handler;
         const { type } = handler;
-        const result = handler.handle(event, this.options.globalTemplateVars);
+        const result = handler.handle(event, EmailPlugin.options.globalTemplateVars);
         if (!result) {
         if (!result) {
             return;
             return;
         }
         }
-        const bodySource = await this.templateLoader.loadTemplate(
-            type,
-            result.templateFile,
-        );
-        const generated = await this.generator.generate(
-            result.subject,
-            bodySource,
-            result.templateVars,
-        );
+        const bodySource = await this.templateLoader.loadTemplate(type, result.templateFile);
+        const generated = await this.generator.generate(result.subject, bodySource, result.templateVars);
         const emailDetails = { ...generated, recipient: result.recipient };
         const emailDetails = { ...generated, recipient: result.recipient };
         await this.emailSender.send(emailDetails, this.transport);
         await this.emailSender.send(emailDetails, this.transport);
     }
     }