From e6fae8573fceaa51677b23a848481ba1c9d01114 Mon Sep 17 00:00:00 2001 From: dukenordj-rgb Date: Wed, 4 Nov 2026 01:37:18 -0300 Subject: [PATCH 1/2] feat(vue): add @tryabby/vue composables integration (#68) --- packages/vue/src/useAbby.ts | 38 +++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 packages/vue/src/useAbby.ts diff --git a/packages/vue/src/useAbby.ts b/packages/vue/src/useAbby.ts new file mode 100644 index 00000000..d8a64aca --- /dev/null +++ b/packages/vue/src/useAbby.ts @@ -0,0 +1,38 @@ +/** + * @tryabby/vue - Official Vue 3 & Nuxt Composable Integration for Abby + * Resolves: tryabby/abby#68 (/claim #68) + * Implements: + * - useAbby(testName) -> returns reactive variant and onAct callback + * - useFeatureFlag(flagKey) -> returns reactive boolean feature flag value + * - useRemoteConfig(configKey) -> returns reactive typed remote configuration value + */ + +export interface AbbyClientCore { + getVariant(testName: string): string; + onAct(testName: string): void; + getFeatureFlag(flagKey: string): boolean; + getRemoteConfig(configKey: string): T; + subscribe(listener: () => void): () => void; +} + +export function createAbbyVueComposables(client: AbbyClientCore) { + function useAbby(testName: string) { + const variant = client.getVariant(testName); + const onAct = () => client.onAct(testName); + return { variant, onAct }; + } + + function useFeatureFlag(flagKey: string): boolean { + return Boolean(client.getFeatureFlag(flagKey)); + } + + function useRemoteConfig(configKey: string): T { + return client.getRemoteConfig(configKey); + } + + return { + useAbby, + useFeatureFlag, + useRemoteConfig + }; +} From 8069ac84d079c25cb7a7bcd36a56acfba78a8fb0 Mon Sep 17 00:00:00 2001 From: dukenordj-rgb Date: Sat, 7 Nov 2026 11:14:17 -0300 Subject: [PATCH 2/2] fix(vue): add package manifest, tsup build config, exposure PING reporting and typed remote config (addresses CodeRabbit review) --- packages/vue/package.json | 41 +++++++ packages/vue/src/StorageService.ts | 67 +++++++++++ packages/vue/src/index.ts | 175 +++++++++++++++++++++++++++++ packages/vue/src/useAbby.ts | 38 ------- packages/vue/tsconfig.json | 7 ++ packages/vue/tsup.config.ts | 10 ++ 6 files changed, 300 insertions(+), 38 deletions(-) create mode 100644 packages/vue/package.json create mode 100644 packages/vue/src/StorageService.ts create mode 100644 packages/vue/src/index.ts delete mode 100644 packages/vue/src/useAbby.ts create mode 100644 packages/vue/tsconfig.json create mode 100644 packages/vue/tsup.config.ts diff --git a/packages/vue/package.json b/packages/vue/package.json new file mode 100644 index 00000000..3977562a --- /dev/null +++ b/packages/vue/package.json @@ -0,0 +1,41 @@ +{ + "name": "@tryabby/vue", + "version": "7.0.1", + "description": "Official Vue 3 & Nuxt integration for Abby", + "main": "dist/index.js", + "files": ["dist"], + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + }, + "scripts": { + "build": "tsup src/", + "dev": "pnpm run build --watch", + "test": "vitest" + }, + "homepage": "https://docs.tryabby.com", + "keywords": ["abby", "ab-testing", "vue", "vue3", "nuxt", "feature-flags", "remote-config"], + "author": "", + "license": "ISC", + "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" + }, + "peerDependencies": { + "vue": "^3.2.0" + }, + "dependencies": { + "@tryabby/core": "workspace:*", + "js-cookie": "^3.0.1" + } +} diff --git a/packages/vue/src/StorageService.ts b/packages/vue/src/StorageService.ts new file mode 100644 index 00000000..810ab12a --- /dev/null +++ b/packages/vue/src/StorageService.ts @@ -0,0 +1,67 @@ +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, + }); + } +} + +class RCStorageService implements IStorageService { + get(projectId: string, configName: string): string | null { + const retrievedValue = Cookie.get(getRCStorageKey(projectId, configName)); + if (!retrievedValue) return null; + return retrievedValue; + } + + set(projectId: string, configName: string, value: string): void { + Cookie.set(getRCStorageKey(projectId, configName), value, { + expires: DEFAULT_COOKIE_AGE, + }); + } +} + +export const TestStorageService = new ABStorageService(); +export const FlagStorageService = new FFStorageService(); +export const RemoteConfigStorageService = new RCStorageService(); diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts new file mode 100644 index 00000000..dd436e5f --- /dev/null +++ b/packages/vue/src/index.ts @@ -0,0 +1,175 @@ +import { + type ABConfig, + Abby, + type AbbyConfig, + type RemoteConfigValueString, + type RemoteConfigValueStringToType, + type ValidatorType, + HttpService, + AbbyEventType, +} from "@tryabby/core"; +import { ref, computed, onMounted, type Ref, type ComputedRef } 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 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" || abbyConfig.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); + }, + } + ); + + /** + * Reports an experiment exposure (PING) to the Abby analytics backend + */ + function reportExposure(testName: string, selectedVariant: string) { + if (!testName || !selectedVariant) return; + HttpService.sendData({ + url: abbyConfig.apiUrl, + type: AbbyEventType.PING, + data: { + projectId: abbyConfig.projectId, + selectedVariant, + testName, + }, + }); + } + + /** + * Vue 3 Composable for A/B Testing with automatic exposure reporting and reactivity + */ + function useAbby< + TName extends keyof Tests, + TestVariant extends Tests[TName]["variants"][number], + LookupValue = undefined, + const Lookup extends Record | undefined = undefined, + >(testName: TName, lookupObject?: Lookup) { + const rawVariant = abby.getTestVariant(testName); + const variantRef = ref(rawVariant) as Ref; + + // Send exposure PING on component mount / usage + onMounted(() => { + if (variantRef.value) { + reportExposure(testName as string, variantRef.value as string); + } + }); + + const onAct = () => { + if (!variantRef.value) return; + HttpService.sendData({ + url: abbyConfig.apiUrl, + type: AbbyEventType.ACT, + data: { + projectId: abbyConfig.projectId, + selectedVariant: variantRef.value as string, + testName: testName as string, + }, + }); + }; + + const variant = computed(() => { + if (lookupObject === undefined) { + return variantRef.value; + } + return lookupObject[variantRef.value as TestVariant]; + }); + + return { + variant, + onAct, + }; + } + + /** + * Vue 3 Composable for Feature Flags + */ + function useFeatureFlag(flagName: FlagName): ComputedRef { + const val = abby.getFeatureFlag(flagName); + return computed(() => Boolean(val)); + } + + /** + * Vue 3 Composable for Remote Config with strict key-to-type derivation + */ + function useRemoteConfig< + T extends RemoteConfigName, + Config extends RemoteConfig[T], + >(remoteConfigName: T): ComputedRef> { + const val = abby.getRemoteConfig(remoteConfigName); + return computed(() => val as RemoteConfigValueStringToType); + } + + return { + useAbby, + useFeatureFlag, + useRemoteConfig, + __abby__: abby, + }; +} + +export * from "./StorageService"; diff --git a/packages/vue/src/useAbby.ts b/packages/vue/src/useAbby.ts deleted file mode 100644 index d8a64aca..00000000 --- a/packages/vue/src/useAbby.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * @tryabby/vue - Official Vue 3 & Nuxt Composable Integration for Abby - * Resolves: tryabby/abby#68 (/claim #68) - * Implements: - * - useAbby(testName) -> returns reactive variant and onAct callback - * - useFeatureFlag(flagKey) -> returns reactive boolean feature flag value - * - useRemoteConfig(configKey) -> returns reactive typed remote configuration value - */ - -export interface AbbyClientCore { - getVariant(testName: string): string; - onAct(testName: string): void; - getFeatureFlag(flagKey: string): boolean; - getRemoteConfig(configKey: string): T; - subscribe(listener: () => void): () => void; -} - -export function createAbbyVueComposables(client: AbbyClientCore) { - function useAbby(testName: string) { - const variant = client.getVariant(testName); - const onAct = () => client.onAct(testName); - return { variant, onAct }; - } - - function useFeatureFlag(flagKey: string): boolean { - return Boolean(client.getFeatureFlag(flagKey)); - } - - function useRemoteConfig(configKey: string): T { - return client.getRemoteConfig(configKey); - } - - return { - useAbby, - useFeatureFlag, - useRemoteConfig - }; -} 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..0a287271 --- /dev/null +++ b/packages/vue/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + dts: true, + clean: true, + format: ["cjs", "esm"], + sourcemap: true, + treeshake: true, + entry: ["src/index.ts"], +});