From d8a20406718a5778478452d572eab1cbee8e5011 Mon Sep 17 00:00:00 2001 From: Tim Raderschad Date: Fri, 16 Aug 2024 01:13:36 +0200 Subject: [PATCH] feat: add new v2 config route --- apps/web/abby.config.ts | 1 - .../20240812163432_add_v2_api/migration.sql | 2 + apps/web/prisma/schema.prisma | 1 + apps/web/src/api/index.ts | 5 +- apps/web/src/api/routes/v1_project_data.ts | 13 +- apps/web/src/api/routes/v2_project_data.ts | 190 +++++++++ .../src/components/AddFeatureFlagModal.tsx | 7 +- apps/web/src/components/FlagPage.tsx | 6 +- apps/web/src/lib/abby.tsx | 1 + apps/web/src/pages/_app.tsx | 2 +- apps/web/src/pages/devtools.tsx | 1 + apps/web/src/pages/index.tsx | 1 + apps/web/src/server/common/config-cache.ts | 29 +- apps/web/src/server/trpc/router/flags.ts | 9 +- apps/web/src/server/trpc/router/tests.ts | 5 +- packages/angular/package.json | 6 +- packages/angular/src/lib/abby.service.spec.ts | 1 + packages/angular/src/lib/abby.service.ts | 2 +- packages/angular/src/lib/get-variant.pipe.ts | 2 +- packages/core/src/defineConfig.ts | 2 +- packages/core/src/index.ts | 362 +++++++++++++----- packages/core/src/shared/helpers.ts | 127 ++++++ packages/core/src/shared/http.ts | 18 +- packages/core/src/shared/schemas.ts | 21 +- packages/core/src/shared/types.ts | 10 +- packages/core/tests/helpers.test.ts | 183 +++++++++ packages/devtools/src/Devtools.svelte | 75 +++- packages/next/src/index.tsx | 5 +- packages/next/src/withAbby.tsx | 5 +- packages/react/src/context.tsx | 58 ++- packages/react/tests/ssr.test.tsx | 64 +++- packages/remix/src/index.tsx | 5 +- 32 files changed, 1021 insertions(+), 198 deletions(-) create mode 100644 apps/web/prisma/migrations/20240812163432_add_v2_api/migration.sql create mode 100644 apps/web/src/api/routes/v2_project_data.ts create mode 100644 packages/core/tests/helpers.test.ts diff --git a/apps/web/abby.config.ts b/apps/web/abby.config.ts index 964949bc..548738b8 100644 --- a/apps/web/abby.config.ts +++ b/apps/web/abby.config.ts @@ -6,7 +6,6 @@ export default defineConfig( projectId: process.env.NEXT_PUBLIC_ABBY_PROJECT_ID!, currentEnvironment: process.env.VERCEL_ENV ?? process.env.NODE_ENV, apiUrl: process.env.NEXT_PUBLIC_ABBY_API_URL, - __experimentalCdnUrl: process.env.NEXT_PUBLIC_ABBY_CDN_URL, debug: process.env.NEXT_PUBLIC_ABBY_DEBUG === "true", }, { diff --git a/apps/web/prisma/migrations/20240812163432_add_v2_api/migration.sql b/apps/web/prisma/migrations/20240812163432_add_v2_api/migration.sql new file mode 100644 index 00000000..7c32d97c --- /dev/null +++ b/apps/web/prisma/migrations/20240812163432_add_v2_api/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `ApiRequest` MODIFY `apiVersion` ENUM('V0', 'V1', 'V2') NOT NULL DEFAULT 'V0'; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index ab3bb4d0..730135a0 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -293,6 +293,7 @@ enum ApiRequestType { enum ApiVersion { V0 V1 + V2 } model ApiRequest { diff --git a/apps/web/src/api/index.ts b/apps/web/src/api/index.ts index ac36ac09..639aaf69 100644 --- a/apps/web/src/api/index.ts +++ b/apps/web/src/api/index.ts @@ -6,6 +6,7 @@ import { logger } from "hono/logger"; import { makeHealthRoute } from "./routes/health"; import { makeLegacyProjectDataRoute } from "./routes/legacy_project_data"; import { makeEventRoute } from "./routes/v1_event"; +import { makeV2ProjectDataRoute } from "./routes/v2_project_data"; export const app = new Hono() .basePath("/api") @@ -19,4 +20,6 @@ export const app = new Hono() // v1 routes .route("/v1/config", makeConfigRoute()) .route("/v1/data", makeProjectDataRoute()) - .route("/v1/track", makeEventRoute()); + .route("/v1/track", makeEventRoute()) + // v2 routes + .route("/v2/data", makeV2ProjectDataRoute()); diff --git a/apps/web/src/api/routes/v1_project_data.ts b/apps/web/src/api/routes/v1_project_data.ts index ac29565b..168f1392 100644 --- a/apps/web/src/api/routes/v1_project_data.ts +++ b/apps/web/src/api/routes/v1_project_data.ts @@ -21,7 +21,11 @@ async function getAbbyResponseWithCache({ c: Context; }) { startTime(c, "readCache"); - const cachedConfig = ConfigCache.getConfig({ environment, projectId }); + const cachedConfig = ConfigCache.getConfig({ + environment, + projectId, + apiVersion: "v1", + }); endTime(c, "readCache"); c.header(X_ABBY_CACHE_HEADER, cachedConfig !== undefined ? "HIT" : "MISS"); @@ -72,7 +76,12 @@ async function getAbbyResponseWithCache({ }), } satisfies AbbyDataResponse; - ConfigCache.setConfig({ environment, projectId, value: response }); + ConfigCache.setConfig({ + environment, + projectId, + value: response, + apiVersion: "v1", + }); return response; } diff --git a/apps/web/src/api/routes/v2_project_data.ts b/apps/web/src/api/routes/v2_project_data.ts new file mode 100644 index 00000000..cba04a7c --- /dev/null +++ b/apps/web/src/api/routes/v2_project_data.ts @@ -0,0 +1,190 @@ +import { zValidator } from "@hono/zod-validator"; +import { + ABBY_WINDOW_KEY, + hashStringToInt32, + serializeAbbyData, + type AbbyData, +} from "@tryabby/core"; +import { type Context, Hono } from "hono"; +import { cors } from "hono/cors"; +import { endTime, startTime, timing } from "hono/timing"; +import { transformFlagValue } from "lib/flags"; +import { ConfigCache } from "server/common/config-cache"; +import { prisma } from "server/db/client"; +import { afterDataRequestQueue } from "server/queue/queues"; +import { z } from "zod"; + +export const X_ABBY_CACHE_HEADER = "X-Abby-Cache"; + +async function getAbbyResponseWithCache({ + environment, + projectId, + c, +}: { + environment: string; + projectId: string; + c: Context; +}) { + startTime(c, "readCache"); + const cachedConfig = ConfigCache.getConfig({ + environment, + projectId, + apiVersion: "v2", + }); + endTime(c, "readCache"); + + c.header(X_ABBY_CACHE_HEADER, cachedConfig !== undefined ? "HIT" : "MISS"); + if (cachedConfig) { + return serializeAbbyData(cachedConfig as AbbyData); + } + + startTime(c, "db"); + const [dbTests, dbFlags] = await Promise.all([ + prisma.test.findMany({ + where: { + projectId, + }, + include: { options: { select: { chance: true } } }, + }), + prisma.featureFlagValue.findMany({ + where: { + environment: { + name: environment, + projectId, + }, + }, + include: { flag: { select: { name: true, type: true } } }, + }), + ]); + endTime(c, "db"); + + const flags = dbFlags.filter(({ flag }) => flag.type === "BOOLEAN"); + + const remoteConfigs = dbFlags.filter(({ flag }) => flag.type !== "BOOLEAN"); + + const response = { + tests: dbTests.map((test) => ({ + name: hashStringToInt32(test.name).toString(), + weights: test.options.map((o) => o.chance.toNumber()), + })), + flags: flags.map((flagValue) => { + return { + name: hashStringToInt32(flagValue.flag.name).toString(), + value: transformFlagValue(flagValue.value, flagValue.flag.type), + }; + }), + remoteConfig: remoteConfigs.map((flagValue) => { + return { + name: hashStringToInt32(flagValue.flag.name).toString(), + value: transformFlagValue(flagValue.value, flagValue.flag.type), + }; + }), + } satisfies AbbyData; + + ConfigCache.setConfig({ + environment, + projectId, + value: response, + apiVersion: "v2", + }); + return serializeAbbyData(response); +} + +export function makeV2ProjectDataRoute() { + const app = new Hono() + .get( + "/:projectId", + cors({ + origin: "*", + maxAge: 86400, + }), + zValidator( + "query", + z.object({ + environment: z.string(), + }) + ), + timing(), + async (c) => { + const projectId = c.req.param("projectId"); + const { environment } = c.req.valid("query"); + + const now = performance.now(); + + try { + startTime(c, "getAbbyResponseWithCache"); + const response = await getAbbyResponseWithCache({ + projectId, + environment, + c, + }); + endTime(c, "getAbbyResponseWithCache"); + + const duration = performance.now() - now; + + afterDataRequestQueue.add("after-data-request", { + apiVersion: "V2", + functionDuration: duration, + projectId, + }); + + return c.json(response); + } catch (e) { + console.error(e); + return c.json({ error: "Internal server error" }, { status: 500 }); + } + } + ) + .get( + "/:projectId/script.js", + cors({ + origin: "*", + maxAge: 86400, + }), + zValidator( + "query", + z.object({ + environment: z.string(), + }) + ), + timing(), + async (c) => { + const projectId = c.req.param("projectId"); + const { environment } = c.req.valid("query"); + + const now = performance.now(); + + try { + startTime(c, "getAbbyResponseWithCache"); + const response = await getAbbyResponseWithCache({ + projectId, + environment, + c, + }); + endTime(c, "getAbbyResponseWithCache"); + + const jsContent = `window.${ABBY_WINDOW_KEY} = ${JSON.stringify( + response + )}`; + + const duration = performance.now() - now; + + afterDataRequestQueue.add("after-data-request", { + apiVersion: "V2", + functionDuration: duration, + projectId, + }); + + return c.text(jsContent, { + headers: { + "Content-Type": "application/javascript", + }, + }); + } catch (e) { + console.error(e); + return c.json({ error: "Internal server error" }, { status: 500 }); + } + } + ); + return app; +} diff --git a/apps/web/src/components/AddFeatureFlagModal.tsx b/apps/web/src/components/AddFeatureFlagModal.tsx index 8187e97a..a7f91e2d 100644 --- a/apps/web/src/components/AddFeatureFlagModal.tsx +++ b/apps/web/src/components/AddFeatureFlagModal.tsx @@ -15,6 +15,7 @@ import { Toggle } from "./Toggle"; import { useTracking } from "lib/tracking"; import { Input } from "./ui/input"; +import { SAFE_NAME_REGEX } from "@tryabby/core"; type Props = { onClose: () => void; @@ -168,7 +169,6 @@ export const AddFeatureFlagModal = ({ projectId, isRemoteConfig, }: Props) => { - const _inputRef = useRef(null); const ctx = trpc.useContext(); const stateRef = useRef(); const trackEvent = useTracking(); @@ -202,6 +202,11 @@ export const AddFeatureFlagModal = ({ if (!stateRef.current?.value) { errors.value = "Value is required"; } + + if (SAFE_NAME_REGEX.test(trimmedName) === false) { + errors.name = + "Invalid name. Only letters, numbers, and underscores are allowed."; + } if (Object.keys(errors).length > 0) { setErrors(errors); return; diff --git a/apps/web/src/components/FlagPage.tsx b/apps/web/src/components/FlagPage.tsx index e8cc50ab..bf4763aa 100644 --- a/apps/web/src/components/FlagPage.tsx +++ b/apps/web/src/components/FlagPage.tsx @@ -15,7 +15,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "components/Tooltip"; import { Input } from "components/ui/input"; import { useProjectId } from "lib/hooks/useProjectId"; import { EditIcon, FileEditIcon, Search, TrashIcon } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { toast } from "react-hot-toast"; import { AiOutlinePlus } from "react-icons/ai"; import { BiInfoCircle } from "react-icons/bi"; @@ -188,6 +188,10 @@ export const FeatureFlagPageContent = ({ setFlags(results.map((result) => result.item)); }; + useEffect(() => { + setFlags(data.flags); + }, [data.flags]); + const activeFlag = data.flags.find((flag) => flag.id === activeFlagInfo?.id); if (data.environments.length === 0) diff --git a/apps/web/src/lib/abby.tsx b/apps/web/src/lib/abby.tsx index 82183934..e6fc33bb 100644 --- a/apps/web/src/lib/abby.tsx +++ b/apps/web/src/lib/abby.tsx @@ -13,6 +13,7 @@ export const { getABTestValue, withAbbyApiHandler, getABResetFunction, + useRemoteConfig, } = createAbby(abbyConfig); export const AbbyDevtools = withDevtools(abbyDevtools, {}); diff --git a/apps/web/src/pages/_app.tsx b/apps/web/src/pages/_app.tsx index 447f9665..0dba2fbe 100644 --- a/apps/web/src/pages/_app.tsx +++ b/apps/web/src/pages/_app.tsx @@ -8,7 +8,6 @@ import { trpc } from "../utils/trpc"; import { TooltipProvider } from "components/Tooltip"; import { env } from "env/client.mjs"; -import { AbbyDevtools, AbbyProvider, withAbby } from "lib/abby"; import type { NextPage } from "next"; import { useRouter } from "next/router"; import type { ReactElement, ReactNode } from "react"; @@ -17,6 +16,7 @@ import "@fontsource/martian-mono/600.css"; import "../styles/shadcn.css"; import "@code-hike/mdx/dist/index.css"; import PlausibleProvider from "next-plausible"; +import { AbbyDevtools, AbbyProvider, withAbby } from "lib/abby"; export type NextPageWithLayout

= NextPage & { getLayout?: (page: ReactElement) => ReactNode; diff --git a/apps/web/src/pages/devtools.tsx b/apps/web/src/pages/devtools.tsx index cbe028de..bdf49e5d 100644 --- a/apps/web/src/pages/devtools.tsx +++ b/apps/web/src/pages/devtools.tsx @@ -205,6 +205,7 @@ export const getStaticProps = async () => { const data = await HttpService.getProjectData({ projectId: config.projectId, environment: config.currentEnvironment, + experimental: config.experimental, }); return { props: { abbyData: data }, diff --git a/apps/web/src/pages/index.tsx b/apps/web/src/pages/index.tsx index 39af10db..007c5538 100644 --- a/apps/web/src/pages/index.tsx +++ b/apps/web/src/pages/index.tsx @@ -380,6 +380,7 @@ export const getStaticProps = async () => { const data = await HttpService.getProjectData({ projectId: config.projectId, environment: config.currentEnvironment, + experimental: config.experimental, }); const codeSnippet = await generateCodeSnippets({ projectId: "", diff --git a/apps/web/src/server/common/config-cache.ts b/apps/web/src/server/common/config-cache.ts index 6e4803a7..56e434d5 100644 --- a/apps/web/src/server/common/config-cache.ts +++ b/apps/web/src/server/common/config-cache.ts @@ -1,4 +1,4 @@ -import type { AbbyDataResponse } from "@tryabby/core"; +import type { AbbyConfigFile, AbbyDataResponse } from "@tryabby/core"; import createCache from "./memory-cache"; const configCache = createCache({ @@ -10,24 +10,37 @@ const configCache = createCache({ type ConfigCacheKey = { environment: string; projectId: string; + apiVersion: NonNullable["apiVersion"]; }; export abstract class ConfigCache { - static getConfig({ environment, projectId }: ConfigCacheKey) { - return configCache.get(projectId + environment); + private static getCacheKey({ + apiVersion, + environment, + projectId, + }: ConfigCacheKey) { + return [projectId, environment, apiVersion].join(":"); + } + static getConfig(opts: ConfigCacheKey) { + return configCache.get(ConfigCache.getCacheKey(opts)); } static setConfig({ - environment, - projectId, value, + ...opts }: ConfigCacheKey & { value: AbbyDataResponse; }) { - configCache.set(projectId + environment, value); + configCache.set(ConfigCache.getCacheKey(opts), value); } - static deleteConfig({ environment, projectId }: ConfigCacheKey) { - configCache.delete(projectId + environment); + static deleteConfig(opts: Omit) { + const apiVersionsToClear: Array = [ + "v1", + "v2", + ]; + for (const apiVersion of apiVersionsToClear) { + configCache.delete(ConfigCache.getCacheKey({ ...opts, apiVersion })); + } } } diff --git a/apps/web/src/server/trpc/router/flags.ts b/apps/web/src/server/trpc/router/flags.ts index d0ffdb06..9f7bfdf3 100644 --- a/apps/web/src/server/trpc/router/flags.ts +++ b/apps/web/src/server/trpc/router/flags.ts @@ -4,6 +4,7 @@ import { ConfigCache } from "server/common/config-cache"; import { FlagService } from "server/services/FlagService"; import { validateFlag } from "utils/validateFlags"; import { z } from "zod"; +import { safeNameSchema } from "@tryabby/core"; import { protectedProcedure, router } from "../trpc"; export const flagRouter = router({ @@ -54,7 +55,7 @@ export const flagRouter = router({ .input( z.object({ projectId: z.string(), - name: z.string(), + name: safeNameSchema, type: z.nativeEnum(FeatureFlagType), value: z.string(), }) @@ -76,7 +77,7 @@ export const flagRouter = router({ z.object({ flagValueId: z.string(), value: z.string(), - name: z.string(), + name: safeNameSchema, }) ) .mutation(async ({ ctx, input }) => { @@ -142,7 +143,7 @@ export const flagRouter = router({ removeFlag: protectedProcedure .input( z.object({ - name: z.string(), + name: safeNameSchema, projectId: z.string(), }) ) @@ -256,7 +257,7 @@ export const flagRouter = router({ .input( z.object({ flagId: z.string(), - title: z.string().min(1), + title: safeNameSchema, }) ) .mutation(async ({ ctx, input }) => { diff --git a/apps/web/src/server/trpc/router/tests.ts b/apps/web/src/server/trpc/router/tests.ts index 43eec817..23914c75 100644 --- a/apps/web/src/server/trpc/router/tests.ts +++ b/apps/web/src/server/trpc/router/tests.ts @@ -4,12 +4,13 @@ import { prisma } from "server/db/client"; import { TestService } from "server/services/TestService"; import { z } from "zod"; import { protectedProcedure, router } from "../trpc"; +import { safeNameSchema } from "@tryabby/core"; export const testRouter = router({ createTest: protectedProcedure .input( z.object({ - name: z.string(), + name: safeNameSchema, projectId: z.string(), variants: z.array( z.object({ @@ -31,7 +32,7 @@ export const testRouter = router({ .input( z.object({ testId: z.string(), - name: z.string(), + name: safeNameSchema, }) ) .mutation(async ({ input, ctx }) => { diff --git a/packages/angular/package.json b/packages/angular/package.json index e137db7c..1a8cd041 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -6,7 +6,7 @@ "start": "ng serve", "build": "ng-packagr -p ng-package.json", "watch": "ng build --watch --configuration development", - "test": "ng test --no-watch --no-progress" + "test": "ng test --no-watch " }, "peerDependencies": { "@angular/common": "^18.1.2", @@ -16,7 +16,9 @@ "@tryabby/core": "workspace:*", "tslib": "^2.3.0" }, - "files": ["dist"], + "files": [ + "dist" + ], "main": "./dist/esm2020/tryabby-angular.mjs", "module": "./dist/esm2020/tryabby-angular.mjs", "types": "./dist/index.d.ts", diff --git a/packages/angular/src/lib/abby.service.spec.ts b/packages/angular/src/lib/abby.service.spec.ts index 673d1026..2ea4c84a 100644 --- a/packages/angular/src/lib/abby.service.spec.ts +++ b/packages/angular/src/lib/abby.service.spec.ts @@ -43,6 +43,7 @@ const mockConfig = { }, }, }, + debug: true, } satisfies AbbyConfig; const mockedData = { diff --git a/packages/angular/src/lib/abby.service.ts b/packages/angular/src/lib/abby.service.ts index a1122f65..72a22af4 100644 --- a/packages/angular/src/lib/abby.service.ts +++ b/packages/angular/src/lib/abby.service.ts @@ -150,7 +150,7 @@ export class AbbyService< return this.resolveData().pipe(map(() => void 0)); } - public getVariant(testName: T): Observable { + public getVariant(testName: T): Observable { this.abbyLogger.log(`getVariant(${testName as string})`); return this.resolveData().pipe( diff --git a/packages/angular/src/lib/get-variant.pipe.ts b/packages/angular/src/lib/get-variant.pipe.ts index f810de89..fd57eb8a 100644 --- a/packages/angular/src/lib/get-variant.pipe.ts +++ b/packages/angular/src/lib/get-variant.pipe.ts @@ -16,6 +16,6 @@ export class GetAbbyVariantPipe< constructor(private abbyService: AbbyService) {} transform(testName: TestName): Observable { - return this.abbyService.getVariant(testName); + return this.abbyService.getVariant(testName as any); } } diff --git a/packages/core/src/defineConfig.ts b/packages/core/src/defineConfig.ts index 30423adf..ddec8a53 100644 --- a/packages/core/src/defineConfig.ts +++ b/packages/core/src/defineConfig.ts @@ -5,7 +5,7 @@ export const DYNAMIC_ABBY_CONFIG_KEYS = [ "currentEnvironment", "debug", "apiUrl", - "__experimentalCdnUrl", + "experimental", ] as const satisfies readonly (keyof AbbyConfig)[]; export type DynamicConfigKeys = (typeof DYNAMIC_ABBY_CONFIG_KEYS)[number]; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 036c761d..91017d4c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,7 +3,12 @@ import { getVariantWithHeighestWeightOrFirst, getWeightedRandomVariant, } from "./mathHelpers"; -import { HttpService } from "./shared"; +import { + hashStringToInt32, + HttpService, + isSerializedAbbyDataResponse, + parseAbbyData, +} from "./shared"; import { ABBY_AB_STORAGE_PREFIX, ABBY_FF_STORAGE_PREFIX, @@ -107,8 +112,12 @@ export type AbbyConfig< cookies?: { disableByDefault?: boolean; expiresInDays?: number; + disableInDevelopment?: boolean; + }; + experimental?: { + cdnUrl?: string; + apiVersion?: "v1" | "v2"; }; - __experimentalCdnUrl?: string; }; export class Abby< @@ -132,8 +141,6 @@ export class Abby< (newData: LocalData) => void >(); - private _cfg: AbbyConfig; - private dataInitialized = false; private flagOverrides = new Map(); @@ -157,35 +164,111 @@ export class Abby< private persistantFlagStorage?: PersistentStorage, private persistentRemoteConfigStorage?: PersistentStorage ) { - this._cfg = config as AbbyConfig; - this.#data.flags = Object.values(this._cfg.flags ?? {}).reduce( + this.#data.flags = Object.values(this.config.flags ?? {}).reduce( (acc, flagName) => { - acc[flagName as FlagName] = DEFAULT_FEATURE_FLAG_VALUE; + const internalFlagName = this.__internal_getInternalName(flagName); + acc[internalFlagName] = DEFAULT_FEATURE_FLAG_VALUE; return acc; }, {} as Record ); - this.#data.tests = config.tests ?? ({} as any); + this.#data.tests = Object.keys(this.config.tests ?? {}).reduce( + (acc, _testName) => { + const testName = _testName as TestName; + const internalTestName = this.__internal_getInternalName(testName); + if (this.config.tests) { + acc[internalTestName as TestName] = + this.config.tests[testName as TestName]; + } + return acc; + }, + {} as Record + ); this.#data.remoteConfig = Object.keys(config.remoteConfig ?? {}).reduce( - (acc, remoteConfigName) => { - acc[remoteConfigName as RemoteConfigName] = - this.getDefaultRemoteConfigValue( - remoteConfigName, - config.remoteConfig as any - ); + (acc, _remoteConfigName) => { + const remoteConfigName = _remoteConfigName as RemoteConfigName; + const internalConfigName = + this.__internal_getInternalName(remoteConfigName); + + acc[internalConfigName] = this.getDefaultRemoteConfigValue( + remoteConfigName, + config.remoteConfig as any + ); return acc; }, {} as Record ); - if (persistantTestStorage) { this.persistantTestStorage = { - get: (...args) => persistantTestStorage.get(...args), + get: (...args) => { + const [key] = args; + const humanReadableName = this.__internal_getNameMatch( + key as TestName, + "tests" + ); + if (!humanReadableName) return null; + return persistantTestStorage.get(humanReadableName as TestName); + }, set: (...args) => { - if (config.cookies?.disableByDefault) return; + if (!this.canUseCookies()) return; const [key, value] = args; - persistantTestStorage.set(key, value, { + const humanReadableName = this.__internal_getNameMatch( + key as TestName, + "tests" + ); + if (!humanReadableName) return; + persistantTestStorage.set(humanReadableName, value, { + expiresInDays: config.cookies?.expiresInDays, + }); + }, + }; + } + if (persistantFlagStorage) { + this.persistantFlagStorage = { + get: (...args) => { + const [key] = args; + const humanReadableName = this.__internal_getNameMatch( + key as FlagName, + "flags" + ); + if (!humanReadableName) return null; + return persistantFlagStorage.get(humanReadableName); + }, + set: (...args) => { + if (!this.canUseCookies()) return; + const [key, value] = args; + const humanReadableName = this.__internal_getNameMatch( + key as FlagName, + "flags" + ); + if (!humanReadableName) return; + persistantFlagStorage.set(humanReadableName, value, { + expiresInDays: config.cookies?.expiresInDays, + }); + }, + }; + } + if (persistentRemoteConfigStorage) { + this.persistentRemoteConfigStorage = { + get: (...args) => { + const [key] = args; + const humanReadableName = this.__internal_getNameMatch( + key as RemoteConfigName, + "remoteConfig" + ); + if (!humanReadableName) return null; + return persistentRemoteConfigStorage.get(humanReadableName); + }, + set: (...args) => { + if (!this.canUseCookies()) return; + const [key, value] = args; + const humanReadableName = this.__internal_getNameMatch( + key as RemoteConfigName, + "remoteConfig" + ); + if (!humanReadableName) return; + persistentRemoteConfigStorage.set(humanReadableName, value, { expiresInDays: config.cookies?.expiresInDays, }); }, @@ -215,10 +298,13 @@ export class Abby< projectId: this.config.projectId, environment: this.config.currentEnvironment as string, url: this.config.apiUrl, - fetch: this._cfg.fetch, - __experimentalCdnUrl: this._cfg.__experimentalCdnUrl - ? `${this._cfg.__experimentalCdnUrl}/${this.config.projectId}/${this.config.currentEnvironment}` - : undefined, + fetch: this.config.fetch, + experimental: { + cdnUrl: this.config.experimental?.cdnUrl + ? `${this.config.experimental?.cdnUrl}/${this.config.projectId}/${this.config.currentEnvironment}` + : undefined, + apiVersion: this.config.experimental?.apiVersion, + }, }); if (!data) { this.log("loadProjectData() => no data"); @@ -244,38 +330,65 @@ export class Abby< * Helper function to transform the data which is fetched from the server * to the local data structure */ - private responseToLocalData( - data: AbbyDataResponse + private responseToLocalData( + res: AbbyDataResponse ): LocalData { + const data = isSerializedAbbyDataResponse(res) ? parseAbbyData(res) : res; return { tests: data.tests.reduce( - (acc, { name, weights }) => { - if (!acc[name as keyof Tests]) { - return acc; - } - + (acc, { name: _name, weights }) => { + const name = _name as TestName; + const currentTest = this.#data.tests[name]; // assigned the fetched weights to the initial config - acc[name as keyof Tests] = { - ...acc[name as keyof Tests], + acc[name] = { + ...currentTest, + // this could be potentially undefined + variants: currentTest?.variants ?? [], weights, }; return acc; }, - (this.config.tests ?? {}) as any + Object.entries(this.#data.tests).reduce( + (acc, cur) => { + const [name, test] = cur; + acc[this.__internal_getInternalName(name as TestName)] = + test as ABConfig; + return acc; + }, + {} as Record + ) as Record ), flags: data.flags.reduce( - (acc, { name, value }) => { + (acc, { name: _name, value }) => { + const name = _name as FlagName; acc[name] = value; return acc; }, - {} as Record + Object.entries(this.#data.tests).reduce( + (acc, cur) => { + const [name, value] = cur; + acc[this.__internal_getInternalName(name as FlagName)] = + value as boolean; + return acc; + }, + {} as Record + ) as Record ), remoteConfig: (data.remoteConfig ?? []).reduce( - (acc, { name, value }) => { + (acc, { name: _name, value }) => { + const name = _name as RemoteConfigName; acc[name] = value; return acc; }, - {} as Record + Object.entries(this.#data.tests).reduce( + (acc, cur) => { + const [name, value] = cur; + acc[this.__internal_getInternalName(name as RemoteConfigName)] = + value as RemoteConfigValue; + return acc; + }, + {} as Record + ) as Record ), }; } @@ -286,31 +399,37 @@ export class Abby< * @returns the local data */ getProjectData(): LocalData { - this.log("getProjectData()"); - return { tests: Object.entries(this.#data.tests).reduce( - (acc, [testName, test]) => { - acc[testName as TestName] = { - ...(test as Tests[TestName]), - selectedVariant: this.getTestVariant(testName as TestName), + (acc, [_testName, test]) => { + const testName = _testName as TestName; + const currentTest = test as Tests[TestName]; + acc[testName] = { + ...currentTest, + selectedVariant: this.__internal_getTestVariant(testName), + variants: currentTest?.variants ?? [], }; return acc; }, - this.#data.tests + {} as Record + ), + flags: Object.keys(this.#data.flags).reduce( + (acc, _flagName) => { + const flagName = _flagName as FlagName; + + acc[flagName] = this.__internal_getFeatureFlag(flagName); + return acc; + }, + {} as Record ), - flags: Object.keys(this.#data.flags).reduce((acc, flagName) => { - acc[flagName as FlagName] = this.getFeatureFlag(flagName as FlagName); - return acc; - }, this.#data.flags), remoteConfig: Object.keys(this.#data.remoteConfig).reduce( - (acc, remoteConfigName) => { - acc[remoteConfigName as RemoteConfigName] = this.getRemoteConfig( - remoteConfigName as RemoteConfigName - ); + (acc, _remoteConfigName) => { + const remoteConfigName = _remoteConfigName as RemoteConfigName; + acc[remoteConfigName] = + this.__internal_getRemoteConfig(remoteConfigName); return acc; }, - this.#data.remoteConfig + {} as Record ), }; } @@ -326,6 +445,7 @@ export class Abby< this.log("init()", data); this.#data = this.responseToLocalData(data); + this.log("init() => data", this.#data); this.notifyListeners(); if (typeof window !== "undefined" && typeof document !== "undefined") { @@ -342,12 +462,16 @@ export class Abby< * @param key the name of the feature flag * @returns the value of the feature flag */ + getFeatureFlag(key: FlagName): boolean { + return this.__internal_getFeatureFlag(this.__internal_getInternalName(key)); + } + private __internal_getFeatureFlag(key: FlagName): boolean { this.log("getFeatureFlag()", key); const storedValue = this.#data.flags[key]; - const localOverride = this.flagOverrides?.get(key as unknown as FlagName); + const localOverride = this.flagOverrides?.get(key); if (localOverride != null) { return localOverride; @@ -361,15 +485,13 @@ export class Abby< * 3. DevDefault from config */ if (process.env.NODE_ENV === "development") { - const devOverride = (this.config.settings?.flags?.devOverrides as any)?.[ - key - ]; + const devOverride = this.config.settings?.flags?.devOverrides?.[key]; if (devOverride != null) { return devOverride; } } - const defaultValue = this._cfg.settings?.flags?.defaultValue; + const defaultValue = this.config.settings?.flags?.defaultValue; if (storedValue !== undefined) { this.log("getFeatureFlag() => storedValue:", storedValue); @@ -377,18 +499,17 @@ export class Abby< } // before we return the default value we check if there is a fallback value set const hasFallbackValue = - key in (this._cfg.settings?.flags?.fallbackValues ?? {}); + key in (this.config.settings?.flags?.fallbackValues ?? {}); if (hasFallbackValue) { - const fallbackValue = - this._cfg.settings?.flags?.fallbackValues?.[key as FlagName]; + const fallbackValue = this.config.settings?.flags?.fallbackValues?.[key]; if (fallbackValue !== undefined) { if (typeof fallbackValue === "boolean") { this.log("getFeatureFlag() => fallbackValue:", fallbackValue); return fallbackValue; } const envFallbackValue = - fallbackValue[this._cfg.currentEnvironment as string]; + fallbackValue[this.config.currentEnvironment as string]; if (envFallbackValue !== undefined) { this.log("getFeatureFlag() => envFallbackValue:", envFallbackValue); @@ -408,10 +529,16 @@ export class Abby< * @param key the name of the remote config * @returns the value of the remote config */ - getRemoteConfig< - T extends RemoteConfigName, - Curr extends RemoteConfig[T] = RemoteConfig[T], - >(key: T): RemoteConfigValueStringToType { + + getRemoteConfig( + key: T + ): RemoteConfigValueStringToType { + return this.__internal_getRemoteConfig(key); + } + + private __internal_getRemoteConfig( + key: T + ): RemoteConfigValueStringToType { this.log("getRemoteConfig()", key); const storedValue = this.#data.remoteConfig[key]; @@ -432,18 +559,18 @@ export class Abby< } } - const defaultValue = this._cfg.remoteConfig?.[key] - ? this._cfg.settings?.remoteConfig?.defaultValues?.[ - this._cfg.remoteConfig?.[key] + const defaultValue = this.config.remoteConfig?.[key] + ? this.config.settings?.remoteConfig?.defaultValues?.[ + this.config.remoteConfig?.[key] ] : null; if (storedValue === undefined) { // before we return the default value we check if there is a fallback value set const fallbackValue = - key in (this._cfg.settings?.remoteConfig?.fallbackValues ?? {}); + key in (this.config.settings?.remoteConfig?.fallbackValues ?? {}); if (fallbackValue) { - return this._cfg.settings?.remoteConfig?.fallbackValues?.[ + return this.config.settings?.remoteConfig?.fallbackValues?.[ key ] as RemoteConfigValueStringToType; } @@ -468,12 +595,14 @@ export class Abby< * @param key The name of the test * @returns the value of the test variant */ - getTestVariant(key: T): Tests[T]["variants"][number] { - this.log("getTestVariant()", key); + getTestVariant(key: T): Tests[T]["variants"][number] { + return this.__internal_getTestVariant(this.__internal_getInternalName(key)); + } - const { variants, weights } = (this.#data.tests as LocalData["tests"])[ - key as keyof LocalData["tests"] - ]; + private __internal_getTestVariant( + key: T + ): Tests[T]["variants"][number] { + const { variants, weights } = this.#data.tests[key]; const override = this.testOverrides.get(key); @@ -481,7 +610,7 @@ export class Abby< return override; } - const persistedValue = this.persistantTestStorage?.get(key as string); + const persistedValue = this.persistantTestStorage?.get(key); if (persistedValue != null) { this.log("getTestVariant() => persistedValue:", persistedValue); @@ -492,7 +621,7 @@ export class Abby< return getVariantWithHeighestWeightOrFirst(variants, weights); } const weightedVariant = getWeightedRandomVariant(variants, weights); - this.persistantTestStorage?.set(key as string, weightedVariant); + this.persistantTestStorage?.set(key, weightedVariant); this.log("getTestVariant() => weightedVariant:", weightedVariant); @@ -504,12 +633,12 @@ export class Abby< * @param key the name of the test * @param override the value to override the test variant with */ - updateLocalVariant( + updateLocalVariant( key: T, override: Tests[T]["variants"][number] ) { this.testOverrides.set(key, override); - this.persistantTestStorage?.set(key as string, override); + this.persistantTestStorage?.set(key, override); this.notifyListeners(); } @@ -588,7 +717,6 @@ export class Abby< */ setLocalOverrides(cookies: string) { const parsedCookies = parseCookies(cookies); - Object.entries(parsedCookies).forEach(([cookieName, cookieValue]) => { // this happens if there are multiple abby instances. We only want to use the cookies for this instance if (!cookieName.includes(`${this.config.projectId}_`)) { @@ -609,7 +737,10 @@ export class Abby< return; } - this.testOverrides.set(testName as TestName, cookieValue); + this.testOverrides.set( + this.__internal_getInternalName(testName as TestName), + cookieValue + ); this.persistantTestStorage?.set(testName as TestName, cookieValue); } // FF testing cookie @@ -620,12 +751,15 @@ export class Abby< ); const flagValue = cookieValue === "true"; - this.flagOverrides.set(flagName, flagValue); + this.flagOverrides.set( + this.__internal_getInternalName(flagName as FlagName), + flagValue + ); } if ( cookieName.startsWith(ABBY_RC_STORAGE_PREFIX) && - this._cfg.remoteConfig + this.config.remoteConfig ) { const remoteConfigName = cookieName.replace( `${ABBY_RC_STORAGE_PREFIX}${this.config.projectId}_`, @@ -633,10 +767,14 @@ export class Abby< ); const remoteConfigValue = remoteConfigStringToType({ - remoteConfigType: this._cfg.remoteConfig[remoteConfigName], + remoteConfigType: + this.config.remoteConfig[remoteConfigName as RemoteConfigName], stringifiedValue: cookieValue, }); - this.remoteConfigOverrides.set(remoteConfigName, remoteConfigValue); + this.remoteConfigOverrides.set( + this.__internal_getInternalName(remoteConfigName as RemoteConfigName), + remoteConfigValue + ); } }); } @@ -671,7 +809,7 @@ export class Abby< getFeatureFlags() { return (Object.keys(this.#data.flags) as Array).map( (flagName) => ({ - name: flagName, + name: this.__internal_getNameMatch(flagName, "flags"), value: this.getFeatureFlag(flagName), }) ); @@ -684,7 +822,7 @@ export class Abby< return ( Object.keys(this.#data.remoteConfig) as Array ).map((configName) => ({ - name: configName, + name: this.__internal_getNameMatch(configName, "remoteConfig"), value: this.getRemoteConfig(configName), })) as Array<{ name: RemoteConfigName; @@ -701,10 +839,11 @@ export class Abby< this.config.cookies.disableByDefault = false; this.persistantTestStorage?.set(this.COOKIE_CONSENT_KEY, "true"); - Object.keys(this.#data.tests).forEach((testName) => { + Object.keys(this.#data.tests).forEach((_testName) => { + const testName = _testName as TestName; this.persistantTestStorage?.set( - testName, - this.getTestVariant(testName as TestName) + this.__internal_getInternalName(testName), + this.__internal_getTestVariant(testName) ); }); } @@ -718,11 +857,48 @@ export class Abby< this.config.cookies.disableByDefault = true; this.persistantTestStorage?.set(this.COOKIE_CONSENT_KEY, "false"); - Object.keys(this.#data.tests).forEach((testName) => { + Object.keys(this.#data.tests).forEach((_testName) => { + const testName = _testName as TestName; this.persistantTestStorage?.set( - testName, - this.getTestVariant(testName as TestName) + this.__internal_getInternalName(testName), + this.__internal_getTestVariant(testName) ); }); } + + __internal_getInternalName( + name: T + ): T { + return this.config.experimental?.apiVersion === "v2" + ? (hashStringToInt32(name).toString() as T) + : (name as T); + } + + __internal_getNameMatch( + name: T, + type: keyof Pick + ) { + switch (type) { + case "flags": + return this.config.flags?.find( + (f) => this.__internal_getInternalName(f) === name + ); + case "remoteConfig": + return Object.keys(this.config.remoteConfig ?? {}).find( + (c) => this.__internal_getInternalName(c as RemoteConfigName) === name + ); + case "tests": + return Object.keys(this.config.tests ?? {}).find( + (t) => this.__internal_getInternalName(t as TestName) === name + ); + } + } + + private canUseCookies() { + return ( + (process.env.NODE_ENV === "development" && + !this.config.cookies?.disableInDevelopment) || + !this.config.cookies?.disableByDefault + ); + } } diff --git a/packages/core/src/shared/helpers.ts b/packages/core/src/shared/helpers.ts index 122f562c..2fceb0dc 100644 --- a/packages/core/src/shared/helpers.ts +++ b/packages/core/src/shared/helpers.ts @@ -4,6 +4,7 @@ import { ABBY_RC_STORAGE_PREFIX, } from "./constants"; import type { RemoteConfigValue, RemoteConfigValueString } from "./schemas"; +import type { AbbyData, AbbyDataResponse } from "./types"; export function getABStorageKey(projectId: string, testName: string): string { return `${ABBY_AB_STORAGE_PREFIX}${projectId}_${testName}`; @@ -73,3 +74,129 @@ export function stringifyRemoteConfigValue(value: RemoteConfigValue) { export const getUseFeatureFlagRegex = (flagName: string) => new RegExp(`useFeatureFlag\\s*\\(\\s*['"\`]${flagName}['"\`]\\s*\\)`); + +const ENTRY_SEPARATOR = ";"; +const NAME_VALUE_SEPARATOR = "%"; +const VARIANT_SEPARATOR = "ยง"; + +export type SerializedAbbyDataResponse = { f: string; r: string; t: string }; + +const EMPTY_STRING = ""; + +export function serializeAbbyData(data: AbbyData): SerializedAbbyDataResponse { + return { + f: + data.flags.length === 0 + ? EMPTY_STRING + : data.flags + .map((f) => `${f.name}${NAME_VALUE_SEPARATOR}${f.value ? 1 : 0}`) + .join(ENTRY_SEPARATOR), + r: + data.remoteConfig.length === 0 + ? EMPTY_STRING + : data.remoteConfig + .map( + (f) => + `${f.name}${NAME_VALUE_SEPARATOR}${JSON.stringify(f.value)}` + ) + .join(ENTRY_SEPARATOR), + t: + data.tests.length === 0 + ? EMPTY_STRING + : data.tests + .map( + (t) => + `${t.name}${NAME_VALUE_SEPARATOR}${t.weights.join(VARIANT_SEPARATOR)}` + ) + .join(ENTRY_SEPARATOR), + } satisfies Record<(keyof AbbyDataResponse)[0], string>; +} + +function destringifyFlags(flags: string) { + return flags.split(ENTRY_SEPARATOR).filter(Boolean); +} + +export function parseAbbyData(s: SerializedAbbyDataResponse): AbbyData { + const flags = destringifyFlags(s.f); + const remoteConfigs = destringifyFlags(s.r); + const tests = destringifyFlags(s.t); + + return { + tests: tests.map((t) => { + const [name, weights] = t.split(NAME_VALUE_SEPARATOR); + return { + name, + weights: weights.split(VARIANT_SEPARATOR).map(Number), + }; + }), + flags: flags.map((f) => { + const [name, value] = f.split(NAME_VALUE_SEPARATOR); + return { + name, + value: Boolean(Number(value)), + }; + }), + remoteConfig: remoteConfigs.map((f) => { + const [name, value] = f.split(NAME_VALUE_SEPARATOR); + + return { + name, + value: JSON.parse(value), + }; + }), + }; +} + +// source: https://github.com/styled-components/styled-components/blob/0aa3170c255a49cd41c3fbeb2b8051b5d132f229/src/vendor/glamor/hash.js + +/** + * Generate a numeric 32 bit hash of a string + */ + +export function hashStringToInt32(str: string): number { + // biome-ignore lint/style/noVar: copy-pasted code; works like a charm + // biome-ignore lint/correctness/noInnerDeclarations: copy-pasted code; works like a charm + // biome-ignore lint/suspicious/noImplicitAnyLet: copy-pasted code; works like a charm + for (var e = str.length | 0, a = e | 0, d = 0, b; e >= 4; ) { + (b = + (str.charCodeAt(d) & 255) | + ((str.charCodeAt(++d) & 255) << 8) | + ((str.charCodeAt(++d) & 255) << 16) | + ((str.charCodeAt(++d) & 255) << 24)), + (b = + 1540483477 * (b & 65535) + (((1540483477 * (b >>> 16)) & 65535) << 16)), + (b ^= b >>> 24), + (b = + 1540483477 * (b & 65535) + (((1540483477 * (b >>> 16)) & 65535) << 16)), + (a = + (1540483477 * (a & 65535) + + (((1540483477 * (a >>> 16)) & 65535) << 16)) ^ + b), + // biome-ignore lint/style/noCommaOperator: copy-pasted code; works like a charm + (e -= 4), + ++d; + } + switch (e) { + // biome-ignore lint/suspicious/noFallthroughSwitchClause: copy-pasted code; works like a charm + case 3: + a ^= (str.charCodeAt(d + 2) & 255) << 16; + // biome-ignore lint/suspicious/noFallthroughSwitchClause: copy-pasted code; works like a charm + case 2: + a ^= (str.charCodeAt(d + 1) & 255) << 8; + case 1: + // biome-ignore lint/style/noCommaOperator: copy-pasted code; works like a charm + (a ^= str.charCodeAt(d) & 255), + (a = + 1540483477 * (a & 65535) + + (((1540483477 * (a >>> 16)) & 65535) << 16)); + } + a ^= a >>> 13; + a = 1540483477 * (a & 65535) + (((1540483477 * (a >>> 16)) & 65535) << 16); + return (a ^ (a >>> 15)) >>> 0; +} + +export function isSerializedAbbyDataResponse( + r: AbbyDataResponse +): r is SerializedAbbyDataResponse { + return typeof r === "object" && "f" in r && "r" in r && "t" in r; +} diff --git a/packages/core/src/shared/http.ts b/packages/core/src/shared/http.ts index f678452b..48f50b9e 100644 --- a/packages/core/src/shared/http.ts +++ b/packages/core/src/shared/http.ts @@ -1,5 +1,10 @@ import { ABBY_BASE_URL } from "./constants"; -import type { AbbyDataResponse, AbbyEvent, AbbyEventType } from "./index"; +import type { + AbbyConfigFile, + AbbyDataResponse, + AbbyEvent, + AbbyEventType, +} from "./index"; export abstract class HttpService { static async getProjectData({ @@ -7,18 +12,17 @@ export abstract class HttpService { environment, url, fetch = globalThis.fetch, - __experimentalCdnUrl, + experimental: { apiVersion, cdnUrl } = {}, }: { projectId: string; environment?: string; url?: string; - __experimentalCdnUrl?: string; - fetch?: (typeof globalThis)["fetch"]; - }) { + fetch?: typeof globalThis.fetch; + } & Pick) { try { const res = await fetch( - __experimentalCdnUrl ?? - `${url ?? ABBY_BASE_URL}api/v1/data/${projectId}${ + cdnUrl ?? + `${url ?? ABBY_BASE_URL}api/${apiVersion ?? "v1"}/data/${projectId}${ environment ? `?environment=${environment}` : "" }` ); diff --git a/packages/core/src/shared/schemas.ts b/packages/core/src/shared/schemas.ts index 26564932..f239ea6e 100644 --- a/packages/core/src/shared/schemas.ts +++ b/packages/core/src/shared/schemas.ts @@ -9,6 +9,9 @@ export const abbyEventSchema = z.object({ selectedVariant: z.string(), }); +export const SAFE_NAME_REGEX = /^[a-zA-Z0-9-_]+$/; +export const safeNameSchema = z.string().regex(SAFE_NAME_REGEX); + export type AbbyEvent = z.infer; export const remoteConfigValue = z.union([ @@ -31,18 +34,20 @@ export const abbyConfigSchema = z.object({ tests: z .record( z.object({ - variants: z.array(z.string()), + variants: z.array(safeNameSchema), }) ) .optional(), - flags: z.array(z.string()).optional(), - remoteConfig: z.record(remoteConfigValueStringSchema).optional(), + flags: z.array(safeNameSchema).optional(), + remoteConfig: z + .record(safeNameSchema, remoteConfigValueStringSchema) + .optional(), settings: z .object({ flags: z .object({ defaultValue: z.boolean().optional(), - devOverrides: z.record(z.string(), z.boolean()).optional(), + devOverrides: z.record(safeNameSchema, z.boolean()).optional(), }) .optional(), remoteConfig: z @@ -64,9 +69,15 @@ export const abbyConfigSchema = z.object({ .object({ disableByDefault: z.boolean().optional(), expiresInDays: z.number().optional(), + disableInDevelopment: z.boolean().optional(), + }) + .optional(), + experimental: z + .object({ + cdnUrl: z.string().optional(), + apiVersion: z.enum(["v1", "v2"]).optional(), }) .optional(), - __experimentalCdnUrl: z.string().optional(), }) satisfies z.ZodType; export type AbbyConfigFile = z.infer; diff --git a/packages/core/src/shared/types.ts b/packages/core/src/shared/types.ts index 3eb16ca9..97790b86 100644 --- a/packages/core/src/shared/types.ts +++ b/packages/core/src/shared/types.ts @@ -1,11 +1,15 @@ -import type { ABConfig, RemoteConfigValue } from ".."; +import type { + ABConfig, + RemoteConfigValue, + SerializedAbbyDataResponse, +} from ".."; export enum AbbyEventType { PING = 0, ACT = 1, } -export type AbbyDataResponse = { +export type AbbyData = { tests: Array<{ name: string; weights: number[]; @@ -17,6 +21,8 @@ export type AbbyDataResponse = { remoteConfig: Array<{ name: string; value: RemoteConfigValue }>; }; +export type AbbyDataResponse = SerializedAbbyDataResponse | AbbyData; + export type LegacyAbbyDataResponse = { tests: Array<{ name: string; diff --git a/packages/core/tests/helpers.test.ts b/packages/core/tests/helpers.test.ts new file mode 100644 index 00000000..f8a6413b --- /dev/null +++ b/packages/core/tests/helpers.test.ts @@ -0,0 +1,183 @@ +import { + hashStringToInt32, + parseAbbyData, + serializeAbbyData, + type AbbyDataResponse, +} from "../src"; + +describe("Abby Config (de)serialization", () => { + it("should serialize and deserialize Abby Config with empty tests", () => { + const mock: AbbyDataResponse = { + tests: [], + flags: [ + { + name: "FeatureFlagA", + value: true, + }, + { + name: "FeatureFlagB", + value: false, + }, + ], + remoteConfig: [ + { + name: "ConfigA", + value: "defaultValue", + }, + { + name: "ConfigB", + value: 12345, + }, + { + name: "ConfigC", + value: { + key1: "value1", + key2: "value2", + }, + }, + ], + }; + + const serialized = serializeAbbyData(mock); + + const deserialized = parseAbbyData(serialized); + + expect(deserialized).toEqual(mock); + }); + + it("should serialize and deserialize Abby Config with empty flags", () => { + const mock: AbbyDataResponse = { + tests: [ + { + name: "A/B Test - Button Color", + weights: [0.5, 0.5], + }, + { + name: "Landing Page Layout", + weights: [0.4, 0.6], + }, + ], + flags: [], + remoteConfig: [ + { + name: "ConfigA", + value: "defaultValue", + }, + { + name: "ConfigB", + value: 12345, + }, + { + name: "ConfigC", + value: { + key1: "value1", + key2: "value2", + }, + }, + ], + }; + + const serialized = serializeAbbyData(mock); + + const deserialized = parseAbbyData(serialized); + + expect(deserialized).toEqual(mock); + }); + + it("should serialize and deserialize Abby Config with empty remote config", () => { + const mock: AbbyDataResponse = { + tests: [ + { + name: "A/B Test - Button Color", + weights: [0.5, 0.5], + }, + { + name: "Landing Page Layout", + weights: [0.4, 0.6], + }, + ], + flags: [ + { + name: "FeatureFlagA", + value: true, + }, + { + name: "FeatureFlagB", + value: false, + }, + ], + remoteConfig: [], + }; + + const serialized = serializeAbbyData(mock); + + const deserialized = parseAbbyData(serialized); + + expect(deserialized).toEqual(mock); + }); + + it("should minify the config", () => { + const mock: AbbyDataResponse = { + tests: [ + { + name: "A/B Test - Button Color", + weights: [0.5, 0.5], + }, + { + name: "Landing Page Layout", + weights: [0.4, 0.6], + }, + ], + flags: [ + { + name: "FeatureFlagA", + value: true, + }, + { + name: "FeatureFlagB", + value: false, + }, + ], + remoteConfig: [ + { + name: "ConfigA", + value: "defaultValue", + }, + { + name: "ConfigB", + value: 12345, + }, + { + name: "ConfigC", + value: { + key1: "value1", + key2: "value2", + }, + }, + ], + }; + + const serialized = serializeAbbyData(mock); + + const deserialized = parseAbbyData(serialized); + + expect(deserialized).toEqual(mock); + expect(JSON.stringify(mock).length).toBeGreaterThan( + JSON.stringify(serialized).length + ); + }); +}); + +describe("Hashing", () => { + it("should hash flag names correctly", () => { + const flagNames = ["useFlagA", "useFlagB"]; + const [flagName, flagName2] = flagNames; + + const hashed = hashStringToInt32(flagName); + expect(hashed).toEqual(hashStringToInt32(flagName)); + + const hashed2 = hashStringToInt32(flagName2); + expect(hashed2).toEqual(hashStringToInt32(flagName2)); + expect(hashed).not.toEqual(hashed2); + }); +}); diff --git a/packages/devtools/src/Devtools.svelte b/packages/devtools/src/Devtools.svelte index b1b946a8..8934bf23 100644 --- a/packages/devtools/src/Devtools.svelte +++ b/packages/devtools/src/Devtools.svelte @@ -13,7 +13,11 @@ import Modal from "./components/Modal.svelte"; import { getShowDevtools, setShowDevtools } from "./lib/storage"; - export let position: "top-left" | "top-right" | "bottom-left" | "bottom-right" = "bottom-right"; + export let position: + | "top-left" + | "top-right" + | "bottom-left" + | "bottom-right" = "bottom-right"; export let defaultShow = false; @@ -76,10 +80,18 @@ in:send={{ key }} out:receive={{ key }} id="abby-devtools" - style:--right={position === "top-right" || position === "bottom-right" ? "1rem" : "auto"} - style:--bottom={position === "bottom-left" || position === "bottom-right" ? "1rem" : "auto"} - style:--left={position === "top-left" || position === "bottom-left" ? "1rem" : "auto"} - style:--top={position === "top-left" || position === "top-right" ? "1rem" : "auto"} + style:--right={position === "top-right" || position === "bottom-right" + ? "1rem" + : "auto"} + style:--bottom={position === "bottom-left" || position === "bottom-right" + ? "1rem" + : "auto"} + style:--left={position === "top-left" || position === "bottom-left" + ? "1rem" + : "auto"} + style:--top={position === "top-left" || position === "top-right" + ? "1rem" + : "auto"} >

Abby Devtools

@@ -98,10 +110,13 @@ {#each Object.entries(flags) as [flagName, flagValue]} { - window.postMessage({ type: "abby:update-flag", flagName, newValue }, "*"); + window.postMessage( + { type: "abby:update-flag", flagName, newValue }, + "*" + ); abby.updateFlag(flagName, newValue); }} /> @@ -112,11 +127,17 @@ {#if typeof remoteConfigValue === "string" || typeof remoteConfigValue === "number"} { - window.postMessage({ type: "abby:update-flag", remoteConfigName, newValue }, "*"); + window.postMessage( + { type: "abby:update-flag", remoteConfigName, newValue }, + "*" + ); abby.updateRemoteConfig(remoteConfigName, newValue); }} /> @@ -126,7 +147,10 @@ { - window.postMessage({ type: "abby:update-flag", remoteConfigName, newValue }, "*"); + window.postMessage( + { type: "abby:update-flag", remoteConfigName, newValue }, + "*" + ); abby.updateRemoteConfig(remoteConfigName, newValue); }} /> @@ -137,14 +161,17 @@

A/B Tests:

{#each Object.entries(tests) as [testName, { selectedVariant, variants }]}