Skip to content

Commit 4623607

Browse files
authored
feat(core): introduce hooks for the Plugin API (#283)
BREAKING CHANGE: The experimental Plugin API has been changed
1 parent 206a5cc commit 4623607

File tree

17 files changed

+900
-251
lines changed

17 files changed

+900
-251
lines changed
Lines changed: 153 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,170 @@
11
# Plugin API
22

3-
:::warning
3+
:::info
44

5-
The Plugin API is still experimental and might change in the future, you should not rely on it in production.
5+
The Plugin API is stable since `v6.0.0` and should only change between major versions.
66

7-
Using any of the "official" plugins is safe since they are maintained by the same author and compatibility of new versions is ensured. If you want to create your own plugin, you should be aware that the API might change between minor versions.
7+
Any of the "official" plugins will always be kept in sync and updated to be compatible with any new version of the Plugin API.
88

99
:::
1010

11-
A plugin is, in its core, a NestJS module with some extra options and should implement the following interface:
11+
## Overview
12+
13+
A plugin provides a way to hook into the lifecycle of the `setup` phase of Cls-initializers (middleware, interceptor, guard, decorator) and modify/extend the contents of CLS Store.
14+
15+
Every plugin must implement the `ClsPlugin` interface and have a **globally unique name** that is used to identify its components in the DI system.
16+
17+
A plugin is, it its essence, just a NestJS module that can register its own providers and standard NestJS lifecycle hooks. These providers will be available in the DI system everywhere that `ClsService` is available.
18+
19+
## Plugin interface
1220

1321
```ts
14-
export interface ClsPlugin {
15-
/**
16-
* The name of the plugin, used for logging and debugging
17-
*/
18-
name: string;
19-
20-
/**
21-
* Function that is called within a Cls initializer (middleware, interceptor, guard, etc.)
22-
* right after `setup`.
23-
*/
24-
onClsInit?: (cls: ClsService) => void | Promise<void>;
25-
26-
/**
27-
* A lifecycle method called when the `ClsModule` is initialized
28-
*/
29-
onModuleInit?: () => void | Promise<void>;
22+
interface ClsPlugin {
23+
readonly name: string;
24+
25+
imports?: any[];
26+
providers?: Provider[];
27+
exports?: any[];
3028

31-
/**
32-
* A lifecycle method called when the `ClsModule` is destroyed
33-
* (only when shutdown hooks are enabled)
34-
*/
29+
onModuleInit?: () => void | Promise<void>;
3530
onModuleDestroy?: () => void | Promise<void>;
31+
onApplicationBootstrap?: () => void | Promise<void>;
32+
onApplicationShutdown?: (signal?: string) => void | Promise<void>;
33+
beforeApplicationShutdown?: (signal?: string) => void | Promise<void>;
34+
}
35+
```
3636

37-
/**
38-
* An array of external modules that should be imported for the plugin to work.
39-
*/
40-
imports?: any[];
37+
## CLS Hooks
4138

42-
/**
43-
* An array of providers that the plugin provides.
44-
*/
45-
providers?: Provider[];
39+
As mentioned above, a plugin can register a special provider that implements the `ClsPluginHooks` interface. This provider should be registered under the `getPluginHooksToken(<pluginName>)` token, where `pluginName` is the name of the plugin.
4640

47-
/**
48-
* An array of providers that the plugin provides that should be exported.
49-
*/
50-
exports?: any[];
41+
```ts
42+
interface ClsPluginHooks {
43+
beforeSetup?: (
44+
cls: ClsService,
45+
context: ClsInitContext,
46+
) => void | Promise<void>;
47+
48+
afterSetup?: (
49+
cls: ClsService,
50+
context: ClsInitContext,
51+
) => void | Promise<void>;
52+
}
53+
```
54+
55+
This interface can contain two methods: `beforeSetup` and `afterSetup`. These methods are called before and after the `setup` phase of the Cls-initializers and have access to the `ClsService` and the `ClsInitContext` object.
56+
57+
Since the plugin cannot know which Cls-initializer is being used, it is up to the plugin to check the `ClsInitContext` object and decide what to do. The `ClsInitContext` will always contain the `kind` property, with a value of either `middleware`, `interceptor`, `guard`, `decorator` or `custom`. and other properties depending on the kind of Cls-initializer.
58+
59+
A plugin author should indicate in the documentation which Cls-initializers are supported by the plugin, if there are any limitations. Otherwise, the plugin should be able to work with any Cls-initializer.
60+
61+
## Creating a plugin
62+
63+
Implementing the aforementioned interface and supplying the (optional) hooks provider is all that is needed to create a plugin. And instance of the plugin can be passed to the `plugins` array of the `ClsModule` options.
64+
65+
However, the `nestjs-cls` package exports a `ClsPluginBase` class, that can be extended to easily create a plugin.
66+
67+
In this example, we will implement a plugin that extracts the `user` property from the request and registers wraps it in a custom [Proxy provider](../03_features-and-use-cases/06_proxy-providers.md) for injection.
68+
69+
The plugin will work in the following way:
70+
71+
1. First, check if the `user` property is already set in the CLS Store. If it is, do nothing.
72+
2. Determine the kind of Cls-initializer that is being used and add the `user` property to the CLS Store.
73+
3. Register a custom `ClsUserHost` proxy provider that hosts the `user` property for injection anywhere in the application.
74+
75+
```ts
76+
// Define a symbol to be used as a key in the CLS Store
77+
export const USER_CLS_SYMBOL = Symbol('user');
78+
79+
// Define a custom proxy provider that will be used to inject the user property
80+
@InjectableProxy()
81+
export class ClsUserHost {
82+
public readonly user: MyUserType;
83+
84+
constructor(private readonly cls: ClsService) {
85+
this.user = this.cls.get(USER_CLS_SYMBOL);
86+
}
87+
}
88+
89+
// To Create the plugin, extend the ClsPluginBase class
90+
export class UserPlugin extends ClsPluginBase {
91+
constructor() {
92+
// Specify a unique name for the plugin
93+
super('user-plugin');
94+
95+
// Register the plugin hooks using the convenience method
96+
this.registerHooks({
97+
useFactory: () => ({
98+
afterSetup(cls, context) {
99+
// This hook will be called after the setup phase of every Cls-initializer
100+
// so we check if the user property is already set and do nothing
101+
if (cls.has(USER_CLS_SYMBOL)) {
102+
return;
103+
}
104+
105+
// If the user property is not set, we check the kind of Cls-initializer
106+
switch (context.kind) {
107+
case 'middleware':
108+
cls.set(USER_CLS_SYMBOL, context.req.user);
109+
break;
110+
case 'interceptor':
111+
cls.set(
112+
USER_CLS_SYMBOL,
113+
context.ctx.switchToHttp().getRequest().user,
114+
);
115+
break;
116+
case 'guard':
117+
cls.set(
118+
USER_CLS_SYMBOL,
119+
context.ctx.switchToHttp().getRequest().user,
120+
);
121+
break;
122+
default:
123+
// If the kind is not supported (decorator or custom), we throw an error,
124+
// because there is no request.
125+
// If the user wants to use the plugin in a decorator or a custom
126+
// Cls-initializer, they should set the user property manually
127+
// in the `setup` method of the Decorator
128+
throw new Error(
129+
`Unsupported context kind: ${context.kind}`,
130+
);
131+
}
132+
},
133+
}),
134+
});
135+
136+
// Register the custom Proxy provider
137+
this.imports.push(ClsModule.forFeature(ClsUserHost));
138+
}
51139
}
52140
```
53141

54-
Each plugin creates a new instance of a _global_ `ClsPluginModule` and the exposed providers can be used for injection by other plugin-related code.
142+
:::info
143+
144+
It is also possible to expose the User itself as Proxy provider without the need of the plugin. This is only for demonstration purposes.
145+
146+
:::
147+
148+
## Using plugin options
149+
150+
If we wanted to customize the plugin and allow the user to be retrieved from a custom property name from the request, we could do it by adding some options to the plugin constructor.
151+
152+
```ts
153+
export class UserPlugin extends ClsPluginBase {
154+
// highlight-start
155+
constructor(userPropertyName: string) {
156+
// highlight-end
157+
// Specify a unique name for the plugin
158+
super('user-plugin');
159+
160+
// [...]
161+
162+
switch (context.kind) {
163+
case 'middleware':
164+
// highlight-start
165+
cls.set(USER_CLS_SYMBOL, context.req[userPropertyName]);
166+
// highlight-end
167+
break;
168+
```
169+
170+
A more advanced use-case would be to allow passing the options asynchronously. For that, we can use the `imports` array and the `inject` method on the `this.registerHooks` method. An example can be found in the implementation of the existing plugins.

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export * from './lib/cls.options';
1010
export * from './lib/cls.service';
1111
export * from './lib/inject-cls.decorator';
1212
export * from './lib/plugin/cls-plugin.interface';
13+
export * from './lib/plugin/cls-plugin-base';
1314
export * from './lib/proxy-provider/injectable-proxy.decorator';
1415
export * from './lib/proxy-provider/proxy-provider.exceptions';
1516
export * from './lib/proxy-provider/proxy-provider.interfaces';

packages/core/src/lib/cls-initializers/cls.guard.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { ClsServiceManager } from '../cls-service-manager';
88
import { CLS_CTX, CLS_ID } from '../cls.constants';
99
import { CLS_GUARD_OPTIONS } from '../cls.internal-constants';
1010
import { ClsGuardOptions } from '../cls.options';
11+
import { ClsEnhancerInitContext } from '../plugin/cls-plugin.interface';
12+
import { ClsPluginsHooksHost } from '../plugin/cls-plugin-hooks-host';
1113
import { ContextClsStoreMap } from './utils/context-cls-store-map';
1214

1315
@Injectable()
@@ -16,20 +18,28 @@ export class ClsGuard implements CanActivate {
1618

1719
constructor(
1820
@Inject(CLS_GUARD_OPTIONS)
19-
options: Omit<ClsGuardOptions, 'mount'>,
21+
options: Omit<ClsGuardOptions, 'mount'> | undefined,
2022
) {
2123
this.options = { ...new ClsGuardOptions(), ...options };
2224
}
2325

2426
async canActivate(context: ExecutionContext): Promise<boolean> {
2527
const cls = ClsServiceManager.getClsService();
2628
const existingStore = ContextClsStoreMap.get(context);
29+
const pluginHooks = ClsPluginsHooksHost.getInstance();
2730
if (existingStore) {
2831
cls.enter({ ifNested: 'reuse' });
2932
} else {
3033
cls.enterWith({});
3134
ContextClsStoreMap.set(context, cls.get());
3235
}
36+
const pluginCtx: ClsEnhancerInitContext = {
37+
kind: 'guard',
38+
ctx: context,
39+
};
40+
if (this.options.initializePlugins) {
41+
await pluginHooks.beforeSetup(pluginCtx);
42+
}
3343
if (this.options.generateId) {
3444
const id = await this.options.idGenerator?.(context);
3545
cls.setIfUndefined<any>(CLS_ID, id);
@@ -41,7 +51,7 @@ export class ClsGuard implements CanActivate {
4151
await this.options.setup(cls, context);
4252
}
4353
if (this.options.initializePlugins) {
44-
await cls.initializePlugins();
54+
await pluginHooks.afterSetup(pluginCtx);
4555
}
4656
if (this.options.resolveProxyProviders) {
4757
await cls.resolveProxyProviders();

packages/core/src/lib/cls-initializers/cls.interceptor.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { ClsServiceManager } from '../cls-service-manager';
1010
import { CLS_CTX, CLS_ID } from '../cls.constants';
1111
import { CLS_INTERCEPTOR_OPTIONS } from '../cls.internal-constants';
1212
import { ClsInterceptorOptions } from '../cls.options';
13+
import { ClsEnhancerInitContext } from '../plugin/cls-plugin.interface';
14+
import { ClsPluginsHooksHost } from '../plugin/cls-plugin-hooks-host';
1315
import { ContextClsStoreMap } from './utils/context-cls-store-map';
1416

1517
@Injectable()
@@ -18,17 +20,25 @@ export class ClsInterceptor implements NestInterceptor {
1820

1921
constructor(
2022
@Inject(CLS_INTERCEPTOR_OPTIONS)
21-
options?: Omit<ClsInterceptorOptions, 'mount'>,
23+
options?: Omit<ClsInterceptorOptions, 'mount'> | undefined,
2224
) {
2325
this.options = { ...new ClsInterceptorOptions(), ...options };
2426
}
2527

2628
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
2729
const cls = ClsServiceManager.getClsService<any>();
2830
const clsStore = ContextClsStoreMap.get(context) ?? {};
31+
const pluginHooks = ClsPluginsHooksHost.getInstance();
2932
ContextClsStoreMap.set(context, clsStore);
3033
return new Observable((subscriber) => {
3134
cls.runWith(clsStore, async () => {
35+
const pluginCtx: ClsEnhancerInitContext = {
36+
kind: 'interceptor',
37+
ctx: context,
38+
};
39+
if (this.options.initializePlugins) {
40+
await pluginHooks.beforeSetup(pluginCtx);
41+
}
3242
if (this.options.generateId) {
3343
const id = await this.options.idGenerator?.(context);
3444
cls.setIfUndefined<any>(CLS_ID, id);
@@ -40,7 +50,7 @@ export class ClsInterceptor implements NestInterceptor {
4050
await this.options.setup(cls, context);
4151
}
4252
if (this.options.initializePlugins) {
43-
await cls.initializePlugins();
53+
await pluginHooks.afterSetup(pluginCtx);
4454
}
4555
if (this.options.resolveProxyProviders) {
4656
await cls.resolveProxyProviders();

packages/core/src/lib/cls-initializers/cls.middleware.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,37 @@
11
import { Inject, Injectable, NestMiddleware } from '@nestjs/common';
22
import { ClsServiceManager } from '../cls-service-manager';
33
import { CLS_ID, CLS_REQ, CLS_RES } from '../cls.constants';
4+
import { CLS_MIDDLEWARE_OPTIONS } from '../cls.internal-constants';
45
import { ClsMiddlewareOptions } from '../cls.options';
6+
import { ClsMiddlewareInitContext } from '../plugin/cls-plugin.interface';
7+
import { ClsPluginsHooksHost } from '../plugin/cls-plugin-hooks-host';
58
import { ContextClsStoreMap } from './utils/context-cls-store-map';
6-
import { CLS_MIDDLEWARE_OPTIONS } from '../cls.internal-constants';
79

810
@Injectable()
911
export class ClsMiddleware implements NestMiddleware {
1012
private readonly options: Omit<ClsMiddlewareOptions, 'mount'>;
1113

1214
constructor(
1315
@Inject(CLS_MIDDLEWARE_OPTIONS)
14-
options?: Omit<ClsMiddlewareOptions, 'mount'>,
16+
options: Omit<ClsMiddlewareOptions, 'mount'> | undefined,
1517
) {
1618
this.options = { ...new ClsMiddlewareOptions(), ...options };
1719
}
1820
use = async (req: any, res: any, next: (err?: any) => any) => {
1921
const cls = ClsServiceManager.getClsService();
22+
const pluginHooks = ClsPluginsHooksHost.getInstance();
2023
const callback = async () => {
2124
try {
25+
const pluginCtx: ClsMiddlewareInitContext = {
26+
kind: 'middleware',
27+
req,
28+
res,
29+
};
2230
this.options.useEnterWith && cls.enter();
2331
ContextClsStoreMap.setByRaw(req, cls.get());
32+
if (this.options.initializePlugins) {
33+
await pluginHooks.beforeSetup(pluginCtx);
34+
}
2435
if (this.options.generateId) {
2536
const id = await this.options.idGenerator?.(req);
2637
cls.setIfUndefined<any>(CLS_ID, id);
@@ -31,7 +42,7 @@ export class ClsMiddleware implements NestMiddleware {
3142
await this.options.setup(cls, req, res);
3243
}
3344
if (this.options.initializePlugins) {
34-
await cls.initializePlugins();
45+
await pluginHooks.afterSetup(pluginCtx);
3546
}
3647
if (this.options.resolveProxyProviders) {
3748
await cls.resolveProxyProviders();

0 commit comments

Comments
 (0)