Skip to content

Commit b8652f8

Browse files
feat(core): add durable providers feature
1 parent 5de7913 commit b8652f8

18 files changed

Lines changed: 482 additions & 51 deletions
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { INestApplication } from '@nestjs/common';
2+
import { ContextIdFactory } from '@nestjs/core';
3+
import { Test } from '@nestjs/testing';
4+
import { expect } from 'chai';
5+
import * as request from 'supertest';
6+
import { DurableContextIdStrategy } from '../src/durable/durable-context-id.strategy';
7+
import { DurableModule } from '../src/durable/durable.module';
8+
9+
describe('Durable providers', () => {
10+
let server: any;
11+
let app: INestApplication;
12+
13+
before(async () => {
14+
const moduleRef = await Test.createTestingModule({
15+
imports: [DurableModule],
16+
}).compile();
17+
18+
app = moduleRef.createNestApplication();
19+
server = app.getHttpServer();
20+
await app.init();
21+
22+
ContextIdFactory.apply(new DurableContextIdStrategy());
23+
});
24+
25+
describe('when service is durable', () => {
26+
const performHttpCall = (tenantId: number, end: (err?: any) => void) =>
27+
request(server)
28+
.get('/durable')
29+
.set({ ['x-tenant-id']: tenantId })
30+
.end((err, res) => {
31+
if (err) return end(err);
32+
end(res);
33+
});
34+
35+
it(`should share durable providers per tenant`, async () => {
36+
let result: request.Response;
37+
result = await new Promise<request.Response>(resolve =>
38+
performHttpCall(1, resolve),
39+
);
40+
expect(result.text).equal('Hello world! Counter: 1');
41+
42+
result = await new Promise<request.Response>(resolve =>
43+
performHttpCall(1, resolve),
44+
);
45+
expect(result.text).equal('Hello world! Counter: 2');
46+
47+
result = await new Promise<request.Response>(resolve =>
48+
performHttpCall(1, resolve),
49+
);
50+
expect(result.text).equal('Hello world! Counter: 3');
51+
});
52+
53+
it(`should create per-tenant DI sub-tree`, async () => {
54+
let result: request.Response;
55+
result = await new Promise<request.Response>(resolve =>
56+
performHttpCall(4, resolve),
57+
);
58+
expect(result.text).equal('Hello world! Counter: 1');
59+
60+
result = await new Promise<request.Response>(resolve =>
61+
performHttpCall(5, resolve),
62+
);
63+
expect(result.text).equal('Hello world! Counter: 1');
64+
65+
result = await new Promise<request.Response>(resolve =>
66+
performHttpCall(6, resolve),
67+
);
68+
expect(result.text).equal('Hello world! Counter: 1');
69+
});
70+
});
71+
72+
after(async () => {
73+
//ContextIdFactory['strategy'] = undefined;
74+
await app.close();
75+
});
76+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { ChildContextIdInfo, ContextId, ContextIdStrategy } from '@nestjs/core';
2+
import { Request } from 'express';
3+
4+
const tenants = new Map<string, ContextId>();
5+
6+
export class DurableContextIdStrategy implements ContextIdStrategy {
7+
attach(contextId: ContextId, request: Request) {
8+
const tenantId = request.headers['x-tenant-id'] as string;
9+
let tenantSubTreeId: ContextId;
10+
11+
if (tenants.has(tenantId)) {
12+
tenantSubTreeId = tenants.get(tenantId);
13+
} else {
14+
tenantSubTreeId = { id: +tenantId } as ContextId;
15+
tenants.set(tenantId, tenantSubTreeId);
16+
}
17+
18+
return (info: ChildContextIdInfo) =>
19+
info.isTreeDurable ? tenantSubTreeId : contextId;
20+
}
21+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Controller, Get } from '@nestjs/common';
2+
import { DurableService } from './durable.service';
3+
4+
@Controller('durable')
5+
export class DurableController {
6+
constructor(private readonly durableService: DurableService) {}
7+
8+
@Get()
9+
greeting(): string {
10+
return this.durableService.greeting();
11+
}
12+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Module } from '@nestjs/common';
2+
import { DurableController } from './durable.controller';
3+
import { DurableService } from './durable.service';
4+
5+
@Module({
6+
controllers: [DurableController],
7+
providers: [DurableService],
8+
})
9+
export class DurableModule {}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Injectable, Scope } from '@nestjs/common';
2+
3+
@Injectable({ scope: Scope.REQUEST, durable: true })
4+
export class DurableService {
5+
public instanceCounter = 0;
6+
7+
greeting() {
8+
++this.instanceCounter;
9+
return `Hello world! Counter: ${this.instanceCounter}`;
10+
}
11+
}

packages/common/decorators/core/controller.decorator.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ export function Controller(
163163
: [
164164
prefixOrOptions.path || defaultPath,
165165
prefixOrOptions.host,
166-
{ scope: prefixOrOptions.scope },
166+
{ scope: prefixOrOptions.scope, durable: prefixOrOptions.durable },
167167
Array.isArray(prefixOrOptions.version)
168168
? Array.from(new Set(prefixOrOptions.version))
169169
: prefixOrOptions.version,

packages/common/interfaces/modules/provider.interface.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ export interface ClassProvider<T = any> {
5252
* @see [Use factory](https://docs.nestjs.com/fundamentals/custom-providers#factory-providers-usefactory)
5353
*/
5454
inject?: never;
55+
/**
56+
* Flags provider as durable. This flag can be used in combination with custom context id
57+
* factory strategy to construct lazy DI subtrees.
58+
*
59+
* This flag can be used only in conjunction with scope = Scope.REQUEST.
60+
*/
61+
durable?: boolean;
5562
}
5663

5764
/**
@@ -123,6 +130,13 @@ export interface FactoryProvider<T = any> {
123130
* Optional enum defining lifetime of the provider that is returned by the Factory function.
124131
*/
125132
scope?: Scope;
133+
/**
134+
* Flags provider as durable. This flag can be used in combination with custom context id
135+
* factory strategy to construct lazy DI subtrees.
136+
*
137+
* This flag can be used only in conjunction with scope = Scope.REQUEST.
138+
*/
139+
durable?: boolean;
126140
}
127141

128142
/**

packages/common/interfaces/scope-options.interface.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,11 @@ export interface ScopeOptions {
2828
* Specifies the lifetime of an injected Provider or Controller.
2929
*/
3030
scope?: Scope;
31+
/**
32+
* Flags provider as durable. This flag can be used in combination with custom context id
33+
* factory strategy to construct lazy DI subtrees.
34+
*
35+
* This flag can be used only in conjunction with scope = Scope.REQUEST.
36+
*/
37+
durable?: boolean;
3138
}

packages/core/helpers/context-id-factory.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ContextId } from '../injector/instance-wrapper';
1+
import { ChildContextIdInfo, ContextId } from '../injector/instance-wrapper';
22
import { REQUEST_CONTEXT_ID } from '../router/request/request-constants';
33

44
export function createContextId(): ContextId {
@@ -13,7 +13,22 @@ export function createContextId(): ContextId {
1313
return { id: Math.random() };
1414
}
1515

16+
export interface ContextIdStrategy<T = any> {
17+
/**
18+
* Allows to attach a parent context id to the existing child context id.
19+
* This lets you construct durable DI sub-trees that can be shared between contexts.
20+
* @param contextId auto-generated child context id
21+
* @param request request object
22+
*/
23+
attach(
24+
contextId: ContextId,
25+
request: T,
26+
): ((info: ChildContextIdInfo) => ContextId) | undefined;
27+
}
28+
1629
export class ContextIdFactory {
30+
private static strategy?: ContextIdStrategy;
31+
1732
/**
1833
* Generates a context identifier based on the request object.
1934
*/
@@ -27,16 +42,33 @@ export class ContextIdFactory {
2742
*/
2843
public static getByRequest<T extends Record<any, any> = any>(
2944
request: T,
45+
propsToInspect: string[] = ['raw'],
3046
): ContextId {
3147
if (!request) {
32-
return createContextId();
48+
return ContextIdFactory.create();
3349
}
3450
if (request[REQUEST_CONTEXT_ID as any]) {
3551
return request[REQUEST_CONTEXT_ID as any];
3652
}
37-
if (request.raw && request.raw[REQUEST_CONTEXT_ID]) {
38-
return request.raw[REQUEST_CONTEXT_ID];
53+
for (const key of propsToInspect) {
54+
if (request[key]?.[REQUEST_CONTEXT_ID]) {
55+
return request[key][REQUEST_CONTEXT_ID];
56+
}
3957
}
40-
return createContextId();
58+
if (!this.strategy) {
59+
return ContextIdFactory.create();
60+
}
61+
const contextId = createContextId();
62+
contextId.getParent = this.strategy.attach(contextId, request);
63+
return contextId;
64+
}
65+
66+
/**
67+
* Registers a custom context id strategy that lets you attach
68+
* a parent context id to the existing context id object.
69+
* @param strategy strategy instance
70+
*/
71+
public static apply(strategy: ContextIdStrategy) {
72+
this.strategy = strategy;
4173
}
4274
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { SCOPE_OPTIONS_METADATA } from '@nestjs/common/constants';
2+
import { Type } from '@nestjs/common/interfaces/type.interface';
3+
4+
export function isDurable(provider: Type<unknown>): boolean | undefined {
5+
const metadata = Reflect.getMetadata(SCOPE_OPTIONS_METADATA, provider);
6+
return metadata && metadata.durable;
7+
}

0 commit comments

Comments
 (0)