diff --git a/packages/vue/README.md b/packages/vue/README.md new file mode 100644 index 00000000..747ede1e --- /dev/null +++ b/packages/vue/README.md @@ -0,0 +1,36 @@ +# `@tryabby/vue` + +Official Vue 3 & Nuxt integration for [Abby](https://tryabby.com) feature flags, A/B testing, and remote configuration. + +## Installation + +```bash +pnpm add @tryabby/vue +``` + +## Quick Start + +```ts +import { createAbby, defineConfig } from "@tryabby/vue"; + +const config = defineConfig({ + projectId: "", + currentEnvironment: "production", + environments: ["production", "development"], + flags: ["darkMode"], + tests: { + heroBanner: { variants: ["Control", "VariantB"] }, + }, + remoteConfig: { + welcomeCopy: "String", + }, +}); + +export const { + AbbyProvider, + abbyPlugin, + useAbby, + useFeatureFlag, + useRemoteConfig, +} = createAbby(config); +``` diff --git a/packages/vue/package.json b/packages/vue/package.json new file mode 100644 index 00000000..8b78c06a --- /dev/null +++ b/packages/vue/package.json @@ -0,0 +1,42 @@ +{ + "name": "@tryabby/vue", + "version": "1.0.0", + "description": "Official Vue 3 & Nuxt integration for Abby feature flags, A/B testing, 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", + "dev": "pnpm run build --watch", + "test": "vitest run" + }, + "homepage": "https://docs.tryabby.com", + "keywords": ["abby", "vue", "nuxt", "feature-flags", "ab-testing", "remote-config"], + "license": "ISC", + "peerDependencies": { + "vue": "^3.2.0" + }, + "dependencies": { + "@tryabby/core": "workspace:*", + "js-cookie": "^3.0.1" + }, + "devDependencies": { + "@tryabby/devtools": "workspace:*", + "@types/js-cookie": "^3.0.3", + "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.0" + } +} diff --git a/packages/vue/src/StorageService.ts b/packages/vue/src/StorageService.ts new file mode 100644 index 00000000..39cc16cd --- /dev/null +++ b/packages/vue/src/StorageService.ts @@ -0,0 +1,74 @@ +import { + type IStorageService, + type StorageServiceOptions, + getABStorageKey, + getFFStorageKey, + getRCStorageKey, +} from "@tryabby/core"; +import Cookie from "js-cookie"; + +const DEFAULT_COOKIE_AGE = 365; + +class ABStorageService implements IStorageService { + get(projectId: string, testName: string): string | null { + const retrievedValue = Cookie.get(getABStorageKey(projectId, testName)); + if (!retrievedValue) return null; + return retrievedValue; + } + + set( + projectId: string, + testName: string, + value: string, + options?: StorageServiceOptions + ): void { + Cookie.set(getABStorageKey(projectId, testName), value, { + expires: options?.expiresInDays + ? options.expiresInDays + : DEFAULT_COOKIE_AGE, + }); + } + + remove(projectId: string, testName: string): void { + Cookie.remove(getABStorageKey(projectId, testName)); + } +} + +class FFStorageService implements IStorageService { + get(projectId: string, flagName: string): string | null { + const retrievedValue = Cookie.get(getFFStorageKey(projectId, flagName)); + if (!retrievedValue) return null; + return retrievedValue; + } + + set(projectId: string, flagName: string, value: string): void { + Cookie.set(getFFStorageKey(projectId, flagName), value, { + expires: DEFAULT_COOKIE_AGE, + }); + } + + remove(projectId: string, flagName: string): void { + Cookie.remove(getFFStorageKey(projectId, flagName)); + } +} + +class RCStorageService implements IStorageService { + get(projectId: string, key: string): string | null { + const retrievedValue = Cookie.get(getRCStorageKey(projectId, key)); + return retrievedValue ?? null; + } + + set(projectId: string, key: string, value: string): void { + Cookie.set(getRCStorageKey(projectId, key), value, { + expires: DEFAULT_COOKIE_AGE, + }); + } + + remove(projectId: string, key: string): void { + Cookie.remove(getRCStorageKey(projectId, key)); + } +} + +export const TestStorageService = new ABStorageService(); +export const FlagStorageService = new FFStorageService(); +export const RemoteConfigStorageService = new RCStorageService(); diff --git a/packages/vue/src/context.ts b/packages/vue/src/context.ts new file mode 100644 index 00000000..f596bf98 --- /dev/null +++ b/packages/vue/src/context.ts @@ -0,0 +1,387 @@ +import { + type ABConfig, + Abby, + type AbbyConfig, + type AbbyDataResponse, + AbbyEventType, + HttpService, + type RemoteConfigValueString, + type RemoteConfigValueStringToType, + type ValidatorType, +} from "@tryabby/core"; +import type { Infer } from "@tryabby/core/validation"; +import { + type App, + type ComputedRef, + type InjectionKey, + type PropType, + computed, + defineComponent, + getCurrentInstance, + inject, + onMounted, + onUnmounted, + provide, + ref, + watch, +} from "vue"; +import { + FlagStorageService, + RemoteConfigStorageService, + TestStorageService, +} from "./StorageService"; + +export type ABTestReturnValue = Lookup extends undefined + ? TestVariant + : TestVariant extends keyof Lookup + ? Lookup[TestVariant] + : never; + +export function createAbby< + const FlagName extends string, + const TestName extends string, + const Tests extends Record, + const RemoteConfig extends Record, + const RemoteConfigName extends Extract, + const User extends Record = Record< + string, + ValidatorType + >, +>( + abbyConfig: AbbyConfig< + FlagName, + Tests, + string[], + RemoteConfigName, + RemoteConfig, + User + > +) { + const config = abbyConfig; + + const abby = new Abby< + FlagName, + TestName, + Tests, + RemoteConfig, + RemoteConfigName, + string[], + User + >( + abbyConfig, + { + get: (key: string) => { + if (typeof window === "undefined") return null; + return TestStorageService.get(abbyConfig.projectId, key); + }, + set: (key: string, value: any) => { + if (typeof window === "undefined" || config.cookies?.disableByDefault) + return; + TestStorageService.set(abbyConfig.projectId, key, value); + }, + }, + { + get: (key: string) => { + if (typeof window === "undefined") return null; + return FlagStorageService.get(abbyConfig.projectId, key); + }, + set: (key: string, value: any) => { + if (typeof window === "undefined") return; + FlagStorageService.set(abbyConfig.projectId, key, value); + }, + }, + { + get: (key: string) => { + if (typeof window === "undefined") return null; + return RemoteConfigStorageService.get(abbyConfig.projectId, key); + }, + set: (key: string, value: any) => { + if (typeof window === "undefined") return; + RemoteConfigStorageService.set(abbyConfig.projectId, key, value); + }, + } + ); + + type AbbyProjectData = ReturnType; + + const AbbyInjectionKey: InjectionKey> = + Symbol("AbbyContext"); + + // Fallback reactive project data ref for plugin installation or standalone usage + const globalDataRef = ref(abby.getProjectData()); + let globalSubscribed = false; + + const ensureGlobalSubscription = () => { + if (!globalSubscribed) { + globalSubscribed = true; + abby.subscribe((newData) => { + globalDataRef.value = newData; + }); + } + }; + + const useAbbyData = (): ComputedRef => { + const instance = getCurrentInstance(); + if (instance) { + const injected = inject(AbbyInjectionKey, null); + if (injected) { + return injected; + } + } + ensureGlobalSubscription(); + return computed(() => globalDataRef.value); + }; + + const useAbby = < + K extends keyof Tests, + TestVariant extends Tests[K]["variants"][number], + LookupValue, + const Lookup extends + | Record + | undefined = undefined, + >( + name: K, + lookupObject?: Lookup + ): { + variant: ABTestReturnValue; + variantRef: ComputedRef>; + onAct: () => void; + } => { + const dataRef = useAbbyData(); + + const getSelectedVariant = (): string => { + return ( + dataRef.value.tests[name as unknown as TestName]?.selectedVariant ?? + abby.getProjectData().tests[name as unknown as TestName] + ?.selectedVariant ?? + "" + ); + }; + + const sendPing = (selectedVariant: string) => { + if (!name || !selectedVariant) return; + HttpService.sendData({ + url: config.apiUrl, + type: AbbyEventType.PING, + data: { + projectId: config.projectId, + selectedVariant, + testName: name as string, + }, + }); + }; + + const initialVariant = getSelectedVariant(); + if (initialVariant) { + sendPing(initialVariant); + } + + const variantRef = computed>(() => { + const selected = getSelectedVariant(); + if (lookupObject) { + return lookupObject[selected as TestVariant] as ABTestReturnValue< + Lookup, + TestVariant + >; + } + return selected as unknown as ABTestReturnValue; + }); + + if (getCurrentInstance()) { + watch( + () => dataRef.value.tests[name as unknown as TestName]?.selectedVariant, + (newVariant) => { + if (newVariant) { + sendPing(newVariant); + } + } + ); + } + + const onAct = () => { + const selectedVariant = getSelectedVariant(); + if (!selectedVariant) return; + + HttpService.sendData({ + url: config.apiUrl, + type: AbbyEventType.ACT, + data: { + projectId: config.projectId, + selectedVariant, + testName: name as string, + }, + }); + }; + + return { + variant: variantRef.value, + variantRef, + onAct, + }; + }; + + const useFeatureFlag = (name: FlagName): boolean => { + const dataRef = useAbbyData(); + return dataRef.value.flags[name].value; + }; + + const useFeatureFlags = () => { + const dataRef = useAbbyData(); + return (Object.keys(dataRef.value.flags) as Array).map( + (flagName) => ({ + name: flagName, + value: dataRef.value.flags[flagName].value, + }) + ); + }; + + const useRemoteConfig = < + T extends RemoteConfigName, + Config extends RemoteConfig[T], + >( + remoteConfigName: T + ): RemoteConfigValueStringToType => { + const dataRef = useAbbyData(); + return dataRef.value.remoteConfig[remoteConfigName] + .value as RemoteConfigValueStringToType; + }; + + const useRemoteConfigVariables = () => { + const dataRef = useAbbyData(); + return ( + Object.keys(dataRef.value.remoteConfig) as Array + ).map((configName) => ({ + name: configName, + value: dataRef.value.remoteConfig[configName].value, + })) as Array<{ + name: RemoteConfigName; + value: RemoteConfigValueStringToType; + }>; + }; + + const AbbyProvider = defineComponent({ + name: "AbbyProvider", + props: { + initialData: { + type: Object as PropType, + required: false, + }, + }, + setup(props, { slots }) { + const localData = ref( + props.initialData + ? abby.init(props.initialData) + : abby.getProjectData() + ); + + let unsubscribe: (() => void) | null = null; + + onMounted(() => { + if (!props.initialData) { + abby.loadProjectData().then((loaded) => { + if (loaded) { + localData.value = loaded; + } + }); + } + unsubscribe = abby.subscribe((updated) => { + localData.value = updated; + }); + }); + + onUnmounted(() => { + if (unsubscribe) { + unsubscribe(); + } + }); + + provide( + AbbyInjectionKey, + computed(() => localData.value) + ); + + return () => (slots.default ? slots.default() : null); + }, + }); + + const abbyPlugin = { + install(app: App, options?: { initialData?: AbbyDataResponse }) { + if (options?.initialData) { + globalDataRef.value = abby.init(options.initialData); + } else { + globalDataRef.value = abby.getProjectData(); + } + ensureGlobalSubscription(); + app.provide( + AbbyInjectionKey, + computed(() => globalDataRef.value) + ); + }, + }; + + const getFeatureFlagValue = (name: FlagName) => { + return abby.getFeatureFlag(name); + }; + + const getRemoteConfig = < + T extends RemoteConfigName, + Config extends RemoteConfig[T], + >( + remoteConfigName: T + ): RemoteConfigValueStringToType => { + return abby.getRemoteConfig(remoteConfigName); + }; + + const getABTestValue = < + K extends keyof Tests, + TestVariant extends Tests[K]["variants"][number], + LookupValue, + const Lookup extends + | Record + | undefined = undefined, + >( + testName: K, + lookupObject?: Lookup + ): ABTestReturnValue => { + const variant = abby.getTestVariant(testName as unknown as TestName); + if (lookupObject === undefined) { + return variant as any; + } + return lookupObject[variant as TestVariant] as any; + }; + + const getABResetFunction = (name: T) => { + return () => { + TestStorageService.remove(config.projectId, name as string); + }; + }; + + const getVariants = (name: T) => { + return abby.getVariants(name); + }; + + const updateUserProperties = ( + user: Partial<{ + -readonly [K in keyof User]: Infer; + }> + ) => { + abby.updateUserProperties(user); + }; + + return { + useAbby, + AbbyProvider, + abbyPlugin, + useFeatureFlag, + getFeatureFlagValue, + useRemoteConfig, + getRemoteConfig, + getABTestValue, + __abby__: abby, + getABResetFunction, + getVariants, + useFeatureFlags, + useRemoteConfigVariables, + updateUserProperties, + }; +} diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts new file mode 100644 index 00000000..9e38bc3a --- /dev/null +++ b/packages/vue/src/index.ts @@ -0,0 +1,5 @@ +export { + createAbby, + type ABTestReturnValue, +} from "./context"; +export { type ABConfig, type AbbyConfig, defineConfig } from "@tryabby/core"; diff --git a/packages/vue/tests/useAbby.test.ts b/packages/vue/tests/useAbby.test.ts new file mode 100644 index 00000000..2ed7fe06 --- /dev/null +++ b/packages/vue/tests/useAbby.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it, vi, beforeEach, afterAll, assertType } from "vitest"; +import { createAbby } from "../src"; +import { TestStorageService } from "../src/StorageService"; +import { HttpService, AbbyEventType } from "@tryabby/core"; + +const OLD_ENV = process.env; + +beforeEach(() => { + vi.restoreAllMocks(); + process.env = { ...OLD_ENV }; +}); + +afterAll(() => { + process.env = OLD_ENV; +}); + +describe("@tryabby/vue integration", () => { + it("useAbby returns selected variant and onAct callback", () => { + const setSpy = vi.spyOn(TestStorageService, "set"); + const sendSpy = vi.spyOn(HttpService, "sendData").mockImplementation(() => Promise.resolve()); + + const { useAbby } = createAbby({ + environments: ["production"], + currentEnvironment: "production", + projectId: "proj_vue_123", + tests: { + heroBanner: { variants: ["Control", "VariantB"] }, + }, + }); + + const { variant, onAct } = useAbby("heroBanner"); + + expect(setSpy).toHaveBeenCalled(); + expect(["Control", "VariantB"]).toContain(variant); + + onAct(); + expect(sendSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: AbbyEventType.ACT, + data: expect.objectContaining({ + projectId: "proj_vue_123", + testName: "heroBanner", + selectedVariant: variant, + }), + }) + ); + }); + + it("useAbby respects persisted variant and supports lookupObject mapping", () => { + vi.spyOn(TestStorageService, "get").mockReturnValue("VariantB"); + const setSpy = vi.spyOn(TestStorageService, "set"); + vi.spyOn(HttpService, "sendData").mockImplementation(() => Promise.resolve()); + + const { useAbby } = createAbby({ + environments: ["production"], + currentEnvironment: "production", + projectId: "proj_vue_123", + tests: { + heroBanner: { variants: ["Control", "VariantB"] as const }, + }, + }); + + const { variant } = useAbby("heroBanner", { + Control: 100, + VariantB: 200, + }); + + expect(setSpy).not.toHaveBeenCalled(); + expect(variant).toBe(200); + }); + + it("useFeatureFlag and useFeatureFlags return strongly typed flag values", () => { + const { useFeatureFlag, useFeatureFlags, __abby__ } = createAbby({ + environments: ["production"], + currentEnvironment: "production", + projectId: "proj_vue_123", + flags: ["darkMode", "betaCheckout"], + }); + + __abby__.init({ + flags: [ + { name: "darkMode", value: true }, + { name: "betaCheckout", value: false }, + ], + tests: [], + remoteConfig: [], + }); + + expect(useFeatureFlag("darkMode")).toBe(true); + expect(useFeatureFlag("betaCheckout")).toBe(false); + expect(useFeatureFlags()).toEqual([ + { name: "darkMode", value: true }, + { name: "betaCheckout", value: false }, + ]); + }); + + it("useRemoteConfig returns typed remote config values (String, Number, Boolean, JSON)", () => { + const { useRemoteConfig, useRemoteConfigVariables, __abby__ } = createAbby({ + environments: ["production"], + currentEnvironment: "production", + projectId: "proj_vue_123", + remoteConfig: { + welcomeMessage: "String", + maxRetries: "Number", + enablePromo: "Boolean", + }, + }); + + __abby__.init({ + flags: [], + tests: [], + remoteConfig: [ + { name: "welcomeMessage", value: "Hello Vue!" }, + { name: "maxRetries", value: 5 }, + { name: "enablePromo", value: true }, + ], + }); + + const msg = useRemoteConfig("welcomeMessage"); + const retries = useRemoteConfig("maxRetries"); + const promo = useRemoteConfig("enablePromo"); + + expect(msg).toBe("Hello Vue!"); + expect(retries).toBe(5); + expect(promo).toBe(true); + expect(useRemoteConfigVariables()).toHaveLength(3); + }); + + it("provides strict TypeScript type inference matching @tryabby/react", () => { + const variants = ["Alpha", "Beta", "Gamma"] as const; + const { useAbby, useFeatureFlag, useRemoteConfig } = createAbby({ + environments: ["dev"], + currentEnvironment: "dev", + projectId: "proj_types", + flags: ["newNav"], + tests: { + single: { variants: ["ONLY_ONE"] }, + multi: { variants }, + }, + remoteConfig: { + strKey: "String", + numKey: "Number", + boolKey: "Boolean", + }, + }); + + const resSingle = useAbby("single"); + assertType<"ONLY_ONE">(resSingle.variant); + + const resMulti = useAbby("multi"); + assertType<"Alpha" | "Beta" | "Gamma">(resMulti.variant); + + const resMapped = useAbby("multi", { + Alpha: { id: 1 }, + Beta: { id: 2 }, + Gamma: { id: 3 }, + }); + assertType<{ id: number }>(resMapped.variant); + + const flagVal = useFeatureFlag("newNav"); + assertType(flagVal); + + assertType(useRemoteConfig("strKey")); + assertType(useRemoteConfig("numKey")); + assertType(useRemoteConfig("boolKey")); + }); +}); diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json new file mode 100644 index 00000000..caf39aa8 --- /dev/null +++ b/packages/vue/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "tsconfig/base.json", + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "types": ["vitest/globals"] + } +} diff --git a/packages/vue/tsup.config.ts b/packages/vue/tsup.config.ts new file mode 100644 index 00000000..f3bd88d2 --- /dev/null +++ b/packages/vue/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + dts: true, + noExternal: ["shared/src/types"], + clean: true, + format: ["cjs", "esm"], + sourcemap: true, + treeshake: true, +}); diff --git a/packages/vue/vite.config.ts b/packages/vue/vite.config.ts new file mode 100644 index 00000000..5c7108aa --- /dev/null +++ b/packages/vue/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + test: { + globals: true, + environment: "jsdom", + }, +});