Просмотр исходного кода

feat(core): Rewrite plugin system to use Nest modules

Relates to #123

BREAKING CHANGE: Vendure plugins are now defined as Nestjs modules. For
existing installations, the VendureConfig will need to be modified so
that plugins are not instantiated, but use the static .init() method to
pass options to the plugin, e.g.:

    ```
    // before
    plugins: [ new AdminUiPlugin({ port: 3002 }) ],

    // after
    plugins: [ AdminUiPlugin.init({ port: 3002 }) ],
    ```
Michael Bromley 6 лет назад
Родитель
Сommit
7ec309b899

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

@@ -47,7 +47,7 @@ describe('Default search plugin', () => {
                 customerCount: 1,
             },
             {
-                plugins: [new DefaultSearchPlugin()],
+                plugins: [DefaultSearchPlugin],
             },
         );
         await adminClient.init();
@@ -59,30 +59,39 @@ describe('Default search plugin', () => {
     });
 
     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);
     }
 
     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);
     }
 
     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([
             'Instant Camera',
             'Camera Lens',
@@ -91,12 +100,15 @@ describe('Default search plugin', () => {
     }
 
     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([
             'Laptop',
             'Curvy Monitor',
@@ -108,12 +120,15 @@ describe('Default search plugin', () => {
     }
 
     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([
             'Spiky Cactus',
             'Orchid',
@@ -122,12 +137,15 @@ describe('Default search plugin', () => {
     }
 
     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([
             {
                 price: { value: 129900 },
@@ -145,12 +163,15 @@ describe('Default search plugin', () => {
     }
 
     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([
             {
                 price: { min: 129900, max: 229900 },
@@ -183,11 +204,14 @@ describe('Default search plugin', () => {
         it('price ranges', () => testPriceRanges(shopClient));
 
         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([
                 { count: 21, facetValue: { id: 'T_1', name: 'electronics' } },
                 { 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 () => {
-            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([
                 { count: 10, facetValue: { id: 'T_1', name: 'electronics' } },
                 { count: 6, facetValue: { id: 'T_2', name: 'computers' } },
@@ -215,18 +242,22 @@ describe('Default search plugin', () => {
         });
 
         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, {
                 input: {
                     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([
                 { count: 10, facetValue: { id: 'T_1', name: 'electronics' } },
                 { count: 6, facetValue: { id: 'T_2', name: 'computers' } },
@@ -251,44 +285,52 @@ describe('Default search plugin', () => {
         });
 
         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 () => {
-            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();
-            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']);
         });
 
         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']);
         });
@@ -386,7 +428,7 @@ describe('Default search plugin', () => {
             const { createCollection } = await adminClient.query<
                 CreateCollection.Mutation,
                 CreateCollection.Variables
-                >(CREATE_COLLECTION, {
+            >(CREATE_COLLECTION, {
                 input: {
                     translations: [
                         {
@@ -442,12 +484,15 @@ describe('Default search plugin', () => {
                 },
             });
             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([
                 {
                     price: { min: 129900, max: 229900 },
@@ -457,12 +502,15 @@ describe('Default search plugin', () => {
         });
 
         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([
                 { productVariantId: 'T_1', 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 () => {
-            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();
-            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([
                 { productId: 'T_1', 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 () => {
-            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();
-            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([
                 { productId: 'T_1', enabled: false },
                 { productId: 'T_2', enabled: true },
@@ -519,12 +574,15 @@ describe('Default search plugin', () => {
                 },
             });
             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([
                 { productId: 'T_1', enabled: false },
                 { 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 { LanguageCode } from '@vendure/common/lib/generated-types';
 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 {
+    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 {
     getNames(): string[] {
         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
         config.defaultLanguageCode = LanguageCode.zh;
         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 {
     TestAPIExtensionPlugin,
+    TestPluginWithAllLifecycleHooks,
     TestPluginWithConfigAndBootstrap,
-    TestPluginWithOnClose,
     TestPluginWithProvider,
 } from './fixtures/test-plugins';
 import { TestAdminClient, TestShopClient } from './test-client';
@@ -19,7 +19,10 @@ describe('Plugins', () => {
     const shopClient = new TestShopClient();
     const server = new TestServer();
     const bootstrapMockFn = jest.fn();
+    const onBootstrapFn = jest.fn();
+    const onWorkerBootstrapFn = jest.fn();
     const onCloseFn = jest.fn();
+    const onWorkerCloseFn = jest.fn();
 
     beforeAll(async () => {
         const token = await server.init(
@@ -29,10 +32,15 @@ describe('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();
     });
 
-    it('can modify the config in configure() and inject in onBootstrap()', () => {
+    it('calls onVendureBootstrap once only', () => {
+        expect(onBootstrapFn.mock.calls.length).toBe(1);
+    });
+
+    it('calls onWorkerVendureBootstrap once only', () => {
+        expect(onWorkerBootstrapFn.mock.calls.length).toBe(1);
+    });
+
+    it('can modify the config in configure()', () => {
         expect(bootstrapMockFn).toHaveBeenCalled();
         const configService: ConfigService = bootstrapMockFn.mock.calls[0][0];
         expect(configService instanceof ConfigService).toBe(true);
@@ -78,8 +94,17 @@ describe('Plugins', () => {
         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 once only', () => {
+            expect(onCloseFn.mock.calls.length).toBe(1);
+        });
+
+        it('calls onWorkerVendureClose once only', () => {
+            expect(onWorkerCloseFn.mock.calls.length).toBe(1);
+        });
     });
 });

+ 17 - 12
packages/core/e2e/shop-auth.e2e-spec.ts

@@ -1,4 +1,5 @@
 /* tslint:disable:no-non-null-assertion */
+import { OnModuleInit } from '@nestjs/common';
 import { RegisterCustomerInput } from '@vendure/common/lib/generated-shop-types';
 import { pick } from '@vendure/common/lib/pick';
 import { DocumentNode } from 'graphql';
@@ -6,11 +7,12 @@ import gql from 'graphql-tag';
 import path from 'path';
 
 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 { IdentifierChangeEvent } from '../src/event-bus/events/identifier-change-event';
 import { IdentifierChangeRequestEvent } from '../src/event-bus/events/identifier-change-request-event';
 import { PasswordResetEvent } from '../src/event-bus/events/password-reset-event';
-import { InjectorFn, VendurePlugin } from '../src/plugin/vendure-plugin';
+import { VendurePlugin } from '../src/plugin/vendure-plugin';
 
 import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
 import {
@@ -58,7 +60,7 @@ describe('Shop auth & accounts', () => {
                 customerCount: 2,
             },
             {
-                plugins: [new TestEmailPlugin()],
+                plugins: [TestEmailPlugin],
             },
         );
         await shopClient.init();
@@ -517,7 +519,7 @@ describe('Expiring tokens', () => {
                 customerCount: 1,
             },
             {
-                plugins: [new TestEmailPlugin()],
+                plugins: [TestEmailPlugin],
                 authOptions: {
                     verificationTokenDuration: '1ms',
                 },
@@ -607,7 +609,7 @@ describe('Registration without email verification', () => {
                 customerCount: 1,
             },
             {
-                plugins: [new TestEmailPlugin()],
+                plugins: [TestEmailPlugin],
                 authOptions: {
                     requireVerification: false,
                 },
@@ -683,7 +685,7 @@ describe('Updating email address without email verification', () => {
                 customerCount: 1,
             },
             {
-                plugins: [new TestEmailPlugin()],
+                plugins: [TestEmailPlugin],
                 authOptions: {
                     requireVerification: false,
                 },
@@ -731,19 +733,22 @@ describe('Updating email address without email verification', () => {
  * This mock plugin simulates an EmailPlugin which would send emails
  * 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);
         });
-        eventBus.subscribe(PasswordResetEvent, event => {
+        this.eventBus.subscribe(PasswordResetEvent, event => {
             sendEmailFn(event);
         });
-        eventBus.subscribe(IdentifierChangeRequestEvent, event => {
+        this.eventBus.subscribe(IdentifierChangeRequestEvent, event => {
             sendEmailFn(event);
         });
-        eventBus.subscribe(IdentifierChangeEvent, event => {
+        this.eventBus.subscribe(IdentifierChangeEvent, event => {
             sendEmailFn(event);
         });
     }

+ 5 - 4
packages/core/e2e/test-server.ts

@@ -7,7 +7,7 @@ import { ConnectionOptions } from 'typeorm';
 import { SqljsConnectionOptions } from 'typeorm/driver/sqljs/SqljsConnectionOptions';
 
 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 { Logger } from '../src/config/logger/vendure-logger';
 import { VendureConfig } from '../src/config/vendure-config';
@@ -108,13 +108,14 @@ export class TestServer {
     /**
      * 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 appModule = await import('../src/app.module');
         try {
-            const app = await NestFactory.create(appModule.AppModule, {cors: config.cors, logger: false});
+            const app = await NestFactory.create(appModule.AppModule, { cors: config.cors, logger: false });
             let worker: INestMicroservice | undefined;
-            await runPluginOnBootstrapMethods(config, app);
             await app.listen(config.port);
             if (config.workerOptions.runInMainProcess) {
                 const workerModule = await import('../src/worker/worker.module');

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

@@ -37,7 +37,10 @@ import { OrderLineEntityResolver } from './resolvers/entity/order-line-entity.re
 import { PaymentEntityResolver } from './resolvers/entity/payment-entity.resolver';
 import { ProductEntityResolver } from './resolvers/entity/product-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 { ShopAuthResolver } from './resolvers/shop/shop-auth.resolver';
 import { ShopCustomerResolver } from './resolvers/shop/shop-customer.resolver';
@@ -92,9 +95,7 @@ export const entityResolvers = [
     RefundEntityResolver,
 ];
 
-export const adminEntityResolvers = [
-    ProductVariantAdminEntityResolver,
-];
+export const adminEntityResolvers = [ProductVariantAdminEntityResolver];
 
 /**
  * The internal module containing some shared providers used by more than
@@ -111,8 +112,8 @@ export class ApiSharedModule {}
  * The internal module containing the Admin GraphQL API resolvers
  */
 @Module({
-    imports: [ApiSharedModule, PluginModule.forRoot(), ServiceModule.forRoot(), DataImportModule],
-    providers: [...adminResolvers, ...entityResolvers, ...adminEntityResolvers, ...PluginModule.adminApiResolvers()],
+    imports: [ApiSharedModule, ServiceModule.forRoot(), DataImportModule, PluginModule.forAdmin()],
+    providers: [...adminResolvers, ...entityResolvers, ...adminEntityResolvers],
     exports: [...adminResolvers],
 })
 export class AdminApiModule {}
@@ -121,8 +122,8 @@ export class AdminApiModule {}
  * The internal module containing the Shop GraphQL API resolvers
  */
 @Module({
-    imports: [ApiSharedModule, PluginModule.forRoot(), ServiceModule.forRoot()],
-    providers: [...shopResolvers, ...entityResolvers, ...PluginModule.shopApiResolvers()],
-    exports: shopResolvers,
+    imports: [ApiSharedModule, ServiceModule.forRoot(), PluginModule.forShop()],
+    providers: [...shopResolvers, ...entityResolvers],
+    exports: [...shopResolvers],
 })
 export class ShopApiModule {}

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

@@ -1,6 +1,7 @@
 import { DynamicModule } from '@nestjs/common';
 import { GqlModuleOptions, GraphQLModule, GraphQLTypesLoader } from '@nestjs/graphql';
 import { StockMovementType } from '@vendure/common/lib/generated-types';
+import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 import { GraphQLUpload } from 'apollo-server-core';
 import { extendSchema, printSchema } from 'graphql';
 import { GraphQLDateTime } from 'graphql-iso-date';
@@ -11,14 +12,19 @@ import { ConfigModule } from '../../config/config.module';
 import { ConfigService } from '../../config/config.service';
 import { I18nModule } from '../../i18n/i18n.module';
 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 { IdCodecService } from '../common/id-codec.service';
 import { IdEncoderExtension } from '../middleware/id-encoder-extension';
 import { TranslateErrorExtension } from '../middleware/translate-errors-extension';
 
 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 {
     apiType: 'shop' | 'admin';
@@ -106,7 +112,7 @@ async function createGraphQLOptions(
     return {
         path: '/' + options.apiPath,
         typeDefs: await createTypeDefs(options.apiType),
-        include: [options.resolverModule],
+        include: [options.resolverModule, ...getDynamicGraphQlModulesForPlugins(options.apiType)],
         resolvers: {
             JSON: GraphQLJSON,
             DateTime: GraphQLDateTime,
@@ -157,9 +163,9 @@ async function createGraphQLOptions(
         schema = addGraphQLCustomFields(schema, customFields, apiType === 'shop');
         schema = addServerConfigCustomFields(schema, customFields);
         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) {
             schema = extendSchema(schema, documentNode);

+ 3 - 12
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 { RequestHandler } from 'express';
 
@@ -12,9 +12,8 @@ import { I18nService } from './i18n/i18n.service';
 @Module({
     imports: [ConfigModule, I18nModule, ApiModule],
 })
-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) {
         const { adminApiPath, shopApiPath } = this.configService;
@@ -39,14 +38,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) {
         if (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 { registerCustomEntityFields } from './entity/register-custom-entity-fields';
 import { validateCustomFieldsConfig } from './entity/validate-custom-fields-config';
+import {
+    getConfigurationFunction,
+    getEntitiesFromPlugins,
+    getPluginModules,
+    hasLifecycleMethod,
+} from './plugin/plugin-metadata';
 import { logProxyMiddlewares } from './plugin/plugin-utils';
 
 export type VendureBootstrapFunction = (config: VendureConfig) => Promise<INestApplication>;
@@ -48,7 +54,6 @@ export async function bootstrap(userConfig: Partial<VendureConfig>): Promise<INe
     });
     DefaultLogger.restoreOriginalLogLevel();
     app.useLogger(new Logger());
-    await runPluginOnBootstrapMethods(config, app);
     await app.listen(config.port, config.hostname);
     app.enableShutdownHooks();
     if (config.workerOptions.runInMainProcess) {
@@ -126,7 +131,7 @@ export async function preBootstrapConfig(
     // base VendureEntity to be correctly configured with the primary key type
     // specified in the EntityIdStrategy.
     // tslint:disable-next-line:whitespace
-    const pluginEntities = getEntitiesFromPlugins(userConfig);
+    const pluginEntities = getEntitiesFromPlugins(userConfig.plugins);
     const entities = await getAllEntities(userConfig);
     const { coreSubscribersMap } = await import('./entity/subscribers');
     setConfig({
@@ -154,40 +159,21 @@ async function runPluginConfigurations(
     config: ReadOnlyRequired<VendureConfig>,
 ): Promise<ReadOnlyRequired<VendureConfig>> {
     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;
 }
 
-/**
- * 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.
  */
 async function getAllEntities(userConfig: Partial<VendureConfig>): Promise<Array<Type<any>>> {
     const { coreEntitiesMap } = await import('./entity/entities');
     const coreEntities = Object.values(coreEntitiesMap) as Array<Type<any>>;
-    const pluginEntities = getEntitiesFromPlugins(userConfig);
+    const pluginEntities = getEntitiesFromPlugins(userConfig.plugins);
 
     const allEntities: Array<Type<any>> = coreEntities;
 
@@ -203,18 +189,6 @@ async function getAllEntities(userConfig: Partial<VendureConfig>): Promise<Array
     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
  * instance too.

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

@@ -1,11 +1,10 @@
-import { Injectable } from '@nestjs/common';
+import { DynamicModule, Injectable, Type } from '@nestjs/common';
 import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface';
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { RequestHandler } from 'express';
 import { ConnectionOptions } from 'typeorm';
 
 import { ReadOnlyRequired } from '../common/types/common-types';
-import { VendurePlugin } from '../plugin/vendure-plugin';
 
 import { getConfig } from './config-helpers';
 import { CustomFields } from './custom-field/custom-field-types';
@@ -112,7 +111,7 @@ export class ConfigService implements VendureConfig {
         return this.activeConfig.middleware;
     }
 
-    get plugins(): VendurePlugin[] {
+    get plugins(): Array<DynamicModule | Type<any>> {
         return this.activeConfig.plugins;
     }
 

+ 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 { ClientOptions, Transport } from '@nestjs/microservices';
 import { LanguageCode } from '@vendure/common/lib/generated-types';
@@ -7,7 +8,6 @@ import { ConnectionOptions } from 'typeorm';
 
 import { Transitions } from '../common/finite-state-machine';
 import { Order } from '../entity/order/order.entity';
-import { VendurePlugin } from '../plugin/vendure-plugin';
 import { OrderState } from '../service/helpers/order-state-machine/order-state';
 
 import { AssetNamingStrategy } from './asset-naming-strategy/asset-naming-strategy';
@@ -489,7 +489,7 @@ export interface VendureConfig {
      *
      * @default []
      */
-    plugins?: VendurePlugin[];
+    plugins?: Array<DynamicModule | Type<any>>;
     /**
      * @description
      * Which port the Vendure server should listen on.

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

@@ -1,4 +1,5 @@
 export * from './event-bus';
+export * from './event-bus.module';
 export * from './vendure-event';
 export * from './events/account-registration-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 {
     Reindex = 'Reindex',
     UpdateVariantsById = 'UpdateVariantsById',

+ 25 - 62
packages/core/src/plugin/default-search-plugin/default-search-plugin.ts

@@ -1,11 +1,7 @@
-import { Provider } from '@nestjs/common';
+import { OnApplicationBootstrap } from '@nestjs/common';
 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 { APIExtensionDefinition, VendurePlugin } from '../../config';
 import { ProductVariant } from '../../entity/product-variant/product-variant.entity';
 import { Product } from '../../entity/product/product.entity';
 import { EventBus } from '../../event-bus/event-bus';
@@ -13,6 +9,8 @@ import { CatalogModificationEvent } from '../../event-bus/events/catalog-modific
 import { CollectionModificationEvent } from '../../event-bus/events/collection-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 { VendurePlugin } from '../vendure-plugin';
 
 import { AdminFulltextSearchResolver, ShopFulltextSearchResolver } from './fulltext-search.resolver';
 import { FulltextSearchService } from './fulltext-search.service';
@@ -54,72 +52,37 @@ export interface DefaultSearchReindexResponse extends SearchReindexResponse {
  *
  * @docsCategory DefaultSearchPlugin
  */
-export class DefaultSearchPlugin implements VendurePlugin {
+@VendurePlugin({
+    imports: [PluginCommonModule],
+    providers: [
+        FulltextSearchService,
+        SearchIndexService,
+        { provide: SearchService, useClass: FulltextSearchService },
+    ],
+    exports: [{ provide: SearchService, useClass: FulltextSearchService }],
+    adminApiExtensions: { resolvers: [AdminFulltextSearchResolver] },
+    shopApiExtensions: { resolvers: [ShopFulltextSearchResolver] },
+    entities: [SearchIndexItem],
+    workers: [IndexerController],
+})
+export class DefaultSearchPlugin implements OnApplicationBootstrap {
+    constructor(private eventBus: EventBus, private searchIndexService: SearchIndexService) {}
 
     /** @internal */
-    async onBootstrap(inject: <T>(type: Type<T>) => T): Promise<void> {
-        const eventBus = inject(EventBus);
-        const searchIndexService = inject(SearchIndexService);
-        eventBus.subscribe(CatalogModificationEvent, event => {
+    onApplicationBootstrap() {
+        this.eventBus.subscribe(CatalogModificationEvent, event => {
             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;
             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,
-        ];
-    }
 }

+ 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 { TaxRateService } from '../../../service/services/tax-rate.service';
 import { AsyncQueue } from '../async-queue';
-import { loggerCtx, Message } from '../constants';
+import { Message, workerLoggerCtx } from '../constants';
 import { SearchIndexItem } from '../search-index-item.entity';
 
 export const BATCH_SIZE = 1000;
@@ -57,17 +57,19 @@ export class IndexerController {
                 const timeStart = Date.now();
                 const qb = this.getSearchIndexQueryBuilder();
                 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);
 
                 // Ensure tax rates are up-to-date.
                 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++) {
-                    Logger.verbose(`Processing batch ${i + 1} of ${batches}`, loggerCtx);
+                    Logger.verbose(`Processing batch ${i + 1} of ${batches}`, workerLoggerCtx);
 
                     const variants = await qb
                         .where('variants__product.deletedAt IS NULL')
@@ -82,7 +84,7 @@ export class IndexerController {
                         duration: +new Date() - timeStart,
                     });
                 }
-                Logger.verbose(`Completed reindexing!`);
+                Logger.verbose(`Completed reindexing`, workerLoggerCtx);
                 observer.next({
                     total: count,
                     completed: count,
@@ -94,7 +96,13 @@ export class IndexerController {
     }
 
     @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);
 
         return new Observable(observer => {
@@ -109,9 +117,11 @@ export class IndexerController {
                         const end = begin + BATCH_SIZE;
                         Logger.verbose(`Updating ids from index ${begin} to ${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);
                         await this.saveVariants(ctx, variants);
                         observer.next({
@@ -136,7 +146,15 @@ export class IndexerController {
      * Updates the search index only for the affected entities.
      */
     @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);
         let updatedVariants: ProductVariant[] = [];
         let removedVariantIds: ID[] = [];
@@ -155,7 +173,7 @@ export class IndexerController {
                                 relations: variantRelations,
                             });
                         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];
                 }
             }
-            Logger.verbose(`Updating ${updatedVariants.length} variants`, loggerCtx);
+            Logger.verbose(`Updating ${updatedVariants.length} variants`, workerLoggerCtx);
             updatedVariants = this.hydrateVariants(ctx, updatedVariants);
             if (updatedVariants.length) {
                 await this.saveVariants(ctx, updatedVariants);
@@ -198,25 +216,26 @@ export class IndexerController {
     }
 
     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));
     }

+ 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 './vendure-plugin';
+export * from './plugin-common.module';
 export { createProxyHandler, ProxyOptions } from './plugin-utils';

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

@@ -0,0 +1,32 @@
+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';
+
+/**
+ * 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.
+ */
+@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;
+}

+ 8 - 19
packages/core/src/plugin/plugin-utils.ts

@@ -1,8 +1,7 @@
-import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 import { RequestHandler } from 'express';
 import proxy from 'http-proxy-middleware';
 
-import { APIExtensionDefinition, Logger, VendureConfig, VendurePlugin } from '../config';
+import { Logger, VendureConfig } from '../config';
 
 /**
  * @description
@@ -104,23 +103,13 @@ export function createProxyHandler(options: ProxyOptions): RequestHandler {
 export function logProxyMiddlewares(config: VendureConfig) {
     for (const middleware of config.middleware || []) {
         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);
-}

+ 107 - 54
packages/core/src/plugin/plugin.module.ts

@@ -1,86 +1,139 @@
-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 { 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';
+import { Logger } from '../config/logger/vendure-logger';
 
-import { getPluginAPIExtensions } from './plugin-utils';
+import { createDynamicGraphQlModulesForPlugins } from './dynamic-plugin-api.module';
+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
- * modules.
+ * modules and in responsible for executing any lifecycle methods defined by the plugins.
  */
 @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],
 })
-export class PluginModule {
-    static shopApiResolvers(): Array<Type<any>> {
-        return graphQLResolversFor('shop');
+export class PluginModule implements OnModuleInit, OnModuleDestroy {
+    static forShop(): DynamicModule {
+        return {
+            module: PluginModule,
+            providers: [{ provide: PLUGIN_PROCESS_CONTEXT, useValue: PluginProcessContext.Main }],
+            imports: [...getConfig().plugins, ...createDynamicGraphQlModulesForPlugins('shop')],
+        };
     }
 
-    static adminApiResolvers(): Array<Type<any>> {
-        return graphQLResolversFor('admin');
+    static forAdmin(): DynamicModule {
+        return {
+            module: PluginModule,
+            providers: [{ provide: PLUGIN_PROCESS_CONTEXT, useValue: PluginProcessContext.Main }],
+            imports: [...getConfig().plugins, ...createDynamicGraphQlModulesForPlugins('admin')],
+        };
     }
 
     static forRoot(): DynamicModule {
         return {
             module: PluginModule,
-            imports: [ServiceModule.forRoot()],
+            providers: [{ provide: PLUGIN_PROCESS_CONTEXT, useValue: PluginProcessContext.Main }],
+            imports: [...getConfig().plugins],
         };
     }
 
     static forWorker(): DynamicModule {
         return {
             module: PluginModule,
-            imports: [ServiceModule.forWorker()],
+            providers: [{ provide: PLUGIN_PROCESS_CONTEXT, useValue: PluginProcessContext.Worker }],
+            imports: [...pluginsWithWorkerControllers()],
         };
     }
-}
 
-function getPluginProviders() {
-    const plugins = getConfig().plugins;
-    return plugins
-        .map(p => (p.defineProviders ? p.defineProviders() : undefined))
-        .filter(notNullOrUndefined)
-        .reduce((flattened, providers) => flattened.concat(providers), []);
-}
+    private static mainBootstrapHasRun = false;
+    private static mainCloseHasRun = false;
+    private static workerBootstrapHasRun = false;
+    private static workerCloseHasRun = false;
+
+    constructor(
+        @Inject(PLUGIN_PROCESS_CONTEXT) private processContext: PluginProcessContext,
+        private moduleRef: ModuleRef,
+        private configService: ConfigService,
+    ) {}
+
+    async onModuleInit() {
+        if (!PluginModule.mainBootstrapHasRun && this.processContext === PluginProcessContext.Main) {
+            PluginModule.mainBootstrapHasRun = true;
+            this.runPluginLifecycleMethods('onVendureBootstrap', instance => {
+                const pluginName = instance.constructor.name || '(anonymous plugin)';
+                Logger.verbose(`Bootstrapped plugin ${pluginName}`);
+            });
+        }
+        if (!PluginModule.workerBootstrapHasRun && this.processContext === PluginProcessContext.Worker) {
+            PluginModule.workerBootstrapHasRun = true;
+            this.runPluginLifecycleMethods('onVendureWorkerBootstrap');
+        }
+    }
+
+    async onModuleDestroy() {
+        if (!PluginModule.mainCloseHasRun && this.processContext === PluginProcessContext.Main) {
+            PluginModule.mainCloseHasRun = true;
+            await this.runPluginLifecycleMethods('onVendureClose');
+        }
+        if (!PluginModule.workerCloseHasRun && this.processContext === PluginProcessContext.Worker) {
+            PluginModule.workerCloseHasRun = true;
+            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,
+            };
+        }
+    });
 }

+ 66 - 54
packages/core/src/plugin/vendure-plugin.ts

@@ -1,16 +1,13 @@
-import { Provider } from '@nestjs/common';
+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';
 
-/**
- * @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;
+import { PLUGIN_METADATA } from './plugin-metadata';
 
 /**
  * @description
@@ -21,7 +18,7 @@ export type InjectorFn = <T>(type: Type<T>) => T;
 export interface APIExtensionDefinition {
     /**
      * @description
-     * The schema extensions.
+     * Extensions to the schema.
      *
      * @example
      * ```TypeScript
@@ -31,7 +28,7 @@ export interface APIExtensionDefinition {
      * }`;
      * ```
      */
-    schema: DocumentNode;
+    schema?: DocumentNode;
     /**
      * @description
      * An array of resolvers for the schema extensions. Should be defined as Nest GraphQL resolver
@@ -42,67 +39,82 @@ export interface APIExtensionDefinition {
 
 /**
  * @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
+ * This method is called before the app bootstraps, and can modify the VendureConfig object and perform
+ * other (potentially async) tasks needed.
  */
-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>;
+export type PluginConfigurationFn = (
+    config: Required<VendureConfig>,
+) => Required<VendureConfig> | Promise<Required<VendureConfig>>;
 
+export interface VendurePluginMetadata extends ModuleMetadata {
+    configuration?: PluginConfigurationFn;
     /**
      * @description
-     * The plugin may extend the default Vendure GraphQL shop api by implementing this method and providing extended
+     * The plugin may extend the default Vendure GraphQL shop api by providing extended
      * schema definitions and any required resolvers.
      */
-    extendShopAPI?(): APIExtensionDefinition;
-
+    shopApiExtensions?: APIExtensionDefinition;
     /**
      * @description
-     * The plugin may extend the default Vendure GraphQL admin api by implementing this method and providing extended
+     * The plugin may extend the default Vendure GraphQL admin api by 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[];
-
+    adminApiExtensions?: APIExtensionDefinition;
     /**
      * @description
      * The plugin may define providers which are run in the Worker context, i.e. Nest microservice controllers.
      */
-    defineWorkers?(): Array<Type<any>>;
-
+    workers?: 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>>;
+    entities?: Array<Type<any>>;
 }
+
+/**
+ * @description
+ * A VendurePlugin is a means of configuring and/or extending the functionality of the Vendure server. A Vendure Plugin is
+ * a Nestjs Module, 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.
+ *
+ * @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);
+    };
+}
+
+export interface OnVendureBootstrap {
+    onVendureBootstrap(): void | Promise<void>;
+}
+
+export interface OnVendureWorkerBootstrap {
+    onVendureWorkerBootstrap(): void | Promise<void>;
+}
+
+export interface OnVendureClose {
+    onVendureClose(): void | Promise<void>;
+}
+
+export interface OnVendureWorkerClose {
+    onVendureWorkerClose(): void | Promise<void>;
+}
+
+export type PluginLifecycleMethods = OnVendureBootstrap &
+    OnVendureWorkerBootstrap &
+    OnVendureClose &
+    OnVendureWorkerClose;

+ 9 - 7
packages/core/src/service/service.module.ts

@@ -88,10 +88,7 @@ let workerTypeOrmModule: DynamicModule;
  * into a format suitable for the service layer logic.
  */
 @Module({
-    imports: [
-        ConfigModule,
-        EventBusModule,
-    ],
+    imports: [ConfigModule, EventBusModule],
     providers: [
         ...exportedProviders,
         PasswordCiper,
@@ -148,9 +145,7 @@ export class ServiceModule implements OnModuleInit {
         }
         return {
             module: ServiceModule,
-            imports: [
-                defaultTypeOrmModule,
-            ],
+            imports: [defaultTypeOrmModule],
         };
     }
 
@@ -183,4 +178,11 @@ export class ServiceModule implements OnModuleInit {
             imports: [workerTypeOrmModule],
         };
     }
+
+    static forPlugin(): DynamicModule {
+        return {
+            module: ServiceModule,
+            imports: [TypeOrmModule.forFeature()],
+        };
+    }
 }

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

@@ -1,8 +1,6 @@
 import { Module, OnApplicationShutdown, OnModuleDestroy } from '@nestjs/common';
 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 { Logger } from '../config/logger/vendure-logger';
 import { PluginModule } from '../plugin/plugin.module';
@@ -12,11 +10,7 @@ import { MessageInterceptor } from './message-interceptor';
 import { WorkerMonitor } from './worker-monitor';
 
 @Module({
-    imports: [
-        ConfigModule,
-        ServiceModule.forWorker(),
-        PluginModule.forWorker(),
-    ],
+    imports: [ConfigModule, ServiceModule.forWorker(), PluginModule.forWorker()],
     providers: [
         WorkerMonitor,
         {
@@ -24,7 +18,6 @@ import { WorkerMonitor } from './worker-monitor';
             useClass: MessageInterceptor,
         },
     ],
-    controllers: getWorkerControllers(),
 })
 export class WorkerModule implements OnModuleDestroy, OnApplicationShutdown {
     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), []);
-}