nest-feature-flags 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.js +25 -0
- package/.prettierrc +8 -0
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/dist/decorators/feature-flag.decorator.d.ts +2 -0
- package/dist/decorators/feature-flag.decorator.js +8 -0
- package/dist/decorators/feature-flag.decorator.js.map +1 -0
- package/dist/feature-flags.module.d.ts +6 -0
- package/dist/feature-flags.module.js +53 -0
- package/dist/feature-flags.module.js.map +1 -0
- package/dist/guards/feature-flag.guard.d.ts +9 -0
- package/dist/guards/feature-flag.guard.js +46 -0
- package/dist/guards/feature-flag.guard.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/interfaces/feature-flag-options.interface.d.ts +10 -0
- package/dist/interfaces/feature-flag-options.interface.js +3 -0
- package/dist/interfaces/feature-flag-options.interface.js.map +1 -0
- package/dist/interfaces/feature-flag.interface.d.ts +20 -0
- package/dist/interfaces/feature-flag.interface.js +3 -0
- package/dist/interfaces/feature-flag.interface.js.map +1 -0
- package/dist/services/feature-flag.service.d.ts +12 -0
- package/dist/services/feature-flag.service.js +86 -0
- package/dist/services/feature-flag.service.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/jest.config.js +23 -0
- package/package.json +77 -0
- package/src/decorators/feature-flag.decorator.ts +8 -0
- package/src/feature-flags.module.ts +42 -0
- package/src/guards/feature-flag.guard.ts +35 -0
- package/src/index.ts +13 -0
- package/src/interfaces/feature-flag-options.interface.ts +25 -0
- package/src/interfaces/feature-flag.interface.ts +84 -0
- package/src/services/feature-flag.service.spec.ts +51 -0
- package/src/services/feature-flag.service.ts +99 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { ModuleMetadata } from '@nestjs/common';
|
|
2
|
+
import { FeatureFlag } from './feature-flag.interface';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Module configuration options
|
|
6
|
+
*/
|
|
7
|
+
export interface FeatureFlagsModuleOptions {
|
|
8
|
+
/**
|
|
9
|
+
* Feature flag definitions
|
|
10
|
+
*/
|
|
11
|
+
flags: Record<string, FeatureFlag>;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Default value when flag is not found
|
|
15
|
+
*/
|
|
16
|
+
defaultValue?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Async module options
|
|
21
|
+
*/
|
|
22
|
+
export interface FeatureFlagsModuleAsyncOptions extends Pick<ModuleMetadata, 'imports'> {
|
|
23
|
+
useFactory?: (...args: any[]) => Promise<FeatureFlagsModuleOptions> | FeatureFlagsModuleOptions;
|
|
24
|
+
inject?: any[];
|
|
25
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feature flag configuration
|
|
3
|
+
*/
|
|
4
|
+
export interface FeatureFlag {
|
|
5
|
+
/**
|
|
6
|
+
* Master switch - feature is enabled
|
|
7
|
+
*/
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Percentage of users who should see this feature (0-100)
|
|
12
|
+
*/
|
|
13
|
+
percentage?: number;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Specific user IDs who should see this feature
|
|
17
|
+
*/
|
|
18
|
+
userIds?: string[];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* User groups who should see this feature
|
|
22
|
+
*/
|
|
23
|
+
groups?: string[];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Date when feature becomes active
|
|
27
|
+
*/
|
|
28
|
+
startDate?: Date;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Date when feature becomes inactive
|
|
32
|
+
*/
|
|
33
|
+
endDate?: Date;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Feature description
|
|
37
|
+
*/
|
|
38
|
+
description?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Context for evaluating feature flags
|
|
43
|
+
*/
|
|
44
|
+
export interface FeatureFlagContext {
|
|
45
|
+
/**
|
|
46
|
+
* User ID for targeting
|
|
47
|
+
*/
|
|
48
|
+
userId?: string;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* User groups for targeting
|
|
52
|
+
*/
|
|
53
|
+
groups?: string[];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Additional custom attributes
|
|
57
|
+
*/
|
|
58
|
+
attributes?: Record<string, any>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Result of feature flag evaluation
|
|
63
|
+
*/
|
|
64
|
+
export interface FeatureFlagResult {
|
|
65
|
+
/**
|
|
66
|
+
* Flag key
|
|
67
|
+
*/
|
|
68
|
+
key: string;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether feature is enabled for this context
|
|
72
|
+
*/
|
|
73
|
+
enabled: boolean;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Reason for the decision
|
|
77
|
+
*/
|
|
78
|
+
reason: string;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Variant (for A/B testing)
|
|
82
|
+
*/
|
|
83
|
+
variant?: string;
|
|
84
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Test } from '@nestjs/testing';
|
|
2
|
+
import { FeatureFlagService, OPTIONS_TOKEN } from './feature-flag.service';
|
|
3
|
+
|
|
4
|
+
describe('FeatureFlagService', () => {
|
|
5
|
+
let service: FeatureFlagService;
|
|
6
|
+
|
|
7
|
+
beforeEach(async () => {
|
|
8
|
+
const module = await Test.createTestingModule({
|
|
9
|
+
providers: [
|
|
10
|
+
FeatureFlagService,
|
|
11
|
+
{
|
|
12
|
+
provide: OPTIONS_TOKEN,
|
|
13
|
+
useValue: {
|
|
14
|
+
flags: {
|
|
15
|
+
'enabled-flag': { enabled: true },
|
|
16
|
+
'disabled-flag': { enabled: false },
|
|
17
|
+
'percentage-flag': { enabled: true, percentage: 50 },
|
|
18
|
+
'user-targeted': { enabled: true, userIds: ['user1'] },
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
}).compile();
|
|
24
|
+
|
|
25
|
+
service = module.get<FeatureFlagService>(FeatureFlagService);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should be defined', () => {
|
|
29
|
+
expect(service).toBeDefined();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should return true for enabled flag', async () => {
|
|
33
|
+
const result = await service.isEnabled('enabled-flag');
|
|
34
|
+
expect(result).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should return false for disabled flag', async () => {
|
|
38
|
+
const result = await service.isEnabled('disabled-flag');
|
|
39
|
+
expect(result).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should target specific users', async () => {
|
|
43
|
+
const result = await service.isEnabled('user-targeted', { userId: 'user1' });
|
|
44
|
+
expect(result).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should handle percentage rollout', async () => {
|
|
48
|
+
const result = await service.isEnabled('percentage-flag', { userId: 'test' });
|
|
49
|
+
expect(typeof result).toBe('boolean');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Injectable, Inject } from '@nestjs/common';
|
|
2
|
+
import { FeatureFlag, FeatureFlagContext, FeatureFlagResult } from '../interfaces/feature-flag.interface';
|
|
3
|
+
import { FeatureFlagsModuleOptions } from '../interfaces/feature-flag-options.interface';
|
|
4
|
+
|
|
5
|
+
const OPTIONS_TOKEN = 'FEATURE_FLAGS_OPTIONS';
|
|
6
|
+
|
|
7
|
+
@Injectable()
|
|
8
|
+
export class FeatureFlagService {
|
|
9
|
+
constructor(
|
|
10
|
+
@Inject(OPTIONS_TOKEN)
|
|
11
|
+
private readonly options: FeatureFlagsModuleOptions,
|
|
12
|
+
) {}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Check if a feature is enabled
|
|
16
|
+
*/
|
|
17
|
+
async isEnabled(key: string, context?: FeatureFlagContext): Promise<boolean> {
|
|
18
|
+
const result = await this.evaluate(key, context);
|
|
19
|
+
return result.enabled;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Evaluate a feature flag
|
|
24
|
+
*/
|
|
25
|
+
async evaluate(key: string, context?: FeatureFlagContext): Promise<FeatureFlagResult> {
|
|
26
|
+
const flag = this.options.flags[key];
|
|
27
|
+
|
|
28
|
+
if (!flag) {
|
|
29
|
+
return {
|
|
30
|
+
key,
|
|
31
|
+
enabled: this.options.defaultValue ?? false,
|
|
32
|
+
reason: 'Flag not found',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Check master switch
|
|
37
|
+
if (!flag.enabled) {
|
|
38
|
+
return { key, enabled: false, reason: 'Flag disabled' };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Check date range
|
|
42
|
+
const now = new Date();
|
|
43
|
+
if (flag.startDate && now < flag.startDate) {
|
|
44
|
+
return { key, enabled: false, reason: 'Not started yet' };
|
|
45
|
+
}
|
|
46
|
+
if (flag.endDate && now > flag.endDate) {
|
|
47
|
+
return { key, enabled: false, reason: 'Expired' };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Check user targeting
|
|
51
|
+
if (context?.userId && flag.userIds?.includes(context.userId)) {
|
|
52
|
+
return { key, enabled: true, reason: 'User targeted' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Check group targeting
|
|
56
|
+
if (context?.groups && flag.groups) {
|
|
57
|
+
const hasGroup = context.groups.some(g => flag.groups!.includes(g));
|
|
58
|
+
if (hasGroup) {
|
|
59
|
+
return { key, enabled: true, reason: 'Group targeted' };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Check percentage rollout
|
|
64
|
+
if (flag.percentage !== undefined) {
|
|
65
|
+
const hash = this.hashUserId(context?.userId || 'anonymous');
|
|
66
|
+
const userPercentage = hash % 100;
|
|
67
|
+
const enabled = userPercentage < flag.percentage;
|
|
68
|
+
return {
|
|
69
|
+
key,
|
|
70
|
+
enabled,
|
|
71
|
+
reason: enabled ? 'In percentage rollout' : 'Not in percentage rollout',
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { key, enabled: true, reason: 'Fully enabled' };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get all flags
|
|
80
|
+
*/
|
|
81
|
+
getAllFlags(): Record<string, FeatureFlag> {
|
|
82
|
+
return this.options.flags;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Simple hash function for consistent percentage rollout
|
|
87
|
+
*/
|
|
88
|
+
private hashUserId(userId: string): number {
|
|
89
|
+
let hash = 0;
|
|
90
|
+
for (let i = 0; i < userId.length; i++) {
|
|
91
|
+
const char = userId.charCodeAt(i);
|
|
92
|
+
hash = (hash << 5) - hash + char;
|
|
93
|
+
hash = hash & hash;
|
|
94
|
+
}
|
|
95
|
+
return Math.abs(hash);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export { OPTIONS_TOKEN };
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"module": "commonjs",
|
|
4
|
+
"declaration": true,
|
|
5
|
+
"removeComments": true,
|
|
6
|
+
"emitDecoratorMetadata": true,
|
|
7
|
+
"experimentalDecorators": true,
|
|
8
|
+
"allowSyntheticDefaultImports": true,
|
|
9
|
+
"target": "ES2021",
|
|
10
|
+
"sourceMap": true,
|
|
11
|
+
"outDir": "./dist",
|
|
12
|
+
"baseUrl": "./",
|
|
13
|
+
"incremental": true,
|
|
14
|
+
"skipLibCheck": true,
|
|
15
|
+
"strictNullChecks": true,
|
|
16
|
+
"noImplicitAny": true,
|
|
17
|
+
"strictBindCallApply": true,
|
|
18
|
+
"forceConsistentCasingInFileNames": true,
|
|
19
|
+
"noFallthroughCasesInSwitch": true,
|
|
20
|
+
"esModuleInterop": true,
|
|
21
|
+
"resolveJsonModule": true
|
|
22
|
+
},
|
|
23
|
+
"include": ["src/**/*"],
|
|
24
|
+
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
|
|
25
|
+
}
|