Skip to content

Commit 49c2945

Browse files
committed
feat: feature flags api
1 parent d5956a5 commit 49c2945

File tree

11 files changed

+333
-2
lines changed

11 files changed

+333
-2
lines changed

apps/test-bot/src/sharding-manager.ts renamed to apps/test-bot/src/_sharding-manager.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// remove _ prefix from this file name to enable sharding
12
import { ShardingManager } from 'discord.js';
23
import { join } from 'node:path';
34

apps/test-bot/src/app/commands/(general)/help.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { CommandData, ChatInputCommand } from 'commandkit';
2+
import { redEmbedColor } from '../../../feature-flags/red-embed-color';
3+
import { Colors, MessageFlags } from 'discord.js';
24

35
export const command: CommandData = {
46
name: 'help',
@@ -13,6 +15,8 @@ function $botVersion(): string {
1315
}
1416

1517
export const chatInput: ChatInputCommand = async (ctx) => {
18+
const showRedColor = await redEmbedColor();
19+
1620
const { interaction } = ctx;
1721
await interaction.deferReply();
1822

@@ -34,7 +38,7 @@ export const chatInput: ChatInputCommand = async (ctx) => {
3438
footer: {
3539
text: `Bot Version: ${botVersion} | Shard ID ${interaction.guild?.shardId ?? 'N/A'}`,
3640
},
37-
color: 0x7289da,
41+
color: showRedColor ? Colors.Red : Colors.Blurple,
3842
timestamp: new Date().toISOString(),
3943
},
4044
],
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { flag } from 'commandkit';
2+
3+
export const redEmbedColor = flag({
4+
key: 'red-embed-color',
5+
description: 'Red embed color',
6+
decide() {
7+
return Math.random() < 0.5;
8+
},
9+
});
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
---
2+
title: Feature Flags
3+
description: Feature flags are a powerful tool for controlling the visibility of features in your application. They allow you to enable or disable features for specific users or groups, making it easier to test and roll out new functionality.
4+
---
5+
6+
Feature flags are a powerful tool for controlling the visibility of features in your application. They allow you to enable or disable features for specific users or groups, making it easier to test and roll out new functionality. This is particularly useful to perform A/B testing, gradual rollouts, and canary releases.
7+
8+
CommandKit natively offers a feature flag system that allows you to define flags in your configuration and use them in your code. This system is designed to be simple and flexible, allowing you to easily manage feature flags across your application.
9+
10+
## Possible Use Cases
11+
12+
- **Maintenance Mode**: You can use feature flags to enable or disable features in your application during maintenance. This allows you to perform maintenance tasks without affecting the user experience.
13+
- **A/B Testing**: You can use feature flags to test different versions of a feature with different users. For example, you can show one version of a button to 50% of users and another version to the other 50%.
14+
- **Gradual Rollouts**: You can use feature flags to gradually roll out a new feature to a small percentage of users before rolling it out to everyone. This allows you to monitor the feature's performance and make adjustments as needed.
15+
- **Canary Releases**: You can use feature flags to release a new feature to a small group of users before rolling it out to everyone. This allows you to test the feature in a production environment and catch any issues before they affect all users.
16+
- **Feature Toggles**: You can use feature flags to enable or disable features in your application without deploying new code. This allows you to quickly turn features on or off as needed.
17+
- **User Segmentation**: You can use feature flags to enable or disable features for specific users or groups. This allows you to customize the user experience based on user preferences or behavior.
18+
- **Testing and Debugging**: You can use feature flags to enable or disable features for testing and debugging purposes. This allows you to isolate issues and test specific features without affecting the entire application.
19+
20+
## Defining Feature Flags
21+
22+
Feature flags may be defined in any file in your project (except `app.ts`). The recommended place to define them is in `src/flags` directory.
23+
24+
```ts title="src/flags/showRedColorInsteadOfBlue.ts"
25+
import { flag } from 'commandkit';
26+
27+
export const showRedColorInsteadOfBlue = flag({
28+
name: 'show-red-color-instead-of-blue',
29+
description: 'Show red color instead of blue in embeds',
30+
decide() {
31+
// show red color instead of blue in embeds
32+
// to 50% of users
33+
return Math.random() > 0.5;
34+
},
35+
});
36+
```
37+
38+
## Using Feature Flags
39+
40+
Once you have defined a feature flag, you can use it in your code to control the visibility of features. You can use the `isEnabled` method to check if a feature flag is enabled or not.
41+
42+
```ts title="src/commands/hello.ts"
43+
import { ChatInputCommand, CommandData } from 'commandkit';
44+
import { showRedColorInsteadOfBlue } from '../flags/showRedColorInsteadOfBlue';
45+
46+
export const command: CommandData = {
47+
name: 'hello',
48+
description: 'Hello world command',
49+
};
50+
51+
export const chatInput: ChatInputCommand = async (ctx) => {
52+
const showRedColor = await showRedColorInsteadOfBlue();
53+
54+
await ctx.interaction.reply({
55+
embeds: [
56+
{
57+
title: 'Hello world',
58+
description: 'This is a hello world command',
59+
color: showRedColor ? 0xff0000 : 0x0000ff,
60+
},
61+
],
62+
});
63+
};
64+
```
65+
66+
Now there's a 50% chance that the embed will be red instead of blue. You can use this feature flag to test the new color scheme with a subset of users before rolling it out to everyone.
67+
68+
## Context Identification
69+
70+
Sometimes you may want to identify specific users or groups for feature flags. For example, you may want to show a secret feature to server boosters only.
71+
In order to identify the context of the flag, you can add the `identify` method to the flag definition.
72+
73+
```ts title="src/flags/showRedColorInsteadOfBlue.ts"
74+
import { flag } from 'commandkit';
75+
import { GuildMember } from 'discord.js';
76+
77+
interface Entity {
78+
isBooster: boolean;
79+
}
80+
81+
export const showRedColorInsteadOfBlue = flag<boolean, Entity>({
82+
key: 'show-red-color-instead-of-blue',
83+
description: 'Show red color instead of blue in embeds',
84+
identify(ctx) {
85+
let member: GuildMember | null = null;
86+
87+
if (ctx.command) {
88+
member = (ctx.command.interaction || ctx.command.message)
89+
?.member as GuildMember;
90+
} else if (ctx.event) {
91+
// handle event specific context
92+
if (ctx.event.event === 'guildMemberUpdate') {
93+
const [_oldMember, newMember] =
94+
ctx.event.argumentsAs('guildMemberUpdate');
95+
96+
member = newMember as GuildMember;
97+
}
98+
}
99+
100+
return { isBooster: member?.premiumSince !== null };
101+
},
102+
decide({ entities }) {
103+
return entities.isBooster;
104+
},
105+
});
106+
```
107+
108+
:::info
109+
Flags with `identify` method only work inside the middlewares, commands and events. If you need to use flags outside of the context of these, you have to manually pass the result of the `identify` method to `flagDeclaration.run` method:
110+
111+
```ts
112+
// passing the value directly
113+
const result = await myFlag.run({ identify: { isBooster: true } });
114+
// or passing as a function
115+
const result = await myFlag.run({ identify: () => ({ isBooster: true }) });
116+
```
117+
118+
This is not recommended must of the time but may be useful in some cases.
119+
:::

packages/commandkit/src/CommandKit.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { isRuntimePlugin } from './plugins';
1919
import { generateTypesPackage } from './utils/types-package';
2020
import { Logger } from './logger/Logger';
2121
import { AsyncFunction, GenericFunction } from './context/async-context';
22+
import { FlagStore } from './flags/store';
2223

2324
export interface CommandKitConfiguration {
2425
defaultLocale: Locale;
@@ -84,6 +85,7 @@ export class CommandKit extends EventEmitter {
8485
};
8586

8687
public readonly store = new Map<string, any>();
88+
public readonly flags = new FlagStore();
8789

8890
public commandsRouter!: CommandsRouter;
8991
public eventsRouter!: EventsRouter;

packages/commandkit/src/app/events/EventWorkerContext.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export interface EventWorkerContext {
77
namespace: string | null;
88
data: ParsedEvent;
99
commandkit: CommandKit;
10+
arguments: any[];
1011
}
1112

1213
export const eventWorkerContext = new AsyncLocalStorage<EventWorkerContext>();

packages/commandkit/src/app/handlers/AppEventsHandler.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ export class AppEventsHandler {
139139
namespace: namespace ?? null,
140140
data: data.event,
141141
commandkit: this.commandkit,
142+
arguments: args,
142143
},
143144
async () => {
144145
for (const listener of onListeners) {
@@ -181,6 +182,7 @@ export class AppEventsHandler {
181182
namespace: namespace ?? null,
182183
data: data.event,
183184
commandkit: this.commandkit,
185+
arguments: args,
184186
},
185187
async () => {
186188
try {

packages/commandkit/src/cli/build.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,9 +159,9 @@ async function injectEntryFile(
159159
${isDev ? `\n\n// Injected for development\n${wrapInAsyncIIFE([envScript(isDev), antiCrashScript, requireScript])}\n\n` : wrapInAsyncIIFE([envScript(isDev), requireScript])}
160160
161161
import { CommandKit } from 'commandkit';
162-
import app from './app.js';
163162
164163
async function bootstrap() {
164+
const app = await import('./app.js').then((m) => m.default ?? m);
165165
const commandkit = new CommandKit({
166166
client: app,
167167
});
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { getCommandKit, getContext } from '../context/async-context';
2+
import { eventWorkerContext } from '../app/events/EventWorkerContext';
3+
import { ParsedEvent } from '../app/router';
4+
import { CommandKit } from '../CommandKit';
5+
import {
6+
AutocompleteInteraction,
7+
ChatInputCommandInteraction,
8+
Client,
9+
ClientEvents,
10+
ContextMenuCommandInteraction,
11+
Events,
12+
Guild,
13+
Message,
14+
TextBasedChannel,
15+
} from 'discord.js';
16+
import { LoadedCommand } from '../app';
17+
import { FlagStore } from './store';
18+
19+
export type MaybePromise<T> = T | Promise<T>;
20+
21+
export type IdentifyFunction<R> = (
22+
context: EvaluationContext,
23+
) => MaybePromise<R>;
24+
25+
export type DecideFunction<E, R> = (data: { entities: E }) => MaybePromise<R>;
26+
27+
export interface FeatureFlagDefinition<R, Entity> {
28+
key: string;
29+
description?: string;
30+
identify?: IdentifyFunction<Entity>;
31+
decide: DecideFunction<Entity, R>;
32+
}
33+
34+
export interface CommandFlagContext {
35+
client: Client<true>;
36+
commandkit: CommandKit;
37+
command: {
38+
interaction?:
39+
| ChatInputCommandInteraction
40+
| AutocompleteInteraction
41+
| ContextMenuCommandInteraction;
42+
message?: Message;
43+
guild: Guild | null;
44+
channel: TextBasedChannel | null;
45+
command: LoadedCommand;
46+
};
47+
event: null;
48+
}
49+
50+
export interface EventFlagContext {
51+
client: Client<true>;
52+
commandkit: CommandKit;
53+
event: {
54+
data: ParsedEvent;
55+
event: string;
56+
namespace: string | null;
57+
arguments: any[];
58+
argumentsAs<E extends keyof ClientEvents>(event: E): ClientEvents[E];
59+
};
60+
command: null;
61+
}
62+
63+
export type EvaluationContext = CommandFlagContext | EventFlagContext;
64+
65+
export type CustomEvaluationFunction<E> = () => MaybePromise<E>;
66+
67+
export type CustomEvaluationContext<E> = {
68+
identify: E | CustomEvaluationFunction<E>;
69+
};
70+
71+
export interface FlagRunner<E, R> {
72+
(): Promise<R>;
73+
run(context: CustomEvaluationContext<E>): Promise<R>;
74+
}
75+
76+
export class FeatureFlag<R, T> {
77+
public constructor(private options: FeatureFlagDefinition<R, T>) {
78+
const FlagStore = getCommandKit(true).flags;
79+
80+
if (FlagStore.has(options.key)) {
81+
throw new Error(`Feature flag with key "${options.key}" already exists.`);
82+
}
83+
84+
FlagStore.set(options.key, this);
85+
}
86+
87+
private getContext(): EvaluationContext {
88+
const env = getContext();
89+
90+
if (env?.context) {
91+
const {
92+
client,
93+
commandkit,
94+
interaction,
95+
message,
96+
guild,
97+
channel,
98+
command,
99+
} = env.context;
100+
101+
return {
102+
client: client as Client<true>,
103+
commandkit,
104+
command: {
105+
interaction,
106+
message,
107+
guild,
108+
channel,
109+
command,
110+
},
111+
event: null,
112+
};
113+
}
114+
115+
const eventCtx = eventWorkerContext.getStore();
116+
117+
if (eventCtx) {
118+
const { commandkit, data, event, namespace } = eventCtx;
119+
120+
return {
121+
client: commandkit.client as Client<true>,
122+
commandkit,
123+
event: {
124+
data,
125+
event,
126+
namespace,
127+
arguments: eventCtx.arguments,
128+
argumentsAs: (eventName) => {
129+
const args = eventCtx.arguments as ClientEvents[typeof eventName];
130+
return args;
131+
},
132+
},
133+
command: null,
134+
};
135+
}
136+
137+
throw new Error(
138+
'Could not determine the execution context. Feature flags may only be used inside a command or event.',
139+
);
140+
}
141+
142+
public async execute(res?: T): Promise<R> {
143+
const { decide, identify } = this.options;
144+
145+
const entities =
146+
res ??
147+
(await (async () => {
148+
const ctx = this.getContext();
149+
return (await identify?.(ctx)) ?? ({} as T);
150+
})());
151+
152+
const decisionResult = await decide({
153+
entities,
154+
});
155+
156+
return decisionResult as R;
157+
}
158+
}
159+
160+
export function flag<Returns = boolean, Entity = Record<any, any>>(
161+
options: FeatureFlagDefinition<Returns, Entity>,
162+
): FlagRunner<Entity, Returns> {
163+
const flag = new FeatureFlag<Returns, Entity>(options);
164+
const runner = flag.execute.bind(flag, undefined) as FlagRunner<
165+
Entity,
166+
Returns
167+
>;
168+
169+
runner.run = async function (ctx) {
170+
if (!ctx?.identify) {
171+
throw new Error(
172+
'Custom evaluation context must have an identify function or object.',
173+
);
174+
}
175+
176+
const context = (
177+
typeof ctx === 'function'
178+
? await (ctx as CustomEvaluationFunction<Entity>)()
179+
: ctx
180+
) as Entity;
181+
182+
const decisionResult = await flag.execute(context);
183+
184+
return decisionResult;
185+
};
186+
187+
return runner;
188+
}

0 commit comments

Comments
 (0)