diff --git a/packages/vue/README.md b/packages/vue/README.md new file mode 100644 index 00000000..48950160 --- /dev/null +++ b/packages/vue/README.md @@ -0,0 +1,46 @@ +# `@tryabby/vue` + +Typed Vue 3 integration for [Abby](https://tryabby.dev) A/B testing, feature flags, and remote configuration. + +## Client-Only Usage (SPA) + +```ts +import { createAbby } from "@tryabby/vue"; + +export const { plugin, useAbTest, useFeatureFlag, useRemoteConfig } = createAbby({ + projectId: "YOUR_PROJECT_ID", + currentEnvironment: "production", + environments: ["production", "development"], + tests: { + headerTest: { variants: ["A", "B"] }, + }, + flags: ["darkMode"], + remoteConfig: { + maxItems: "number", + }, +}); +``` + +## SSR Usage (Per-Request Instance) + +In SSR environments (e.g., Nuxt / Vite SSR), create a fresh Abby instance per request so `updateUserProperties` and state are never shared across concurrent requests: + +```ts +import { createSSRApp } from "vue"; +import { createAbby } from "@tryabby/vue"; +import App from "./App.vue"; + +export function createApp() { + const app = createSSRApp(App); + const abby = createAbby({ + projectId: "YOUR_PROJECT_ID", + currentEnvironment: "production", + environments: ["production", "development"], + tests: { headerTest: { variants: ["A", "B"] } }, + flags: ["darkMode"], + remoteConfig: { maxItems: "number" }, + }); + app.use(abby.plugin); + return { app, abby }; +} +``` diff --git a/packages/vue/package.json b/packages/vue/package.json new file mode 100644 index 00000000..f0dd99ec --- /dev/null +++ b/packages/vue/package.json @@ -0,0 +1,33 @@ +{ + "name": "@tryabby/vue", + "version": "1.0.0", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "sideEffects": false, + "license": "MIT", + "files": [ + "dist/**" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest run", + "check-types": "tsc --noEmit" + }, + "peerDependencies": { + "vue": "^3.2.0" + }, + "dependencies": { + "@tryabby/core": "workspace:*", + "js-cookie": "^3.0.5" + }, + "devDependencies": { + "tsconfig": "workspace:*", + "@types/js-cookie": "^3.0.3", + "tsup": "^6.6.3", + "typescript": "5.5.4", + "vitest": "2.0.5", + "vue": "^3.4.0" + } +} diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts new file mode 100644 index 00000000..c500bedc --- /dev/null +++ b/packages/vue/src/index.ts @@ -0,0 +1,157 @@ +import { inject, ref, readonly, onUnmounted, type App, type InjectionKey, type Ref } from "vue"; +import { + type ABConfig, + Abby, + type AbbyConfig, + AbbyEventType, + HttpService, + type IStorageService, + type RemoteConfigValueString, + type RemoteConfigValueStringToType, + type ValidatorType, + getABStorageKey, + getFFStorageKey, + getRCStorageKey, +} from "@tryabby/core"; +import Cookie from "js-cookie"; + +const DEFAULT_COOKIE_AGE = 365; +const isClient = () => typeof window !== "undefined"; + +function makeCookieStore(getKey: (projectId: string, name: string) => string): IStorageService { + return { + get(projectId: string, name: string): string | null { + if (!isClient()) return null; + return Cookie.get(getKey(projectId, name)) ?? null; + }, + set(projectId: string, name: string, value: string, options?: { expiresInDays?: number }): void { + if (!isClient()) return; + Cookie.set(getKey(projectId, name), value, { + expires: options?.expiresInDays ?? DEFAULT_COOKIE_AGE, + }); + }, + remove(projectId: string, name: string): void { + if (!isClient()) return; + Cookie.remove(getKey(projectId, name)); + }, + }; +} + +const TestStore = makeCookieStore(getABStorageKey); +const FlagStore = makeCookieStore(getFFStorageKey); +const RemoteConfigStore = makeCookieStore(getRCStorageKey); + +export const ABBY_INJECTION_KEY: InjectionKey> = Symbol("abby"); + +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, +>( + abbyConfig: AbbyConfig +) { + const core = new Abby( + abbyConfig, + { + get: (key: string) => TestStore.get(abbyConfig.projectId, key), + set: (key: string, value: any) => { + if (abbyConfig.cookies?.disableByDefault) return; + TestStore.set(abbyConfig.projectId, key, value); + }, + }, + { + get: (key: string) => FlagStore.get(abbyConfig.projectId, key), + set: (key: string, value: any) => FlagStore.set(abbyConfig.projectId, key, value), + }, + { + get: (key: string) => RemoteConfigStore.get(abbyConfig.projectId, key), + set: (key: string, value: any) => RemoteConfigStore.set(abbyConfig.projectId, key, value), + } + ); + + const useAbby = () => core; + + const useFeatureFlag = (flagKey: FlagName): Readonly> => { + const value = ref(Boolean(core.getFeatureFlag(flagKey))); + if (isClient()) { + const unsubscribe = core.subscribe(() => { + value.value = Boolean(core.getFeatureFlag(flagKey)); + }); + onUnmounted(() => { + if (typeof unsubscribe === "function") unsubscribe(); + }); + } + return readonly(value); + }; + + const useRemoteConfig = ( + configKey: Key + ): Readonly>> => { + const value = ref(core.getRemoteConfig(configKey)) as Ref< + RemoteConfigValueStringToType + >; + if (isClient()) { + const unsubscribe = core.subscribe(() => { + value.value = core.getRemoteConfig(configKey); + }); + onUnmounted(() => { + if (typeof unsubscribe === "function") unsubscribe(); + }); + } + return readonly(value); + }; + + const useAbTest = (testName: K) => { + const variant = ref(core.getTestVariant(testName)); + if (isClient()) { + const unsubscribe = core.subscribe(() => { + variant.value = core.getTestVariant(testName); + }); + onUnmounted(() => { + if (typeof unsubscribe === "function") unsubscribe(); + }); + } + return { + variant: readonly(variant), + onAct: () => { + if (!variant.value) return; + HttpService.sendData({ + url: abbyConfig.apiUrl, + type: AbbyEventType.ACT, + data: { + projectId: abbyConfig.projectId, + selectedVariant: variant.value as string, + testName: testName as string, + }, + }); + }, + }; + }; + + const plugin = { + install(app: App) { + app.provide(ABBY_INJECTION_KEY, core); + app.config.globalProperties.$abby = core; + }, + }; + + return { + abby: core, + plugin, + useAbby, + useFeatureFlag, + useRemoteConfig, + useAbTest, + }; +} + +export function useInjectedAbby() { + const instance = inject(ABBY_INJECTION_KEY); + if (!instance) { + throw new Error("Abby plugin is not installed. Call app.use(abby.plugin) first."); + } + return instance; +} diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json new file mode 100644 index 00000000..6f5ddbe6 --- /dev/null +++ b/packages/vue/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "tsconfig/base.json", + "include": ["src", "tests"], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/packages/vue/tsup.config.ts b/packages/vue/tsup.config.ts new file mode 100644 index 00000000..f43452e2 --- /dev/null +++ b/packages/vue/tsup.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm", "cjs"], + dts: true, + external: ["vue", "@tryabby/core"], +});