Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions packages/vue/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"name": "@tryabby/vue",
"version": "1.0.0",
"description": "Vue 3 composables integration for Abby A/B testing, Feature Flags, and Remote Config",
"main": "dist/index.js",
"files": ["dist"],
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts --external vue",
"dev": "pnpm run build --watch",
"test": "vitest run --environment jsdom"
},
"homepage": "https://docs.tryabby.com",
"keywords": ["abby", "vue", "ab-testing", "feature-flags", "remote-config"],
"author": "",
"license": "ISC",
"devDependencies": {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
"jsdom": "^20.0.3",
"tsconfig": "workspace:*",
"tsup": "^6.5.0",
"typescript": "5.5.4",
"vite": "5.4.0",
"vitest": "2.0.5",
"vue": "^3.4.38"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
},
"peerDependencies": {
"vue": "^3.0.0"
},
"peerDependenciesMeta": {
"vue": {
"optional": true
}
},
"dependencies": {
"@tryabby/core": "workspace:*"
}
}
220 changes: 220 additions & 0 deletions packages/vue/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import { AbbyEventType, HttpService } from "@tryabby/core";
import {
type App,
type ComputedRef,
type InjectionKey,
type Plugin,
computed,
getCurrentInstance,
inject,
onMounted,
onUnmounted,
provide,
ref,
} from "vue";

export interface AbbyCoreClient<
TConfig extends Record<string, any> = Record<string, any>,
TFlag extends string = string,
TTest extends string = string,
> {
getTestVariant?: (testId: TTest) => string;
getVariant?: (testId: TTest) => string;
onAct?: (testId: TTest, selectedVariant?: string) => void;
getConfig?: () => {
projectId: string;
apiUrl?: string;
currentEnvironment?: string;
};
getFeatureFlag: (flagKey: TFlag) => boolean;
getRemoteConfig: <K extends Extract<keyof TConfig, string>>(
configKey: K
) => TConfig[K];
subscribe?: (listener: (data?: any) => void) => () => void;
}

export const ABBY_INJECTION_KEY: InjectionKey<AbbyCoreClient<any, any, any>> =
Symbol("AbbyVueClient");

function resolveTestVariant<
TConfig extends Record<string, any>,
TFlag extends string,
TTest extends string,
>(client: AbbyCoreClient<TConfig, TFlag, TTest>, testId: TTest): string {
if (typeof client.getTestVariant === "function") {
return client.getTestVariant(testId) ?? "";
}
if (typeof client.getVariant === "function") {
return client.getVariant(testId) ?? "";
}
throw new Error(
"[@tryabby/vue] Provided Abby client must implement getTestVariant(testId) or getVariant(testId)."
);
}

export function createAbbyPlugin<
TConfig extends Record<string, any> = Record<string, any>,
TFlag extends string = string,
TTest extends string = string,
>(client: AbbyCoreClient<TConfig, TFlag, TTest>): Plugin {
return {
install(app: App) {
app.provide(ABBY_INJECTION_KEY, client);
},
};
}

export function provideAbby<
TConfig extends Record<string, any> = Record<string, any>,
TFlag extends string = string,
TTest extends string = string,
>(
client: AbbyCoreClient<TConfig, TFlag, TTest>
): AbbyCoreClient<TConfig, TFlag, TTest> {
provide(ABBY_INJECTION_KEY, client);
return client;
}

export function useAbbyClient<
TConfig extends Record<string, any> = Record<string, any>,
TFlag extends string = string,
TTest extends string = string,
>(): AbbyCoreClient<TConfig, TFlag, TTest> {
const client = inject(ABBY_INJECTION_KEY);
if (!client) {
throw new Error(
"[@tryabby/vue] Abby client not found. Register via app.use(createAbbyPlugin(client)) or provideAbby(client)."
);
}
return client as AbbyCoreClient<TConfig, TFlag, TTest>;
}

export function useAbby<TTest extends string = string>(
testId: TTest
): {
variant: ComputedRef<string>;
onAct: () => void;
} {
const client = useAbbyClient<Record<string, any>, string, TTest>();
const version = ref(0);
const isMounted = ref(false);

let unsubscribe: (() => void) | undefined;

if (getCurrentInstance()) {
onMounted(() => {
isMounted.value = true;
if (typeof client.subscribe === "function") {
unsubscribe = client.subscribe(() => {
version.value += 1;
});
}
});
onUnmounted(() => {
unsubscribe?.();
unsubscribe = undefined;
});
}

const variant = computed(() => {
void version.value;
if (!isMounted.value) {
return "";
}
return resolveTestVariant(client, testId);
});

const onAct = () => {
const currentVariant = variant.value;
if (!currentVariant) return;
if (typeof client.onAct === "function") {
client.onAct(testId, currentVariant);
return;
}
const cfg =
typeof client.getConfig === "function" ? client.getConfig() : undefined;
if (cfg?.projectId) {
HttpService.sendData({
url: cfg.apiUrl,
type: AbbyEventType.ACT,
data: {
projectId: cfg.projectId,
selectedVariant: currentVariant,
testName: testId,
},
});
}
};

return { variant, onAct };
}

export function useFeatureFlag<TFlag extends string = string>(
flagKey: TFlag
): ComputedRef<boolean> {
const client = useAbbyClient<Record<string, any>, TFlag, string>();
const version = ref(0);

let unsubscribe: (() => void) | undefined;
if (getCurrentInstance()) {
onMounted(() => {
if (typeof client.subscribe === "function") {
unsubscribe = client.subscribe(() => {
version.value += 1;
});
}
});
onUnmounted(() => {
unsubscribe?.();
unsubscribe = undefined;
});
}

return computed(() => {
void version.value;
return Boolean(client.getFeatureFlag(flagKey));
});
}

export function useRemoteConfig<
TConfig extends Record<string, any> = Record<string, any>,
K extends Extract<keyof TConfig, string> = Extract<keyof TConfig, string>,
>(configKey: K): ComputedRef<TConfig[K]> {
const client = useAbbyClient<TConfig, string, string>();
const version = ref(0);

let unsubscribe: (() => void) | undefined;
if (getCurrentInstance()) {
onMounted(() => {
if (typeof client.subscribe === "function") {
unsubscribe = client.subscribe(() => {
version.value += 1;
});
}
});
onUnmounted(() => {
unsubscribe?.();
unsubscribe = undefined;
});
}

return computed(() => {
void version.value;
return client.getRemoteConfig(configKey);
});
}

export function createAbbyVue<
TConfig extends Record<string, any> = Record<string, any>,
TFlag extends string = string,
TTest extends string = string,
>(client: AbbyCoreClient<TConfig, TFlag, TTest>) {
return {
plugin: createAbbyPlugin(client),
provideAbby: () => provideAbby(client),
useAbby: (testId: TTest) => useAbby<TTest>(testId),
useFeatureFlag: (flagKey: TFlag) => useFeatureFlag<TFlag>(flagKey),
useRemoteConfig: <K extends Extract<keyof TConfig, string>>(configKey: K) =>
useRemoteConfig<TConfig, K>(configKey),
};
}
Loading