Repository navigation
feat(vue): add @tryabby/vue composables (useAbby, useFeatureFlag, useRemoteConfig) (#68) #215
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+37
to
+49
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add the missing
🐛 Proposed fix
Committable suggestion
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+51
to
+63
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add the missing
🐛 Proposed fix
Committable suggestion
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export const TestStorageService = new ABStorageService(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export const FlagStorageService = new FFStorageService(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export const RemoteConfigStorageService = new RCStorageService(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, TestVariant> = 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<TestName, ABConfig>, | ||
| const RemoteConfig extends Record<RemoteConfigName, RemoteConfigValueString>, | ||
| const RemoteConfigName extends Extract<keyof RemoteConfig, string>, | ||
| const User extends Record<string, ValidatorType> = Record< | ||
| string, | ||
| ValidatorType | ||
| >, | ||
| >( | ||
| abbyConfig: AbbyConfig< | ||
| FlagName, | ||
| Tests, | ||
| string[], | ||
| RemoteConfigName, | ||
| RemoteConfig, | ||
| User | ||
| > | ||
| ) { | ||
| const abby = new Abby< | ||
| FlagName, | ||
| TestName, | ||
| Tests, | ||
| RemoteConfig, | ||
| RemoteConfigName, | ||
| string[], | ||
| User | ||
| >( | ||
| abbyConfig, | ||
|
Comment on lines
+44
to
+53
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| { | ||
| 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<TestVariant, LookupValue> | undefined = undefined, | ||
| >(testName: TName, lookupObject?: Lookup) { | ||
| const rawVariant = abby.getTestVariant(testName); | ||
| const variantRef = ref(rawVariant) as Ref<TestVariant>; | ||
|
Comment on lines
+112
to
+113
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| // Send exposure PING on component mount / usage | ||
| onMounted(() => { | ||
| if (variantRef.value) { | ||
| reportExposure(testName as string, variantRef.value as string); | ||
| } | ||
| }); | ||
|
Comment on lines
+116
to
+120
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Guard
|
||
|
|
||
| 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<boolean> { | ||
| 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<RemoteConfigValueStringToType<Config>> { | ||
| const val = abby.getRemoteConfig(remoteConfigName); | ||
| return computed(() => val as RemoteConfigValueStringToType<Config>); | ||
| } | ||
|
|
||
| return { | ||
| useAbby, | ||
| useFeatureFlag, | ||
| useRemoteConfig, | ||
| __abby__: abby, | ||
| }; | ||
| } | ||
|
|
||
| export * from "./StorageService"; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| { | ||
| "extends": "tsconfig/base.json", | ||
| "compilerOptions": { | ||
| "lib": ["ESNext", "DOM"], | ||
| "types": ["vitest/globals"] | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"], | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Move the
.entry into anexportsfield.The
".": {...}block sits at the top level of the manifest. Node and bundlers ignore it. Onlymain,module, andtypestake effect. Wrap the block in"exports": { ... }so the conditional entry points work. If you addexports, check that thetypescondition comes first.🐛 Proposed fix
Committable suggestion