Skip to content

Commit acfb6e7

Browse files
committed
feat: add LaunchDarkly integration
1 parent 3b923f1 commit acfb6e7

15 files changed

+245
-183
lines changed

src/core/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from "./keat";
2+
export * from "./matchers";
23
export * from "./plugin";
34
export * from "./types";
45
export * from "./utils";

src/core/keat.ts

+44-55
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
11
import { load } from "./display";
2-
import type { EvalCtx, OnEvalApi, OnPluginInitApi, Plugin } from "./plugin";
2+
import type { EvalCtx, OnEvalApi, Plugin } from "./plugin";
33
import type {
44
AnyFeatures,
55
Config,
66
Display,
77
KeatApi,
88
KeatInit,
9-
Literal,
109
Rule,
1110
User,
1211
} from "./types";
13-
import { mutable } from "./utils";
12+
import { getRules, getVariatesMap, isLiteral } from "./utils";
1413

1514
export type ExtractFeatures<K> = K extends KeatApi<infer K> ? keyof K : never;
1615

@@ -22,49 +21,47 @@ export function keatCore<TFeatures extends AnyFeatures>({
2221
display = "swap",
2322
plugins = [],
2423
}: KeatInit<TFeatures>): KeatApi<TFeatures> {
25-
let listeners: Listener[] = [];
24+
const names = Object.keys(features);
25+
const variatesMap = getVariatesMap(features);
2626
let defaultDisplay = display;
2727
let defaultUser: User | undefined = undefined;
28+
2829
let configId = 0;
2930
let config: Config = {};
31+
const setConfig = (newConfig: Config) => {
32+
configId += 1;
33+
config = newConfig;
34+
};
3035

31-
const initApi: OnPluginInitApi = {
32-
setConfig: (newConfig) => {
33-
configId += 1;
34-
config = newConfig;
35-
},
36-
onChange: () => {
37-
listeners.forEach((l) => l(config));
38-
},
36+
let listeners: Listener[] = [];
37+
const handleChange = () => {
38+
listeners.forEach((l) => l(config));
3939
};
4040

41-
const loader = load(
42-
Promise.allSettled(
43-
plugins.map((p) => p.onPluginInit?.({ features }, initApi))
41+
const initPromise = Promise.allSettled(
42+
plugins.map((p) =>
43+
p.onPluginInit?.(
44+
{ features: names, variates: variatesMap },
45+
{
46+
setConfig,
47+
onChange: handleChange,
48+
}
49+
)
4450
)
45-
);
46-
47-
function getVariates(feature: string): any[] {
48-
const feat = features[feature];
49-
return typeof feat === "object" && "variates" in feat
50-
? mutable(feat.variates) ?? [true, false]
51-
: [true, false];
52-
}
53-
54-
const getRules = (feature: string, configId: number): Rule[] | undefined => {
55-
const feat = features[feature];
56-
const remote = config[feature];
57-
const local = isRule(feat) ? feat : (feat["when"] as Rule | Rule[]);
58-
return configId === 0 ? normalize(local) : normalize(remote ?? local);
59-
};
51+
).then(() => {
52+
if (configId === 0) return;
53+
handleChange();
54+
});
55+
56+
let loader = load(initPromise);
6057

6158
const evaluate = (
6259
feature: string,
6360
user: User | undefined,
6461
configId: number
6562
): any => {
66-
const variates = getVariates(feature);
67-
const rules = getRules(feature, configId);
63+
const variates = variatesMap[feature];
64+
const rules = getRules(features, config, feature, configId);
6865
if (!rules) return variates[variates.length - 1];
6966

7067
let result: unknown;
@@ -108,12 +105,22 @@ export function keatCore<TFeatures extends AnyFeatures>({
108105
return {
109106
ready: (display: Display = defaultDisplay) => loader.ready(display),
110107
configure: (newConfig: Config) => {
111-
initApi.setConfig(newConfig);
112-
initApi.onChange();
108+
setConfig(newConfig);
109+
handleChange();
113110
},
114-
identify: (user?: User) => {
111+
identify: (user?: User, noReload?: boolean) => {
115112
defaultUser = user;
116113
plugins.forEach((p) => p.onIdentify);
114+
if (noReload) return;
115+
const currentId = configId;
116+
loader = load(
117+
Promise.allSettled(plugins.map((p) => p.onIdentify?.({ user }))).then(
118+
() => {
119+
if (configId === currentId) return;
120+
handleChange();
121+
}
122+
)
123+
);
117124
},
118125
setDisplay: (display: Display) => (defaultDisplay = display),
119126
variation: <TFeature extends keyof TFeatures>(
@@ -135,7 +142,7 @@ export function keatCore<TFeatures extends AnyFeatures>({
135142

136143
function evaluateVariate(
137144
ctx: EvalCtx,
138-
plugins: Plugin[],
145+
plugins: Plugin<any>[],
139146
rule: Rule | undefined
140147
): boolean {
141148
if (rule === undefined) return false;
@@ -145,26 +152,8 @@ function evaluateVariate(
145152
for (const matcher of matchers) {
146153
const literal = matcher(rule);
147154
if (literal === null) continue;
148-
return p.evaluate({ ...ctx, literal });
155+
return p.evaluate?.({ ...ctx, literal }) ?? false;
149156
}
150157
})
151158
: rule.OR.some((r) => evaluateVariate(ctx, plugins, r));
152159
}
153-
154-
function isLiteral(rule: Rule): rule is Literal {
155-
const t = typeof rule;
156-
return t === "string" || t === "number" || t === "boolean";
157-
}
158-
159-
function normalize(rule: Rule | Rule[] | undefined): Rule[] | undefined {
160-
return Array.isArray(rule) ? rule : rule === undefined ? undefined : [rule];
161-
}
162-
163-
function isRule(x: unknown): x is Rule {
164-
return (
165-
typeof x === "boolean" ||
166-
typeof x === "string" ||
167-
typeof x === "number" ||
168-
(typeof x === "object" && x !== null && "OR" in x)
169-
);
170-
}

src/core/matchers.ts

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
export type Matcher<T = any> = (literal: unknown) => T | null;
2+
3+
export const isNone: Matcher<never> = () => {
4+
return null;
5+
};
6+
7+
export const isAny: Matcher<any> = (literal) => {
8+
return literal;
9+
};
10+
11+
export const isBoolean: Matcher<boolean> = (literal) => {
12+
return typeof literal === "boolean" ? literal : null;
13+
};
14+
15+
export const isString: Matcher<string> = (literal) => {
16+
return typeof literal === "string" ? literal : null;
17+
};
18+
19+
export const isNumber: Matcher<number> = (literal) => {
20+
return typeof literal === "number" ? literal : null;
21+
};
22+
23+
export const isDate: Matcher<Date> = (literal) => {
24+
const date = typeof literal === "string" ? Date.parse(literal) : NaN;
25+
return isNaN(date) ? new Date(date) : null;
26+
};

src/core/plugin.ts

+24-43
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Config, User } from ".";
2+
import { isNone, Matcher } from "./matchers";
23
import { Rule } from "./types";
34

45
export type LegacyPlugin = {
@@ -19,7 +20,8 @@ export type OnPluginInitHook = (
1920
) => void | Promise<void>;
2021

2122
export type OnPluginInitCtx = {
22-
features: Record<string, any>;
23+
features: string[];
24+
variates: Record<string, any[]>;
2325
config?: Config;
2426
};
2527

@@ -55,12 +57,7 @@ export type OnEvalApi = {
5557

5658
export type onPostEvaluateHook = (ctx: EvalCtx & { result: unknown }) => void;
5759

58-
//////
59-
60-
// { OR: ["a", "b", "c", { AND: ["d", "e"] }]}
61-
export type Matcher<T = any> = (literal: unknown) => T | null;
62-
63-
export type Plugin<M extends Matcher = Matcher> = {
60+
export type Plugin<M extends Matcher> = {
6461
/**
6562
* Invoked when a plugin is initialized.
6663
*/
@@ -72,62 +69,46 @@ export type Plugin<M extends Matcher = Matcher> = {
7269
onIdentify?: OnPluginIdentifyHook;
7370

7471
/**
75-
* Whether a literal matches this plugin.
72+
* Invoked when the evaluation starts.
7673
*/
77-
matcher: M | M[];
74+
onPreEvaluate?: onPreEvaluateHook;
7875

7976
/**
80-
* Evaluates the matched literal.
77+
* Invoked when the evaluation ends.
8178
*/
82-
evaluate: (ctx: EvaluateContext<InferLiteralType<M>>) => boolean;
79+
onPostEvaluate?: onPostEvaluateHook;
8380

8481
/**
85-
* Invoked when the evaluation starts.
82+
* Whether a literal matches this plugin.
83+
*
84+
* The matcher decides whether `evaluate` is invoked, Use `isNone` to skip evaluation.
85+
*
86+
* @remark Consider a helper: `isNone`, `isAny`, `isBoolean`, `isString`, `isNumber` or `isDate`.
8687
*/
87-
onPreEvaluate?: onPreEvaluateHook;
88+
matcher: M | M[];
8889

8990
/**
90-
* Invoked when the evaluation ends.
91+
* Evaluates the matched literal.
92+
*
93+
* @remark The matcher infers the type of your literal.
9194
*/
92-
onPostEvaluate?: onPostEvaluateHook;
95+
evaluate?: (ctx: EvaluateContext<InferLiteralType<M>>) => boolean;
9396
};
9497

9598
type EvaluateContext<TLiteral> = EvalCtx & {
9699
literal: TLiteral;
97100
};
98101

102+
type InferLiteralType<M extends Matcher<any>> = M extends Matcher<infer T>
103+
? T
104+
: any;
105+
99106
export function createPlugin<M extends Matcher>(plugin: Plugin<M>) {
100107
return plugin;
101108
}
102109

103-
export function createNopPlugin(): Plugin {
110+
export function createNopPlugin(): Plugin<Matcher<void>> {
104111
return {
105-
matcher: (literal) => literal,
106-
evaluate: () => false,
112+
matcher: isNone,
107113
};
108114
}
109-
110-
export const isAny: Matcher<any> = (literal) => {
111-
return literal;
112-
};
113-
114-
export const isBoolean: Matcher<boolean> = (literal) => {
115-
return typeof literal === "boolean" ? literal : null;
116-
};
117-
118-
export const isString: Matcher<string> = (literal) => {
119-
return typeof literal === "string" ? literal : null;
120-
};
121-
122-
export const isNumber: Matcher<number> = (literal) => {
123-
return typeof literal === "number" ? literal : null;
124-
};
125-
126-
export const isDate: Matcher<Date> = (literal) => {
127-
const date = typeof literal === "string" ? Date.parse(literal) : NaN;
128-
return isNaN(date) ? new Date(date) : null;
129-
};
130-
131-
type InferLiteralType<M extends Matcher<any>> = M extends Matcher<infer T>
132-
? T
133-
: any;

src/core/utils.ts

+65-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Rule } from "./types";
1+
import { AnyFeatures, Config, Feature, Literal, Rule } from "./types";
22

33
/**
44
* Utility which transforms an environment variable into a properly typed array.
@@ -46,3 +46,67 @@ export function takeBooleans(rule: Rule): boolean[] {
4646
export function mutable<T>(x?: readonly T[]): T[] | undefined {
4747
return x as T[];
4848
}
49+
50+
export function getVariatesMap(
51+
features: Record<string, Feature>
52+
): Record<string, any[]> {
53+
const names = Object.keys(features);
54+
const entries = names.map((name) => [name, getVariates(features, name)]);
55+
return Object.fromEntries(entries);
56+
}
57+
58+
export function getVariates(features: AnyFeatures, name: string): any[] {
59+
const feat = features[name];
60+
return typeof feat === "object" && "variates" in feat
61+
? mutable(feat.variates) ?? [true, false]
62+
: [true, false];
63+
}
64+
65+
export function getRules(
66+
features: AnyFeatures,
67+
config: Config,
68+
name: string,
69+
configId: number
70+
): Rule[] | undefined {
71+
const feat = features[name];
72+
const remote = config[name];
73+
const local = isRule(feat) ? feat : (feat["when"] as Rule | Rule[]);
74+
return configId === 0 ? normalize(local) : normalize(remote ?? local);
75+
}
76+
77+
function isRule(x: unknown): x is Rule {
78+
return (
79+
typeof x === "boolean" ||
80+
typeof x === "string" ||
81+
typeof x === "number" ||
82+
(typeof x === "object" && x !== null && "OR" in x)
83+
);
84+
}
85+
86+
export function isLiteral(rule: Rule): rule is Literal {
87+
const t = typeof rule;
88+
return t === "string" || t === "number" || t === "boolean";
89+
}
90+
91+
function normalize(rule: Rule | Rule[] | undefined): Rule[] | undefined {
92+
return Array.isArray(rule) ? rule : rule === undefined ? undefined : [rule];
93+
}
94+
95+
export function flagsToConfig(
96+
flags: Record<string, any>,
97+
variates: Record<string, any[]>
98+
): Config {
99+
const config: Config = {};
100+
101+
for (const [feature, variate] of Object.entries(flags)) {
102+
const variations = variates[feature];
103+
if (!variations) continue;
104+
const rule = variations.map((v) => v === variate);
105+
const isFalse = rule.length === 2 && rule[0] === false;
106+
const isTrue = rule.length === 2 && rule[0] === true;
107+
const simplifiedRule = isFalse ? false : isTrue ? true : rule;
108+
config[feature] = simplifiedRule;
109+
}
110+
111+
return config;
112+
}

0 commit comments

Comments
 (0)