├── .nvmrc ├── packages ├── core │ ├── .gitignore │ ├── tsconfig.json │ ├── src │ │ ├── shared │ │ │ ├── index.ts │ │ │ ├── interfaces.ts │ │ │ ├── constants.ts │ │ │ └── types.ts │ │ ├── helpers.ts │ │ └── defineConfig.ts │ ├── tsup.config.ts │ ├── tests │ │ ├── mocks │ │ │ ├── server.ts │ │ │ └── handlers.ts │ │ └── setup.ts │ └── vite.config.ts ├── next │ ├── .gitignore │ ├── tsconfig.json │ ├── tests │ │ ├── mocks │ │ │ ├── server.ts │ │ │ └── handlers.ts │ │ ├── setup.ts │ │ └── cache.test.ts │ ├── vite.config.ts │ ├── tsup.config.ts │ └── src │ │ └── cache.ts ├── node │ ├── .gitignore │ ├── tsconfig.json │ ├── tsup.config.ts │ ├── tests │ │ ├── mocks │ │ │ ├── server.ts │ │ │ └── handlers.ts │ │ ├── setup.ts │ │ └── node.test.ts │ ├── vite.config.ts │ ├── src │ │ ├── utils │ │ │ └── MemoryStorage.ts │ │ └── index.ts │ ├── fix-exports.js │ └── CHANGELOG.md ├── cli │ ├── .gitignore │ ├── src │ │ ├── schemas.ts │ │ ├── consts.ts │ │ ├── sharedOptions.ts │ │ ├── push.ts │ │ ├── auth.ts │ │ ├── init.ts │ │ └── check.ts │ ├── tests │ │ ├── mocks │ │ │ ├── server.ts │ │ │ └── handlers.ts │ │ ├── abby.config.stub.ts │ │ └── setup.ts │ ├── vite.config.ts │ ├── tsup.config.ts │ ├── tsconfig.json │ └── CHANGELOG.md ├── react │ ├── .gitignore │ ├── tsconfig.json │ ├── src │ │ └── index.ts │ ├── tests │ │ ├── mocks │ │ │ ├── server.ts │ │ │ └── handlers.ts │ │ ├── setup.ts │ │ └── helpers.test.ts │ ├── vite.config.ts │ └── tsup.config.ts ├── remix │ ├── .gitignore │ ├── tsconfig.json │ ├── tests │ │ ├── mocks │ │ │ ├── server.ts │ │ │ └── handlers.ts │ │ ├── setup.ts │ │ ├── ssr.test.tsx │ │ └── cache.test.ts │ ├── vite.config.ts │ ├── tsup.config.ts │ └── src │ │ └── cache.ts ├── devtools │ ├── .vscode │ │ └── extensions.json │ ├── src │ │ ├── vite-env.d.ts │ │ ├── components │ │ │ ├── CloseIcon.svelte │ │ │ └── ChevronIcon.svelte │ │ ├── index.ts │ │ └── lib │ │ │ ├── types.ts │ │ │ └── storage.ts │ ├── tsconfig.node.json │ ├── svelte.config.js │ ├── .gitignore │ ├── .storybook │ │ ├── preview.ts │ │ └── main.ts │ ├── vite.config.ts │ └── tsconfig.json ├── tsconfig │ ├── README.md │ ├── package.json │ ├── react-library.json │ ├── base.json │ ├── nextjs.json │ └── angular-library.json ├── svelte │ ├── tsconfig.node.json │ ├── src │ │ ├── index.ts │ │ ├── tests │ │ │ ├── mocks │ │ │ │ ├── server.ts │ │ │ │ └── handlers.ts │ │ │ ├── abby.ts │ │ │ ├── setupTest.ts │ │ │ ├── pages │ │ │ │ └── +test.svelte │ │ │ └── withAbby.test.ts │ │ └── lib │ │ │ ├── AbbyDevtools.svelte │ │ │ └── AbbyProvider.svelte │ ├── .gitignore │ ├── svelte.config.js │ └── tsconfig.json └── angular │ ├── tsconfig.spec.json │ ├── tsconfig.lib.prod.json │ ├── ng-package.json │ ├── tsconfig.lib.json │ ├── tsconfig.json │ ├── src │ ├── public-api.ts │ └── lib │ │ ├── abby-logger.service.ts │ │ ├── get-variant.pipe.ts │ │ ├── get-remote-config.pipe.ts │ │ └── devtools.component.ts │ ├── .gitignore │ ├── angular.json │ └── karma.conf.js ├── pnpm-workspace.yaml ├── .vscode ├── extensions.json └── settings.json ├── apps ├── docs │ ├── .eslintrc.json │ ├── public │ │ ├── favicon.ico │ │ └── vercel.svg │ ├── CHANGELOG.md │ ├── tsconfig.json │ ├── pages │ │ ├── integrations │ │ │ └── _meta.json │ │ ├── reference │ │ │ └── _meta.json │ │ ├── _app.mdx │ │ ├── a-b-testing.mdx │ │ ├── remote-config.mdx │ │ ├── environments.mdx │ │ ├── _meta.json │ │ └── index.mdx │ ├── .gitignore │ ├── next.config.js │ └── package.json ├── web │ ├── public │ │ ├── og.png │ │ ├── favicon.ico │ │ ├── favicon.png │ │ ├── abby_next.png │ │ ├── screenshot.png │ │ ├── Mona-Sans.woff2 │ │ ├── videos │ │ │ ├── hero.mp4 │ │ │ ├── devtools.mp4 │ │ │ └── devtools_new.mp4 │ │ ├── img │ │ │ ├── abby-next.png │ │ │ ├── abby-react.png │ │ │ ├── cut-the-bs.png │ │ │ ├── feature-flags-next.png │ │ │ ├── featureflags-for-pms.png │ │ │ └── modern-feature-flags.png │ │ └── FragmentMono-Regular.ttf │ ├── postcss.config.cjs │ ├── src │ │ ├── server │ │ │ ├── services │ │ │ │ ├── IntegrationService.ts │ │ │ │ ├── RequestService.ts │ │ │ │ ├── RequestCache.ts │ │ │ │ ├── ProjectService.ts │ │ │ │ └── InviteService.ts │ │ │ ├── common │ │ │ │ ├── memory-cache.ts │ │ │ │ ├── integrations.ts │ │ │ │ ├── ratelimit.ts │ │ │ │ ├── auth.ts │ │ │ │ ├── stripe.ts │ │ │ │ ├── get-server-auth-session.ts │ │ │ │ ├── getRequestOrigin.ts │ │ │ │ └── config-cache.ts │ │ │ ├── trpc │ │ │ │ ├── router │ │ │ │ │ ├── auth.ts │ │ │ │ │ ├── project-user.ts │ │ │ │ │ ├── _app.ts │ │ │ │ │ └── apikey.ts │ │ │ │ ├── helpers.ts │ │ │ │ └── trpc.ts │ │ │ └── db │ │ │ │ ├── redis.ts │ │ │ │ └── client.ts │ │ ├── types │ │ │ ├── flags.ts │ │ │ ├── next-auth.d.ts │ │ │ └── plausible-events.ts │ │ ├── lib │ │ │ ├── hooks │ │ │ │ ├── useProjectId.ts │ │ │ │ └── useQueryParam.ts │ │ │ ├── utils.ts │ │ │ ├── abby.tsx │ │ │ ├── shiki │ │ │ │ └── languages │ │ │ │ │ └── jinja-html.tmLanguage.json │ │ │ ├── graphs.ts │ │ │ ├── tracking.ts │ │ │ ├── logsnag.ts │ │ │ └── environment-styles.ts │ │ ├── pages │ │ │ ├── api │ │ │ │ ├── [[...route]].ts │ │ │ │ ├── trpc │ │ │ │ │ └── [trpc].ts │ │ │ │ └── checkout │ │ │ │ │ └── index.ts │ │ │ ├── invites │ │ │ │ └── index.tsx │ │ │ ├── _document.tsx │ │ │ ├── checkout │ │ │ │ └── index.tsx │ │ │ ├── _error.jsx │ │ │ ├── imprint.mdx │ │ │ ├── projects │ │ │ │ └── [projectId] │ │ │ │ │ └── remote-config.tsx │ │ │ └── redeem.tsx │ │ ├── components │ │ │ ├── Logo.tsx │ │ │ ├── DashboardButton.tsx │ │ │ ├── Divider.tsx │ │ │ ├── JSONEditor.tsx │ │ │ ├── DashboardHeader.tsx │ │ │ ├── AsyncCodeExample.tsx │ │ │ ├── Test │ │ │ │ ├── Serves.tsx │ │ │ │ └── Metrics.tsx │ │ │ ├── Toggle.tsx │ │ │ ├── ui │ │ │ │ ├── label.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── environment-badge.tsx │ │ │ │ ├── popover.tsx │ │ │ │ ├── switch.tsx │ │ │ │ └── tooltip.tsx │ │ │ ├── Button.tsx │ │ │ ├── Input.tsx │ │ │ ├── EventCounter.tsx │ │ │ ├── SignupButton.tsx │ │ │ ├── Feature.tsx │ │ │ ├── DashboardSection.tsx │ │ │ ├── RadioGroup.tsx │ │ │ ├── FlagIcon.tsx │ │ │ ├── Progress.tsx │ │ │ ├── LoadingSpinner.tsx │ │ │ └── Tooltip.tsx │ │ ├── api │ │ │ ├── routes │ │ │ │ ├── health.ts │ │ │ │ └── health.test.ts │ │ │ ├── helpers.ts │ │ │ └── index.ts │ │ ├── utils │ │ │ ├── apiKey.ts │ │ │ ├── checkSession.ts │ │ │ ├── validateFlags.ts │ │ │ ├── updateSession.ts │ │ │ └── trpc.ts │ │ ├── styles │ │ │ └── theme.json │ │ ├── env │ │ │ ├── server.mjs │ │ │ └── client.mjs │ │ └── instrumentation.ts │ ├── prisma │ │ ├── migrations │ │ │ ├── 20240903210250_add_anonymous_id_to_event │ │ │ │ └── migration.sql │ │ │ ├── 20230303082044_use_text_for_description_for_longer_texts │ │ │ │ └── migration.sql │ │ │ ├── migration_lock.toml │ │ │ ├── 20230302165144_add_description_to_feature_flag │ │ │ │ └── migration.sql │ │ │ ├── 20221212063242_add_plan_to_project │ │ │ │ └── migration.sql │ │ │ ├── 20230630140739_add_json_type │ │ │ │ └── migration.sql │ │ │ ├── 20230305160755_rename_flag_value_and_add_index_for_faster_access │ │ │ │ └── migration.sql │ │ │ ├── 20240620195120_add_apirequest_type │ │ │ │ └── migration.sql │ │ │ ├── 20240621084922_add_indices_for_apirequest │ │ │ │ └── migration.sql │ │ │ ├── 20230303130553_ │ │ │ │ └── migration.sql │ │ │ ├── 20231207205533_add_api_version_to_request │ │ │ │ └── migration.sql │ │ │ ├── 20230115171608_remove │ │ │ │ └── migration.sql │ │ │ ├── 20230901065851_remove_hashed_ip_field │ │ │ │ └── migration.sql │ │ │ ├── 20221208172715_ │ │ │ │ └── migration.sql │ │ │ ├── 20230222182342_add_sort_index_for_env │ │ │ │ └── migration.sql │ │ │ ├── 20221208134716_use_decimal_for_weights │ │ │ │ └── migration.sql │ │ │ ├── 20230602080502_remove_json_field_from_flagtype_enum │ │ │ │ └── migration.sql │ │ │ ├── 20230104080059_make_featureflag_name_unique_per_project │ │ │ │ └── migration.sql │ │ │ ├── 20230101140121_use_role_enum_for_project_users │ │ │ │ └── migration.sql │ │ │ ├── 20230303181813_remove_env_flag_relation │ │ │ │ └── migration.sql │ │ │ ├── 20231207205701_add_apiversion_enum │ │ │ │ └── migration.sql │ │ │ ├── 20230611180244_move_flag_type_to_flag_instead_of_value │ │ │ │ └── migration.sql │ │ │ ├── 20230303182053_make_id_for_flag_values_unique │ │ │ │ └── migration.sql │ │ │ ├── 20230903075910_add_user_onboarding_information │ │ │ │ └── migration.sql │ │ │ ├── 20221205132203_ │ │ │ │ └── migration.sql │ │ │ ├── 20230104081514_fix │ │ │ │ └── migration.sql │ │ │ ├── 20230511064125_renmae_price_id_field_for_code │ │ │ │ └── migration.sql │ │ │ ├── 20230901064929_add_requests │ │ │ │ └── migration.sql │ │ │ ├── 20221207080237_add_event_table │ │ │ │ └── migration.sql │ │ │ ├── 20230511063052_add_coupon_codes_table │ │ │ │ └── migration.sql │ │ │ ├── 20230210162012_add_history │ │ │ │ └── migration.sql │ │ │ ├── 20240826064116_add_integrations │ │ │ │ └── migration.sql │ │ │ ├── 20221205162949_make_name_and_project_id_unique_identifier_for_test │ │ │ │ └── migration.sql │ │ │ ├── 20230601161058_update_flag_history │ │ │ │ └── migration.sql │ │ │ ├── 20221208152910_add_project_user │ │ │ │ └── migration.sql │ │ │ ├── 20230628081242_add_api_key_table │ │ │ │ └── migration.sql │ │ │ ├── 20230803094512_change_api_key_validity_logic │ │ │ │ └── migration.sql │ │ │ ├── 20221208123701_add_project_invites │ │ │ │ └── migration.sql │ │ │ ├── 20230601155320_add_multivariate_flags │ │ │ │ └── migration.sql │ │ │ ├── 20230326163301_make_payment_interval_required │ │ │ │ └── migration.sql │ │ │ ├── 20221205131914_bruh │ │ │ │ └── migration.sql │ │ │ ├── 20230731133223_rename_apikey │ │ │ │ └── migration.sql │ │ │ ├── 20221205133208_ │ │ │ │ └── migration.sql │ │ │ ├── 20221210195541_add_stripe_info_to_projects │ │ │ │ └── migration.sql │ │ │ ├── 20230303181212_update_history │ │ │ │ └── migration.sql │ │ │ ├── 20250131071445_add_flag_rulesets │ │ │ │ └── migration.sql │ │ │ └── 20230104071050_add_feature_flags │ │ │ │ └── migration.sql │ │ ├── sql │ │ │ ├── getEventsByTestIdForDay.sql │ │ │ ├── getEventsByTestIdForLast30Days.sql │ │ │ └── getEventsByTestIdForAllTime.sql │ │ └── generateCoupons.ts │ ├── vercel.json │ ├── docker-compose.mailhog.yaml │ ├── vitest.config.ts │ ├── components.json │ ├── next-sitemap.config.js │ ├── sentry.server.config.ts │ ├── sentry.client.config.ts │ ├── .env.example │ ├── sentry.edge.config.ts │ ├── .gitignore │ ├── tsconfig.json │ ├── abby.config.ts │ ├── emails │ │ └── index.tsx │ └── README.md └── cdn │ ├── README.md │ ├── .gitignore │ ├── tsconfig.json │ ├── package.json │ └── src │ └── lib │ └── config.ts ├── .dockerignore ├── .changeset ├── config.json └── README.md ├── .github └── workflows │ ├── pull_request.yaml │ ├── tests.yaml │ └── publish.yaml ├── .gitignore ├── docker-compose.yaml ├── package.json └── docs └── integrations.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 22.8.0 -------------------------------------------------------------------------------- /packages/core/.gitignore: -------------------------------------------------------------------------------- 1 | dist -------------------------------------------------------------------------------- /packages/next/.gitignore: -------------------------------------------------------------------------------- 1 | dist -------------------------------------------------------------------------------- /packages/node/.gitignore: -------------------------------------------------------------------------------- 1 | dist -------------------------------------------------------------------------------- /packages/cli/.gitignore: -------------------------------------------------------------------------------- 1 | dist/* -------------------------------------------------------------------------------- /packages/react/.gitignore: -------------------------------------------------------------------------------- 1 | dist -------------------------------------------------------------------------------- /packages/remix/.gitignore: -------------------------------------------------------------------------------- 1 | dist -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "apps/*" 3 | - "packages/*" 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["svelte.svelte-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /apps/docs/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": ["custom"] 4 | } 5 | -------------------------------------------------------------------------------- /apps/web/public/og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tryabby/abby/HEAD/apps/web/public/og.png -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | apps/angular-example 3 | apps/docs 4 | node_modules/ 5 | **/node_modules/ -------------------------------------------------------------------------------- /apps/web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tryabby/abby/HEAD/apps/web/public/favicon.ico -------------------------------------------------------------------------------- /apps/web/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tryabby/abby/HEAD/apps/web/public/favicon.png -------------------------------------------------------------------------------- /packages/devtools/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["svelte.svelte-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /apps/cdn/README.md: -------------------------------------------------------------------------------- 1 | ``` 2 | npm install 3 | npm run dev 4 | ``` 5 | 6 | ``` 7 | npm run deploy 8 | ``` 9 | -------------------------------------------------------------------------------- /apps/docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tryabby/abby/HEAD/apps/docs/public/favicon.ico -------------------------------------------------------------------------------- /apps/web/public/abby_next.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tryabby/abby/HEAD/apps/web/public/abby_next.png -------------------------------------------------------------------------------- /apps/web/public/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tryabby/abby/HEAD/apps/web/public/screenshot.png -------------------------------------------------------------------------------- /apps/web/public/Mona-Sans.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tryabby/abby/HEAD/apps/web/public/Mona-Sans.woff2 -------------------------------------------------------------------------------- /apps/web/public/videos/hero.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tryabby/abby/HEAD/apps/web/public/videos/hero.mp4 -------------------------------------------------------------------------------- /apps/web/public/img/abby-next.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tryabby/abby/HEAD/apps/web/public/img/abby-next.png -------------------------------------------------------------------------------- /apps/web/public/img/abby-react.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tryabby/abby/HEAD/apps/web/public/img/abby-react.png -------------------------------------------------------------------------------- /apps/web/public/img/cut-the-bs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tryabby/abby/HEAD/apps/web/public/img/cut-the-bs.png -------------------------------------------------------------------------------- /apps/web/public/videos/devtools.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tryabby/abby/HEAD/apps/web/public/videos/devtools.mp4 -------------------------------------------------------------------------------- /packages/devtools/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /apps/web/public/FragmentMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tryabby/abby/HEAD/apps/web/public/FragmentMono-Regular.ttf -------------------------------------------------------------------------------- /apps/web/public/videos/devtools_new.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tryabby/abby/HEAD/apps/web/public/videos/devtools_new.mp4 -------------------------------------------------------------------------------- /apps/web/public/img/feature-flags-next.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tryabby/abby/HEAD/apps/web/public/img/feature-flags-next.png -------------------------------------------------------------------------------- /packages/cli/src/schemas.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const addCommandTypeSchema = z.enum(["flag", "config"]); 4 | -------------------------------------------------------------------------------- /apps/web/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /apps/web/public/img/featureflags-for-pms.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tryabby/abby/HEAD/apps/web/public/img/featureflags-for-pms.png -------------------------------------------------------------------------------- /apps/web/public/img/modern-feature-flags.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tryabby/abby/HEAD/apps/web/public/img/modern-feature-flags.png -------------------------------------------------------------------------------- /packages/tsconfig/README.md: -------------------------------------------------------------------------------- 1 | # `tsconfig` 2 | 3 | These are base shared `tsconfig.json`s from which all other `tsconfig.json`'s inherit from. 4 | -------------------------------------------------------------------------------- /apps/docs/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # docs 2 | 3 | ## 0.2.0 4 | 5 | ### Minor Changes 6 | 7 | - add edge/api function helpers and non-react a/b test function 8 | -------------------------------------------------------------------------------- /apps/web/src/server/services/IntegrationService.ts: -------------------------------------------------------------------------------- 1 | export namespace IntegrationService { 2 | // createIntegration(projectId: string, In) 3 | } 4 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20240903210250_add_anonymous_id_to_event/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `Event` ADD COLUMN `anonymousId` VARCHAR(191) NULL; 3 | -------------------------------------------------------------------------------- /apps/web/src/types/flags.ts: -------------------------------------------------------------------------------- 1 | import type { RemoteConfigValueString } from "@tryabby/core"; 2 | 3 | export type FlagValueString = RemoteConfigValueString | "Boolean"; 4 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20230303082044_use_text_for_description_for_longer_texts/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `FeatureFlag` MODIFY `description` TEXT NULL; 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "mysql" -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20230302165144_add_description_to_feature_flag/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `FeatureFlag` ADD COLUMN `description` VARCHAR(191) NULL; 3 | -------------------------------------------------------------------------------- /apps/docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/nextjs.json", 3 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "theme.config.jsx"], 4 | "exclude": ["node_modules"] 5 | } -------------------------------------------------------------------------------- /apps/cdn/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .wrangler 4 | .dev.vars 5 | 6 | # Change them to your taste: 7 | wrangler.toml 8 | package-lock.json 9 | yarn.lock 10 | pnpm-lock.yaml -------------------------------------------------------------------------------- /apps/web/src/lib/hooks/useProjectId.ts: -------------------------------------------------------------------------------- 1 | import { useUnsafeQueryParam } from "./useQueryParam"; 2 | 3 | export function useProjectId() { 4 | return useUnsafeQueryParam("projectId"); 5 | } 6 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20221212063242_add_plan_to_project/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `Project` ADD COLUMN `plan` ENUM('FREE', 'STARTER', 'ENTERPRISE') NOT NULL DEFAULT 'FREE'; 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20230630140739_add_json_type/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `FeatureFlag` MODIFY `type` ENUM('BOOLEAN', 'STRING', 'NUMBER', 'JSON') NOT NULL DEFAULT 'BOOLEAN'; 3 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/react-library.json", 3 | "compilerOptions": { 4 | "lib": ["ESNext", "DOM"], 5 | "types": ["vitest/globals"] 6 | } 7 | } -------------------------------------------------------------------------------- /packages/next/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/react-library.json", 3 | "compilerOptions": { 4 | "lib": ["ESNext", "DOM"], 5 | "types": ["vitest/globals"] 6 | } 7 | } -------------------------------------------------------------------------------- /packages/node/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/react-library.json", 3 | "compilerOptions": { 4 | "lib": ["ESNext", "DOM"], 5 | "types": ["vitest/globals"] 6 | } 7 | } -------------------------------------------------------------------------------- /packages/react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/react-library.json", 3 | "compilerOptions": { 4 | "lib": ["ESNext", "DOM"], 5 | "types": ["vitest/globals"] 6 | } 7 | } -------------------------------------------------------------------------------- /packages/remix/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/react-library.json", 3 | "compilerOptions": { 4 | "lib": ["ESNext", "DOM"], 5 | "types": ["vitest/globals"] 6 | } 7 | } -------------------------------------------------------------------------------- /apps/web/src/server/common/memory-cache.ts: -------------------------------------------------------------------------------- 1 | import createCacheRealm from "@databases/cache"; 2 | 3 | const { createCache } = createCacheRealm({ maximumSize: 10_000 }); 4 | 5 | export default createCache; 6 | -------------------------------------------------------------------------------- /packages/svelte/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node" 6 | }, 7 | "include": ["vite.config.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20230305160755_rename_flag_value_and_add_index_for_faster_access/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateIndex 2 | CREATE INDEX `Environment_projectId_name_idx` ON `Environment`(`projectId`, `name`); 3 | -------------------------------------------------------------------------------- /apps/web/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "crons": [ 3 | { 4 | "path": "/api/invalidate-limits?secretKey=yfMWV3TC0xyLvEKoHjslTp8GeKFEFRDtfVckg3Y2LHA=", 5 | "schedule": "0 0 * * *" 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /packages/devtools/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node" 6 | }, 7 | "include": ["vite.config.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/node/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | dts: true, 5 | sourcemap: true, 6 | treeshake: true, 7 | format: ["cjs", "esm"], 8 | }); 9 | -------------------------------------------------------------------------------- /packages/svelte/src/index.ts: -------------------------------------------------------------------------------- 1 | // Reexport your entry components here 2 | 3 | export { createAbby } from "./lib/createAbby"; 4 | export { type ABConfig, type AbbyConfig, defineConfig } from "@tryabby/core"; 5 | -------------------------------------------------------------------------------- /apps/web/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20240620195120_add_apirequest_type/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `ApiRequest` MODIFY `type` ENUM('GET_CONFIG', 'GET_CONFIG_SCRIPT', 'TRACK_VIEW', 'TRACK_CONVERSION') NOT NULL; 3 | -------------------------------------------------------------------------------- /packages/core/src/shared/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./types"; 2 | export * from "./schemas"; 3 | export * from "./constants"; 4 | export * from "./helpers"; 5 | export * from "./http"; 6 | export * from "./interfaces"; 7 | -------------------------------------------------------------------------------- /packages/svelte/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /dist 5 | /.svelte-kit 6 | /package 7 | .env 8 | .env.* 9 | !.env.example 10 | vite.config.js.timestamp-* 11 | vite.config.ts.timestamp-* 12 | -------------------------------------------------------------------------------- /packages/react/src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | createAbby, 3 | type withDevtoolsFunction, 4 | type ABTestReturnValue, 5 | } from "./context"; 6 | export { type ABConfig, type AbbyConfig, defineConfig } from "@tryabby/core"; 7 | -------------------------------------------------------------------------------- /packages/cli/src/consts.ts: -------------------------------------------------------------------------------- 1 | import os from "node:os"; 2 | import path from "node:path"; 3 | 4 | export const ABBY_BASE_URL = "https://www.tryabby.com"; 5 | 6 | export const getTokenFilePath = () => path.join(os.homedir(), ".abby"); 7 | -------------------------------------------------------------------------------- /packages/core/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | dts: true, 5 | clean: true, 6 | sourcemap: true, 7 | treeshake: true, 8 | format: ["cjs", "esm"], 9 | }); 10 | -------------------------------------------------------------------------------- /packages/angular/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/spec", 5 | "types": ["jasmine", "node"] 6 | }, 7 | "include": ["**/*.spec.ts", "**/*.d.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/angular/tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.lib.json", 3 | "compilerOptions": { 4 | "declarationMap": false 5 | }, 6 | "angularCompilerOptions": { 7 | "compilationMode": "partial" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/angular/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "./dist", 4 | "lib": { 5 | "entryFile": "src/public-api.ts" 6 | }, 7 | "allowedNonPeerDependencies": ["@tryabby/core"] 8 | } 9 | -------------------------------------------------------------------------------- /apps/docs/pages/integrations/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "nextjs": "Next.js", 3 | "react": "React", 4 | "remix": "Remix", 5 | "svelte": "Svelte", 6 | "angular": "Angular", 7 | "node": "Node", 8 | "express": "Express", 9 | "koa": "Koa" 10 | } 11 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20240621084922_add_indices_for_apirequest/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateIndex 2 | CREATE INDEX `ApiRequest_createdAt_idx` ON `ApiRequest`(`createdAt`); 3 | 4 | -- CreateIndex 5 | CREATE INDEX `ApiRequest_type_idx` ON `ApiRequest`(`type`); 6 | -------------------------------------------------------------------------------- /apps/web/src/pages/api/[[...route]].ts: -------------------------------------------------------------------------------- 1 | import { handle } from "@hono/node-server/vercel"; 2 | import { app } from "api"; 3 | 4 | export default handle(app); 5 | 6 | export const config = { 7 | api: { 8 | bodyParser: false, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /packages/cli/tests/mocks/server.ts: -------------------------------------------------------------------------------- 1 | import { setupServer } from "msw/node"; 2 | import { handlers } from "./handlers"; 3 | 4 | // This configures a request mocking server with the given request handlers. 5 | export const server = setupServer(...handlers); 6 | -------------------------------------------------------------------------------- /packages/core/tests/mocks/server.ts: -------------------------------------------------------------------------------- 1 | import { setupServer } from "msw/node"; 2 | import { handlers } from "./handlers"; 3 | 4 | // This configures a request mocking server with the given request handlers. 5 | export const server = setupServer(...handlers); 6 | -------------------------------------------------------------------------------- /packages/next/tests/mocks/server.ts: -------------------------------------------------------------------------------- 1 | import { setupServer } from "msw/node"; 2 | import { handlers } from "./handlers"; 3 | 4 | // This configures a request mocking server with the given request handlers. 5 | export const server = setupServer(...handlers); 6 | -------------------------------------------------------------------------------- /packages/node/tests/mocks/server.ts: -------------------------------------------------------------------------------- 1 | import { setupServer } from "msw/node"; 2 | import { handlers } from "./handlers"; 3 | 4 | // This configures a request mocking server with the given request handlers. 5 | export const server = setupServer(...handlers); 6 | -------------------------------------------------------------------------------- /packages/react/tests/mocks/server.ts: -------------------------------------------------------------------------------- 1 | import { setupServer } from "msw/node"; 2 | import { handlers } from "./handlers"; 3 | 4 | // This configures a request mocking server with the given request handlers. 5 | export const server = setupServer(...handlers); 6 | -------------------------------------------------------------------------------- /packages/remix/tests/mocks/server.ts: -------------------------------------------------------------------------------- 1 | import { setupServer } from "msw/node"; 2 | import { handlers } from "./handlers"; 3 | 4 | // This configures a request mocking server with the given request handlers. 5 | export const server = setupServer(...handlers); 6 | -------------------------------------------------------------------------------- /packages/tsconfig/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tsconfig", 3 | "version": "0.0.0", 4 | "private": true, 5 | "files": [ 6 | "base.json", 7 | "nextjs.json", 8 | "react-library.json", 9 | "angular-library.json" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /apps/docs/pages/reference/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "nextjs": "Next.js", 3 | "react": "React", 4 | "remix": "Remix", 5 | "svelte": "Svelte", 6 | "angular": "Angular", 7 | "http": "HTTP API", 8 | "cli": "CLI", 9 | "operators": "Operators" 10 | } 11 | -------------------------------------------------------------------------------- /packages/svelte/src/tests/mocks/server.ts: -------------------------------------------------------------------------------- 1 | import { setupServer } from "msw/node"; 2 | import { handlers } from "./handlers"; 3 | 4 | // This configures a request mocking server with the given request handlers. 5 | export const server = setupServer(...handlers); 6 | -------------------------------------------------------------------------------- /apps/web/docker-compose.mailhog.yaml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | services: 4 | mailhog: 5 | image: mailhog/mailhog 6 | logging: 7 | driver: 'none' # disable saving logs 8 | ports: 9 | - 1025:1025 # smtp server 10 | - 8025:8025 # web ui -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20230303130553_/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `name` on the `FlagValue` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE `FlagValue` DROP COLUMN `name`; 9 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20231207205533_add_api_version_to_request/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `ApiRequest` ADD COLUMN `apiVersion` VARCHAR(191) NOT NULL DEFAULT 'v0', 3 | MODIFY `type` ENUM('GET_CONFIG', 'GET_CONFIG_SCRIPT', 'TRACK_VIEW') NOT NULL; 4 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20230115171608_remove/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `plan` on the `Project` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE `Project` DROP COLUMN `plan`; 9 | -------------------------------------------------------------------------------- /apps/web/src/components/Logo.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | as?: React.ElementType; 3 | }; 4 | 5 | export default function Logo({ as: Component = "span" }: Props) { 6 | return ( 7 | Abby 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /packages/cli/vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { defineConfig } from "vite"; 4 | 5 | export default defineConfig({ 6 | test: { 7 | globals: true, 8 | environment: "jsdom", 9 | setupFiles: "./tests/setup.ts", 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /packages/core/vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { defineConfig } from "vite"; 4 | 5 | export default defineConfig({ 6 | test: { 7 | globals: true, 8 | environment: "jsdom", 9 | setupFiles: "./tests/setup.ts", 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /packages/node/vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { defineConfig } from "vite"; 4 | 5 | export default defineConfig({ 6 | test: { 7 | globals: true, 8 | environment: "jsdom", 9 | setupFiles: "./tests/setup.ts", 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /packages/devtools/svelte.config.js: -------------------------------------------------------------------------------- 1 | import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; 2 | 3 | export default { 4 | // Consult https://svelte.dev/docs#compile-time-svelte-preprocess 5 | // for more information about preprocessors 6 | preprocess: vitePreprocess({}), 7 | }; 8 | -------------------------------------------------------------------------------- /apps/docs/pages/_app.mdx: -------------------------------------------------------------------------------- 1 | import PlausibleProvider from "next-plausible"; 2 | 3 | export default function MyApp({ Component, pageProps }) { 4 | return ( 5 | 6 | 7 | 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20230901065851_remove_hashed_ip_field/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `hashedIp` on the `ApiRequest` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE `ApiRequest` DROP COLUMN `hashedIp`; 9 | -------------------------------------------------------------------------------- /apps/web/src/components/DashboardButton.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentPropsWithRef } from "react"; 2 | import { Button } from "./ui/button"; 3 | 4 | type Props = ComponentPropsWithRef<"button">; 5 | 6 | export function DashboardButton({ className, ...props }: Props) { 7 | return 22 | 23 | ); 24 | }; 25 | 26 | export default Projects; 27 | -------------------------------------------------------------------------------- /packages/next/src/cache.ts: -------------------------------------------------------------------------------- 1 | export class PromiseCache { 2 | private ttl: number; 3 | private cache: Map }> = 4 | new Map(); 5 | 6 | constructor(ttl = 1000 * 60) { 7 | this.ttl = ttl; 8 | } 9 | 10 | public async get(key: string, fn: () => Promise): Promise { 11 | const cached = this.cache.get(key); 12 | if (cached && Date.now() - cached.storedAt < this.ttl) { 13 | return cached.value; 14 | } 15 | 16 | const promise = fn(); 17 | this.cache.set(key, { value: promise, storedAt: Date.now() }); 18 | return promise; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/remix/src/cache.ts: -------------------------------------------------------------------------------- 1 | export class PromiseCache { 2 | private ttl: number; 3 | private cache: Map }> = 4 | new Map(); 5 | 6 | constructor(ttl = 1000 * 60) { 7 | this.ttl = ttl; 8 | } 9 | 10 | public async get(key: string, fn: () => Promise): Promise { 11 | const cached = this.cache.get(key); 12 | if (cached && Date.now() - cached.storedAt < this.ttl) { 13 | return cached.value; 14 | } 15 | 16 | const promise = fn(); 17 | this.cache.set(key, { value: promise, storedAt: Date.now() }); 18 | return promise; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/tsconfig/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Default", 4 | "compilerOptions": { 5 | "composite": false, 6 | "declaration": true, 7 | "declarationMap": true, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "inlineSources": false, 11 | "isolatedModules": true, 12 | "moduleResolution": "node", 13 | "noUnusedLocals": false, 14 | "noUnusedParameters": false, 15 | "preserveWatchOutput": true, 16 | "skipLibCheck": true, 17 | "strict": true 18 | }, 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /apps/web/src/server/common/stripe.ts: -------------------------------------------------------------------------------- 1 | import { env } from "env/server.mjs"; 2 | import Stripe from "stripe"; 3 | 4 | if (process.browser) 5 | throw new Error( 6 | "DO NOT USE stripe/server.ts IN THE BROWSER AS YOU WILL EXPOSE FULL CONTROL OVER YOUR STRIPE ACCOUNT!" 7 | ); 8 | 9 | if (!env.STRIPE_SECRET_KEY) 10 | throw new Error("Please provide a STRIPE_SECRET_KEY environment variable!"); 11 | 12 | const stripe = new Stripe(env.STRIPE_SECRET_KEY, { 13 | // @ts-ignore The Stripe docs state that null denotes the Stripe account's default version and to use ts-ignore 14 | apiVersion: null, 15 | }); 16 | 17 | export { stripe }; 18 | -------------------------------------------------------------------------------- /packages/core/src/helpers.ts: -------------------------------------------------------------------------------- 1 | // taken from https://stackoverflow.com/questions/3393854/get-and-set-a-single-cookie-with-node-js-http-server 2 | export function parseCookies(cookieHeader: string) { 3 | const cookieRegexp = /([^;=\s]*)=([^;]*)/g; 4 | const cookies: Record = {}; 5 | // biome-ignore lint/suspicious/noAssignInExpressions:> 6 | // biome-ignore lint/suspicious/noImplicitAnyLet:> 7 | for (let m; (m = cookieRegexp.exec(cookieHeader)); ) { 8 | const [, cookieName, cookieValue] = m; 9 | cookies[decodeURIComponent(cookieName)] = decodeURIComponent(cookieValue); 10 | } 11 | 12 | return cookies; 13 | } 14 | -------------------------------------------------------------------------------- /apps/web/src/pages/api/trpc/[trpc].ts: -------------------------------------------------------------------------------- 1 | import { createNextApiHandler } from "@trpc/server/adapters/next"; 2 | 3 | import { env } from "../../../env/server.mjs"; 4 | import { createContext } from "../../../server/trpc/context"; 5 | import { appRouter } from "../../../server/trpc/router/_app"; 6 | 7 | // export API handler 8 | export default createNextApiHandler({ 9 | router: appRouter, 10 | batching: { enabled: false }, 11 | createContext, 12 | onError: 13 | env.NODE_ENV === "development" 14 | ? ({ path, error }) => { 15 | console.error(`❌ tRPC failed on ${path}: ${error}`); 16 | } 17 | : undefined, 18 | }); 19 | -------------------------------------------------------------------------------- /apps/docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "0.2.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "@types/node": "^18.0.0", 12 | "@types/react": "18.0.26", 13 | "@types/react-dom": "^18.0.5", 14 | "next": "14.1.1", 15 | "next-plausible": "^3.11.3", 16 | "nextra": "2.13.2", 17 | "nextra-theme-docs": "2.13.2", 18 | "react": "18.2.0", 19 | "react-dom": "18.2.0", 20 | "typescript": "5.5.4" 21 | }, 22 | "devDependencies": { 23 | "tsconfig": "workspace:^0.0.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /apps/web/src/api/routes/health.test.ts: -------------------------------------------------------------------------------- 1 | import { testClient } from "hono/testing"; 2 | import { makeHealthRoute } from "./health"; 3 | 4 | vi.mock("server/db/client", () => ({ 5 | prisma: { 6 | verificationToken: { 7 | count: vi.fn(async () => 1), 8 | }, 9 | }, 10 | })); 11 | 12 | vi.mock("server/db/redis", () => ({ 13 | redis: { 14 | get: vi.fn(async () => "test"), 15 | }, 16 | })); 17 | 18 | it("should work", async () => { 19 | const app = makeHealthRoute(); 20 | 21 | const res = await testClient(app).index.$get(); 22 | 23 | expect(res.status).toEqual(200); 24 | expect(await res.json()).toEqual({ status: "ok" }); 25 | }); 26 | -------------------------------------------------------------------------------- /apps/web/src/lib/shiki/languages/jinja-html.tmLanguage.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jinja-html", 3 | "scopeName": "text.html.jinja", 4 | "comment": "Jinja HTML Templates", 5 | "firstLineMatch": "^{% extends [\"'][^\"']+[\"'] %}", 6 | "foldingStartMarker": "(<(?i:(head|table|tr|div|style|script|ul|ol|form|dl))\\b.*?>|{%\\s*(block|filter|for|if|macro|raw))", 7 | "foldingStopMarker": "(|{%\\s*(endblock|endfilter|endfor|endif|endmacro|endraw)\\s*%})", 8 | "patterns": [ 9 | { 10 | "include": "source.jinja" 11 | }, 12 | { 13 | "include": "text.html.basic" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /apps/web/sentry.server.config.ts: -------------------------------------------------------------------------------- 1 | // This file configures the initialization of Sentry on the server. 2 | // The config you add here will be used whenever the server handles a request. 3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ 4 | 5 | import * as Sentry from "@sentry/nextjs"; 6 | 7 | Sentry.init({ 8 | dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, 9 | 10 | // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. 11 | tracesSampleRate: 1, 12 | 13 | // Setting this option to true will print useful information to the console while you're setting up Sentry. 14 | debug: false, 15 | }); 16 | -------------------------------------------------------------------------------- /apps/web/src/server/common/get-server-auth-session.ts: -------------------------------------------------------------------------------- 1 | import type { GetServerSidePropsContext } from "next"; 2 | import { unstable_getServerSession } from "next-auth"; 3 | 4 | import { authOptions } from "../../pages/api/auth/[...nextauth]"; 5 | 6 | /** 7 | * Wrapper for unstable_getServerSession https://next-auth.js.org/configuration/nextjs 8 | * See example usage in trpc createContext or the restricted API route 9 | */ 10 | export const getServerAuthSession = async (ctx: { 11 | req: GetServerSidePropsContext["req"]; 12 | res: GetServerSidePropsContext["res"]; 13 | }) => { 14 | return await unstable_getServerSession(ctx.req, ctx.res, authOptions); 15 | }; 16 | -------------------------------------------------------------------------------- /packages/svelte/src/tests/setupTest.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import { afterEach } from "vitest"; 3 | /// @ts-ignore it doesn't have types 4 | import { server } from "./mocks/server"; 5 | 6 | /// @ts-ignore 7 | global.fetch = fetch; 8 | 9 | // Establish API mocking before all tests. 10 | beforeAll(() => server.listen()); 11 | 12 | // Clean up after the tests are finished. 13 | afterAll(() => server.close()); 14 | 15 | // runs a cleanup after each test case (e.g. clearing jsdom) 16 | afterEach(() => { 17 | // Reset any request handlers that we may add during the tests, 18 | // so they don't affect other tests. 19 | server.resetHandlers(); 20 | }); 21 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20221208152910_add_project_user/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `ProjectUser` ( 3 | `id` VARCHAR(191) NOT NULL, 4 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 5 | `updatedAt` DATETIME(3) NOT NULL, 6 | `userId` VARCHAR(191) NOT NULL, 7 | `projectId` VARCHAR(191) NOT NULL, 8 | `role` INTEGER NOT NULL DEFAULT 0, 9 | 10 | INDEX `ProjectUser_userId_idx`(`userId`), 11 | INDEX `ProjectUser_projectId_idx`(`projectId`), 12 | UNIQUE INDEX `ProjectUser_userId_projectId_key`(`userId`, `projectId`), 13 | PRIMARY KEY (`id`) 14 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 15 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20230628081242_add_api_key_table/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `APIKey` ( 3 | `id` VARCHAR(191) NOT NULL, 4 | `name` VARCHAR(191) NOT NULL, 5 | `hashedKey` VARCHAR(191) NOT NULL, 6 | `validDays` INTEGER NOT NULL, 7 | `isRevoked` BOOLEAN NOT NULL DEFAULT false, 8 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 9 | `updatedAt` DATETIME(3) NOT NULL, 10 | `userId` VARCHAR(191) NOT NULL, 11 | 12 | UNIQUE INDEX `APIKey_hashedKey_key`(`hashedKey`), 13 | INDEX `APIKey_userId_idx`(`userId`), 14 | PRIMARY KEY (`id`) 15 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 16 | -------------------------------------------------------------------------------- /apps/web/sentry.client.config.ts: -------------------------------------------------------------------------------- 1 | // This file configures the initialization of Sentry on the client. 2 | // The config you add here will be used whenever a users loads a page in their browser. 3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ 4 | 5 | import * as Sentry from "@sentry/nextjs"; 6 | 7 | Sentry.init({ 8 | dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, 9 | 10 | // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. 11 | tracesSampleRate: 1, 12 | 13 | // Setting this option to true will print useful information to the console while you're setting up Sentry. 14 | debug: false, 15 | }); 16 | -------------------------------------------------------------------------------- /apps/web/src/server/common/getRequestOrigin.ts: -------------------------------------------------------------------------------- 1 | import type { IncomingMessage } from "node:http"; 2 | 3 | /** 4 | * Reliably get a request's origin, even when deployed on serverless functions 5 | * 6 | * @example 7 | * ```ts 8 | * const route = (req, res) => { 9 | * const origin = getRequestOrigin(req) 10 | * } 11 | * ``` 12 | */ 13 | export const getRequestOrigin = (req: IncomingMessage): string => 14 | // The x-forwarded-proto header is the only reliable way to determine HTTP vs HTTPS 15 | // with Vercel serverless functions and Netlify functions. 16 | `${req.headers["x-forwarded-proto"] === "https" ? "https" : "http"}://${ 17 | req.headers.host 18 | }`; 19 | -------------------------------------------------------------------------------- /packages/svelte/src/tests/pages/+test.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | {#if $flag1} 12 |
my super secret feature 1
13 | {/if} 14 | 15 | {#if $remoteConfig1 !== "FooBar"} 16 |
my remoteConfig1 value
17 | {/if} 18 |
19 | 20 | -------------------------------------------------------------------------------- /packages/tsconfig/nextjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Next.js", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "target": "es5", 7 | "lib": ["dom", "dom.iterable", "esnext"], 8 | "allowJs": true, 9 | "skipLibCheck": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "noEmit": true, 13 | "incremental": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "preserve" 19 | }, 20 | "include": ["src", "next-env.d.ts"], 21 | "exclude": ["node_modules"] 22 | } 23 | -------------------------------------------------------------------------------- /packages/angular/src/lib/abby-logger.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from "@angular/core"; 2 | import type { AbbyConfig } from "@tryabby/core"; 3 | import { ABBY_CONFIG_TOKEN } from "./abby.module"; 4 | 5 | @Injectable() 6 | export class AbbyLoggerService { 7 | readonly LOGGER_SCOPE = "ng.Abby"; 8 | 9 | constructor(@Inject(ABBY_CONFIG_TOKEN) private config: AbbyConfig) {} 10 | 11 | log(...args: unknown[]): void { 12 | if (this.config.debug) { 13 | console.log(this.LOGGER_SCOPE, ...args); 14 | } 15 | } 16 | 17 | warn(...args: unknown[]): void { 18 | if (this.config.debug) { 19 | console.warn(this.LOGGER_SCOPE, ...args); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20230803094512_change_api_key_validity_logic/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `isRevoked` on the `ApiKey` table. All the data in the column will be lost. 5 | - You are about to drop the column `validDays` on the `ApiKey` table. All the data in the column will be lost. 6 | - Added the required column `validUntil` to the `ApiKey` table without a default value. This is not possible if the table is not empty. 7 | 8 | */ 9 | -- AlterTable 10 | ALTER TABLE `ApiKey` DROP COLUMN `isRevoked`, 11 | DROP COLUMN `validDays`, 12 | ADD COLUMN `revokedAt` DATETIME(3) NULL, 13 | ADD COLUMN `validUntil` DATETIME(3) NOT NULL; 14 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20221208123701_add_project_invites/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `ProjectInvite` ( 3 | `id` VARCHAR(191) NOT NULL, 4 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 5 | `updatedAt` DATETIME(3) NOT NULL, 6 | `email` VARCHAR(191) NOT NULL, 7 | `projectId` VARCHAR(191) NOT NULL, 8 | `userId` VARCHAR(191) NULL, 9 | `usedAt` DATETIME(3) NULL, 10 | 11 | INDEX `ProjectInvite_projectId_idx`(`projectId`), 12 | INDEX `ProjectInvite_userId_idx`(`userId`), 13 | UNIQUE INDEX `ProjectInvite_projectId_email_key`(`projectId`, `email`), 14 | PRIMARY KEY (`id`) 15 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 16 | -------------------------------------------------------------------------------- /apps/web/src/lib/graphs.ts: -------------------------------------------------------------------------------- 1 | import colors from "tailwindcss/colors"; 2 | 3 | const forbiddenColors: Array = [ 4 | "slate", 5 | "gray", 6 | "zinc", 7 | "neutral", 8 | "stone", 9 | "black", 10 | "blueGray", 11 | "coolGray", 12 | "warmGray", 13 | "current", 14 | "inherit", 15 | "transparent", 16 | ]; 17 | 18 | const COLORS = Object.keys(colors) 19 | .flatMap((key) => { 20 | const currentColor = key as keyof typeof colors; 21 | if (forbiddenColors.includes(currentColor)) return []; 22 | return colors[currentColor][300]; 23 | }) 24 | .filter(Boolean); 25 | 26 | export function getColorByIndex(index: number) { 27 | return COLORS[index % COLORS.length]; 28 | } 29 | -------------------------------------------------------------------------------- /apps/web/src/server/services/RequestCache.ts: -------------------------------------------------------------------------------- 1 | import { redis } from "server/db/redis"; 2 | 3 | export abstract class RequestCache { 4 | private static getCacheKey(projectId: string) { 5 | return `requests:${projectId}:`; 6 | } 7 | 8 | static async increment(projectId: string) { 9 | return redis.incr(RequestCache.getCacheKey(projectId)); 10 | } 11 | 12 | static async get(projectId: string) { 13 | return Number(await redis.get(RequestCache.getCacheKey(projectId))); 14 | } 15 | 16 | static async reset(projectId: string | string[]) { 17 | return redis.del( 18 | new Array() 19 | .concat(projectId) 20 | .map((id) => RequestCache.getCacheKey(id)) 21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /apps/web/src/utils/validateFlags.ts: -------------------------------------------------------------------------------- 1 | import { FeatureFlagType } from "@prisma/client"; 2 | 3 | export function validateFlag(flagType: FeatureFlagType, value: string) { 4 | switch (flagType) { 5 | case FeatureFlagType.BOOLEAN: { 6 | return value === "true" || value === "false"; 7 | } 8 | case FeatureFlagType.NUMBER: { 9 | return !Number.isNaN(Number(value)); 10 | } 11 | case FeatureFlagType.STRING: { 12 | return true; 13 | } 14 | case FeatureFlagType.JSON: { 15 | try { 16 | JSON.parse(value); 17 | return true; 18 | } catch (_e) { 19 | return false; 20 | } 21 | } 22 | } 23 | // exhaustivenes test for switch 24 | flagType satisfies never; 25 | } 26 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20230601155320_add_multivariate_flags/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `isEnabled` on the `FlagValue` table. All the data in the column will be lost. 5 | - Added the required column `value` to the `FlagValue` table without a default value. This is not possible if the table is not empty. 6 | 7 | */ 8 | -- AlterTable 9 | ALTER TABLE `FlagValue` 10 | ADD COLUMN `type` ENUM('BOOLEAN', 'STRING', 'NUMBER', 'JSON') NOT NULL DEFAULT 'BOOLEAN', 11 | ADD COLUMN `value` LONGTEXT NOT NULL; 12 | 13 | UPDATE `FlagValue` SET `value` = IF(`isEnabled`, 'true', 'false') WHERE `type` = 'BOOLEAN'; 14 | 15 | -- DropColumn 16 | ALTER TABLE `FlagValue` 17 | DROP COLUMN `isEnabled`; 18 | -------------------------------------------------------------------------------- /apps/web/src/pages/_error.jsx: -------------------------------------------------------------------------------- 1 | import * as Sentry from "@sentry/nextjs"; 2 | // biome-ignore lint/suspicious/noShadowRestrictedNames: 3 | import Error from "next/error"; 4 | 5 | const CustomErrorComponent = (props) => { 6 | return ; 7 | }; 8 | 9 | CustomErrorComponent.getInitialProps = async (contextData) => { 10 | // In case this is running in a serverless function, await this in order to give Sentry 11 | // time to send the error before the lambda exits 12 | await Sentry.captureUnderscoreErrorException(contextData); 13 | 14 | // This will contain the status code of the response 15 | return Error.getInitialProps(contextData); 16 | }; 17 | 18 | export default CustomErrorComponent; 19 | -------------------------------------------------------------------------------- /apps/web/prisma/sql/getEventsByTestIdForAllTime.sql: -------------------------------------------------------------------------------- 1 | -- @param {String} $1:testId 2 | -- @param {Int} $2:type 3 | -- @param {String} $3:testIdAgain 4 | -- @param {Int} $4:typeAgain 5 | 6 | SELECT 7 | COUNT(DISTINCT CASE WHEN anonymousId IS NOT NULL THEN 8 | anonymousId 9 | ELSE 10 | CONCAT('NULL_', UUID()) 11 | END) AS uniqueEventCount, 12 | COUNT(*) AS eventCount, 13 | TYPE, 14 | createdAt, 15 | selectedVariant 16 | FROM 17 | `Event` 18 | WHERE 19 | testId = ? 20 | AND TYPE = ? 21 | AND(DATE(createdAt) BETWEEN DATE(( 22 | SELECT 23 | MIN(createdAt) 24 | FROM `Event` 25 | WHERE 26 | testId = ? 27 | AND TYPE = ?)) 28 | AND DATE(NOW())) 29 | GROUP BY 30 | DATE(createdAt), selectedVariant 31 | ORDER BY createdAt ASC; -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20230326163301_make_payment_interval_required/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `stripeCurrentPeriodEnd` on the `Project` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- AlterTable 8 | SET SQL_MODE='ALLOW_INVALID_DATES'; 9 | 10 | ALTER TABLE `Project` ADD COLUMN `currentPeriodEnd` DATETIME(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP(3) + INTERVAL 30 DAY); 11 | 12 | UPDATE `Project` SET `currentPeriodEnd` = `stripeCurrentPeriodEnd` WHERE `stripeCurrentPeriodEnd` IS NOT NULL; 13 | UPDATE `Project` SET `currentPeriodEnd` = (CURRENT_TIMESTAMP(3) + INTERVAL 30 DAY) WHERE `stripeCurrentPeriodEnd` IS NULL; 14 | 15 | ALTER TABLE `Project` DROP COLUMN `stripeCurrentPeriodEnd`; 16 | -------------------------------------------------------------------------------- /apps/web/src/types/next-auth.d.ts: -------------------------------------------------------------------------------- 1 | import type { DefaultSession } from "next-auth"; 2 | 3 | type UserSession = { 4 | id: string; 5 | image: string; 6 | projectIds: string[]; 7 | lastOpenProjectId: string | undefined; 8 | hasCompletedOnboarding: boolean; 9 | }; 10 | 11 | declare module "next-auth" { 12 | /** 13 | * Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context 14 | */ 15 | interface Session { 16 | user?: UserSession & DefaultSession["user"]; 17 | } 18 | } 19 | 20 | declare module "next-auth/jwt" { 21 | /** Returned by the `jwt` callback and `getToken`, when using JWT sessions */ 22 | interface JWT { 23 | /** OpenID ID Token */ 24 | user: {} & UserSession; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /apps/web/.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL=mysql://root:password@localhost:3306/abby 2 | 3 | NODE_ENV=development 4 | 5 | NEXTAUTH_SECRET=secret 6 | NEXTAUTH_URL=http://localhost:3000 7 | EMAIL_SERVER=smtp://0.0.0.0:1025 8 | GITHUB_ID=null 9 | GITHUB_SECRET=null 10 | 11 | GOOGLE_CLIENT_ID= 12 | GOOGLE_CLIENT_SECRET= 13 | 14 | REDIS_URL=redis://localhost:6379 15 | ABBY_FROM_EMAIL=no-reply@tryabby.dev 16 | GITHUB_OAUTH_TOKEN= 17 | 18 | STRIPE_WEBHOOK_SECRET=null 19 | STRIPE_SECRET_KEY=null 20 | LOGSNAG_API_KEY=null 21 | HASHING_SECRET=secret 22 | 23 | NEXT_PUBLIC_PLAUSIBLE_DOMAIN=tryabby.com 24 | NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=null 25 | NEXT_PUBLIC_STRIPE_STARTER_PLAN_PRICE_ID=null 26 | NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE_ID=null 27 | NEXT_PUBLIC_ABBY_PROJECT_ID=null 28 | -------------------------------------------------------------------------------- /packages/devtools/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ESNext", 5 | "useDefineForClassFields": true, 6 | "module": "ESNext", 7 | "resolveJsonModule": true, 8 | /** 9 | * Typecheck JS in `.svelte` and `.js` files by default. 10 | * Disable checkJs if you'd like to use dynamic types in JS. 11 | * Note that setting allowJs false does not prevent the use 12 | * of JS in `.svelte` files. 13 | */ 14 | "allowJs": true, 15 | "checkJs": true, 16 | "isolatedModules": true, 17 | "rootDir": "src" 18 | }, 19 | "include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.js", "src/**/*.svelte"], 20 | "references": [{ "path": "./tsconfig.node.json" }] 21 | } 22 | -------------------------------------------------------------------------------- /packages/svelte/src/lib/AbbyProvider.svelte: -------------------------------------------------------------------------------- 1 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /packages/angular/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # Compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /bazel-out 8 | 9 | # Node 10 | /node_modules 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # IDEs and editors 15 | .idea/ 16 | .project 17 | .classpath 18 | .c9/ 19 | *.launch 20 | .settings/ 21 | *.sublime-workspace 22 | 23 | # Visual Studio Code 24 | .vscode/* 25 | !.vscode/settings.json 26 | !.vscode/tasks.json 27 | !.vscode/launch.json 28 | !.vscode/extensions.json 29 | .history/* 30 | 31 | # Miscellaneous 32 | /.angular/cache 33 | .sass-cache/ 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | testem.log 38 | /typings 39 | TESTS.xml 40 | 41 | # System files 42 | .DS_Store 43 | Thumbs.db 44 | -------------------------------------------------------------------------------- /packages/svelte/svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from "@sveltejs/adapter-auto"; 2 | import { vitePreprocess } from "@sveltejs/kit/vite"; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors 7 | // for more information about preprocessors 8 | preprocess: vitePreprocess(), 9 | 10 | kit: { 11 | // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. 12 | // If your environment is not supported or you settled on a specific environment, switch out the adapter. 13 | // See https://kit.svelte.dev/docs/adapters for more information about adapters. 14 | adapter: adapter(), 15 | }, 16 | }; 17 | 18 | export default config; 19 | -------------------------------------------------------------------------------- /packages/svelte/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ESNext", 5 | "useDefineForClassFields": true, 6 | "module": "ESNext", 7 | "resolveJsonModule": true, 8 | /** 9 | * Typecheck JS in `.svelte` and `.js` files by default. 10 | * Disable checkJs if you'd like to use dynamic types in JS. 11 | * Note that setting allowJs false does not prevent the use 12 | * of JS in `.svelte` files. 13 | */ 14 | "allowJs": true, 15 | "checkJs": true, 16 | "isolatedModules": true 17 | }, 18 | "include": [ 19 | "src/**/*.d.ts", 20 | "src/**/*.ts", 21 | "src/**/*.js", 22 | "src/**/*.svelte" 23 | ], 24 | "references": [ 25 | { 26 | "path": "./tsconfig.node.json" 27 | } 28 | ] 29 | } -------------------------------------------------------------------------------- /apps/web/src/components/Test/Serves.tsx: -------------------------------------------------------------------------------- 1 | import { DonutChart } from "components/charts/Donut"; 2 | import type { ProjectClientEvents } from "pages/projects/[projectId]"; 3 | import type { ClientOption } from "server/trpc/router/project"; 4 | 5 | const Serves = ({ 6 | pingEvents, 7 | options, 8 | }: { 9 | pingEvents: ProjectClientEvents; 10 | options: ClientOption[]; 11 | }) => { 12 | const labels = options.map((option) => option.identifier); 13 | 14 | return ( 15 |
16 | acc + e._count._all, 0)} 18 | variants={labels} 19 | events={pingEvents} 20 | totalText="Visits" 21 | /> 22 |
23 | ); 24 | }; 25 | 26 | export { Serves }; 27 | -------------------------------------------------------------------------------- /apps/web/src/components/Test/Metrics.tsx: -------------------------------------------------------------------------------- 1 | import { DonutChart } from "components/charts/Donut"; 2 | import type { ProjectClientEvents } from "pages/projects/[projectId]"; 3 | import type { ClientOption } from "server/trpc/router/project"; 4 | 5 | const Metrics = ({ 6 | actEvents, 7 | options, 8 | }: { 9 | actEvents: ProjectClientEvents; 10 | options: ClientOption[]; 11 | }) => { 12 | const labels = options.map((option) => option.identifier); 13 | 14 | return ( 15 |
16 | acc + e._count._all, 0)} 18 | variants={labels} 19 | events={actEvents} 20 | totalText="Conversions" 21 | /> 22 |
23 | ); 24 | }; 25 | 26 | export { Metrics }; 27 | -------------------------------------------------------------------------------- /packages/angular/src/lib/get-variant.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, type PipeTransform } from "@angular/core"; 2 | import type { ABConfig } from "@tryabby/core"; 3 | import type { Observable } from "rxjs"; 4 | import type { Key } from "ts-toolbelt/out/Any/Key"; 5 | // biome-ignore lint/style/useImportType: angular needs this 6 | import { AbbyService } from "./abby.service"; 7 | 8 | @Pipe({ 9 | name: "getAbbyVariant", 10 | }) 11 | export class GetAbbyVariantPipe< 12 | const TestName extends Key, 13 | const Tests extends Record, 14 | > implements PipeTransform 15 | { 16 | constructor(private abbyService: AbbyService) {} 17 | 18 | transform(testName: TestName): Observable { 19 | return this.abbyService.getVariant(testName); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/devtools/src/components/ChevronIcon.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 | 17 | 18 | 19 |
20 | 21 | 34 | -------------------------------------------------------------------------------- /packages/node/src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ABConfig, 3 | Abby, 4 | type AbbyConfig, 5 | type RemoteConfigValueString, 6 | } from "@tryabby/core"; 7 | export { defineConfig } from "@tryabby/core"; 8 | import { InMemoryStorageService } from "./utils/MemoryStorage"; 9 | 10 | export function createAbby< 11 | const FlagName extends string, 12 | const TestName extends string, 13 | const Tests extends Record, 14 | const RemoteConfig extends Record, 15 | const RemoteConfigName extends Extract, 16 | >( 17 | config: AbbyConfig 18 | ) { 19 | const testStorage = new InMemoryStorageService(); 20 | return new Abby(config, testStorage); 21 | } 22 | -------------------------------------------------------------------------------- /apps/web/src/components/Toggle.tsx: -------------------------------------------------------------------------------- 1 | import { useId } from "react"; 2 | import { Switch } from "./ui/switch"; 3 | export function Toggle({ 4 | label, 5 | onChange, 6 | isChecked, 7 | }: { 8 | isChecked?: boolean; 9 | label: string; 10 | onChange: (value: boolean) => void; 11 | }) { 12 | const id = useId(); 13 | return ( 14 |
15 | onChange(checked)} 19 | /> 20 | 26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /apps/web/prisma/generateCoupons.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs/promises"; 2 | import path from "node:path"; 3 | import { PrismaClient } from "@prisma/client"; 4 | 5 | const prisma = new PrismaClient(); 6 | 7 | const COUPON_CODE_AMOUNT = 200; 8 | 9 | async function main() { 10 | const fileName = path.join(__dirname, "./coupons.csv"); 11 | 12 | const items = Array.from({ length: COUPON_CODE_AMOUNT }).map(() => ({ 13 | stripePriceId: "STARTUP_LIFETIME", 14 | })); 15 | 16 | const codes = await prisma.$transaction( 17 | items.map((item) => prisma.couponCodes.create({ data: item })) 18 | ); 19 | 20 | const csv = codes.map((code) => code.code).join("\n"); 21 | 22 | await fs.writeFile(fileName, csv); 23 | 24 | console.log(`Wrote ${COUPON_CODE_AMOUNT} codes to ${fileName}`); 25 | } 26 | 27 | main(); 28 | -------------------------------------------------------------------------------- /apps/web/sentry.edge.config.ts: -------------------------------------------------------------------------------- 1 | // This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on). 2 | // The config you add here will be used whenever one of the edge features is loaded. 3 | // Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally. 4 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ 5 | 6 | import * as Sentry from "@sentry/nextjs"; 7 | 8 | Sentry.init({ 9 | dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, 10 | 11 | // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. 12 | tracesSampleRate: 1, 13 | 14 | // Setting this option to true will print useful information to the console while you're setting up Sentry. 15 | debug: false, 16 | }); 17 | -------------------------------------------------------------------------------- /apps/web/src/pages/imprint.mdx: -------------------------------------------------------------------------------- 1 | import { MarketingLayout } from "components/MarketingLayout"; 2 | 3 | # Imprint 4 | 5 | **dynabase Technologies GmbH** 6 | Von-Werth-Str. 37 7 | 50670 Köln 8 | Telefon: [+49-221-588-307-0](tel:+49-221-588-307-0) 9 | Telefax: [+49-221-588-307-0](fax:+49-221-588-307-00) 10 | E-Mail: [mail@dynabase.de](mailto:mail@dynabase.de) 11 | 12 | Geschäftsführer: Daniel Angileri, Norman Wenk 13 | Registergericht: Amtsgericht Köln 14 | Registernummer: HRB 91004 - USt-IdNr.: DE312712619 15 | Inhaltlich Verantwortliche: Daniel Angileri, Norman Wenk 16 | 17 | export default ({ children }) => ( 18 | 19 | {children} 20 | 21 | ); 22 | 23 | export const getStaticProps = () => { 24 | return { 25 | props: {}, 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /apps/docs/pages/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "index": "Getting Started", 3 | "integrations": "Integrations", 4 | "reference": "Reference", 5 | "-- Guides --": { 6 | "type": "separator", 7 | "title": "Guides" 8 | }, 9 | "nextjs": "Next.js", 10 | "devtools": "Devtools", 11 | "-- Concepts --": { 12 | "type": "separator", 13 | "title": "Concepts" 14 | }, 15 | "feature-flags": "Feature Flags", 16 | "remote-config": "Remote Configuration", 17 | "environments": "Environments", 18 | "config": "Configuration as Code", 19 | "user-segments": "User Segments", 20 | "a-b-testing": "A/B Tests", 21 | "-- More --": { 22 | "type": "separator", 23 | "title": "More" 24 | }, 25 | "abby_link": { 26 | "title": "Abby ↗", 27 | "href": "https://www.tryabby.com", 28 | "newWindow": true 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as LabelPrimitive from "@radix-ui/react-label"; 2 | import { type VariantProps, cva } from "class-variance-authority"; 3 | import * as React from "react"; 4 | 5 | import { cn } from "lib/utils"; 6 | 7 | const labelVariants = cva( 8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 9 | ); 10 | 11 | const Label = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef & 14 | VariantProps 15 | >(({ className, ...props }, ref) => ( 16 | 21 | )); 22 | Label.displayName = LabelPrimitive.Root.displayName; 23 | 24 | export { Label }; 25 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20221205131914_bruh/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `Project` ( 3 | `id` VARCHAR(191) NOT NULL, 4 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 5 | `updatedAt` DATETIME(3) NOT NULL, 6 | `name` VARCHAR(191) NOT NULL, 7 | 8 | PRIMARY KEY (`id`) 9 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 10 | 11 | -- CreateTable 12 | CREATE TABLE `ProjectMemeber` ( 13 | `id` VARCHAR(191) NOT NULL, 14 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 15 | `updatedAt` DATETIME(3) NOT NULL, 16 | `userId` VARCHAR(191) NOT NULL, 17 | `projectId` VARCHAR(191) NOT NULL, 18 | 19 | UNIQUE INDEX `ProjectMemeber_userId_projectId_key`(`userId`, `projectId`), 20 | PRIMARY KEY (`id`) 21 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 22 | -------------------------------------------------------------------------------- /apps/web/src/lib/tracking.ts: -------------------------------------------------------------------------------- 1 | import { useOpenPanel } from "@openpanel/nextjs"; 2 | import { usePlausible } from "next-plausible"; 3 | import { useCallback } from "react"; 4 | import type { EventOptionsTuple } from "server/common/tracking"; 5 | import type { PlausibleEvents } from "types/plausible-events"; 6 | 7 | export const useTracking = () => { 8 | const trackPlausible = usePlausible(); 9 | const { track: trackOpenPanel } = useOpenPanel(); 10 | 11 | return useCallback( 12 | ( 13 | eventName: N, 14 | ...rest: PlausibleEvents[N] extends never 15 | ? [] 16 | : EventOptionsTuple 17 | ) => { 18 | trackPlausible(eventName, ...rest); 19 | trackOpenPanel(eventName, ...rest); 20 | }, 21 | [trackPlausible, trackOpenPanel] 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /apps/web/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # database 12 | /prisma/db.sqlite 13 | /prisma/db.sqlite-journal 14 | 15 | # next.js 16 | /.next/ 17 | /out/ 18 | next-env.d.ts 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # local env files 34 | # do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables 35 | .env 36 | .env*.local 37 | 38 | # vercel 39 | .vercel 40 | 41 | # typescript 42 | *.tsbuildinfo 43 | 44 | public/sitemap* 45 | public/robots.txt 46 | prisma/coupons.csv 47 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20230731133223_rename_apikey/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the `APIKey` table. If the table is not empty, all the data it contains will be lost. 5 | 6 | */ 7 | -- DropTable 8 | DROP TABLE `APIKey`; 9 | 10 | -- CreateTable 11 | CREATE TABLE `ApiKey` ( 12 | `id` VARCHAR(191) NOT NULL, 13 | `name` VARCHAR(191) NOT NULL, 14 | `hashedKey` VARCHAR(191) NOT NULL, 15 | `validDays` INTEGER NOT NULL, 16 | `isRevoked` BOOLEAN NOT NULL DEFAULT false, 17 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 18 | `updatedAt` DATETIME(3) NOT NULL, 19 | `userId` VARCHAR(191) NOT NULL, 20 | 21 | UNIQUE INDEX `ApiKey_hashedKey_key`(`hashedKey`), 22 | INDEX `ApiKey_userId_idx`(`userId`), 23 | PRIMARY KEY (`id`) 24 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 25 | -------------------------------------------------------------------------------- /apps/web/src/pages/api/checkout/index.ts: -------------------------------------------------------------------------------- 1 | import { type Stripe, loadStripe } from "@stripe/stripe-js"; 2 | 3 | let stripePromise: Promise | null = null; 4 | 5 | const getStripe = () => { 6 | if (!stripePromise) { 7 | if (!process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) return; 8 | stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY); 9 | } 10 | return stripePromise as Promise; 11 | }; 12 | 13 | export async function checkout({ 14 | lineItems, 15 | }: { 16 | lineItems: { price: string; quantity: number }[]; 17 | }) { 18 | const stripe = await getStripe(); 19 | if (!stripe) return; 20 | await stripe.redirectToCheckout({ 21 | mode: "payment", 22 | lineItems, 23 | successUrl: `${window.location.origin}?session_id={CHECKOUT_SESSION_ID}`, 24 | cancelUrl: window.location.origin, 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /packages/core/tests/mocks/handlers.ts: -------------------------------------------------------------------------------- 1 | import { rest } from "msw"; 2 | import { ABBY_BASE_URL, type AbbyDataResponse } from "../../src/shared"; 3 | 4 | const returnData: AbbyDataResponse = { 5 | tests: [ 6 | { 7 | name: "test", 8 | weights: [1, 1, 1, 1], 9 | }, 10 | { 11 | name: "test2", 12 | weights: [1, 0], 13 | }, 14 | ], 15 | flags: [ 16 | { 17 | name: "flag1", 18 | value: true, 19 | }, 20 | ], 21 | remoteConfig: [{ name: "remoteConfig1", value: "asdf" }], 22 | }; 23 | 24 | export const handlers = [ 25 | rest.get( 26 | `${ABBY_BASE_URL}api/dashboard/:projectId/data`, 27 | (_req, res, ctx) => { 28 | return res(ctx.json(returnData)); 29 | } 30 | ), 31 | rest.get(`${ABBY_BASE_URL}api/v2/data/:projectId`, (_req, res, ctx) => { 32 | return res(ctx.json(returnData)); 33 | }), 34 | ]; 35 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "lib/utils"; 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ); 21 | } 22 | ); 23 | Input.displayName = "Input"; 24 | 25 | export { Input }; 26 | -------------------------------------------------------------------------------- /apps/web/src/styles/theme.json: -------------------------------------------------------------------------------- 1 | { 2 | "--background": "231.43 42.17% 16.27%", 3 | "--foreground": "222.2 47.4% 11.2%", 4 | "--muted": "210 40% 96.1%", 5 | "--muted-foreground": "215.4 16.3% 46.9%", 6 | "--primary": "222.2 47.4% 11.2%", 7 | "--primary-foreground": "210 40% 98%", 8 | "--secondary": "210 40% 96.1%", 9 | "--secondary-foreground": "222.2 47.4% 11.2%", 10 | "--popover": "0 0% 100%", 11 | "--popover-foreground": "222.2 47.4% 11.2%", 12 | "--input": "214.3 31.8% 91.4%", 13 | "--accent": "210 40% 96.1%", 14 | "--accent-foreground": "222.2 47.4% 11.2%", 15 | "--card": "0 0% 100%", 16 | "--card-foreground": "222.2 47.4% 11.2%", 17 | "--border": "214.3 31.8% 91.4%", 18 | "--destructive": "0 100% 50%", 19 | "--destructive-foreground": "210 40% 98%", 20 | "--ring": "215 20.2% 65.1%", 21 | "--radius": "0.5rem", 22 | "[object Object]": "--background" 23 | } 24 | -------------------------------------------------------------------------------- /packages/core/src/shared/types.ts: -------------------------------------------------------------------------------- 1 | import type { ABConfig, FlagRuleSet, RemoteConfigValue } from ".."; 2 | 3 | export enum AbbyEventType { 4 | PING = 0, 5 | ACT = 1, 6 | } 7 | 8 | export type AbbyDataResponse = { 9 | tests: Array<{ 10 | name: string; 11 | weights: number[]; 12 | }>; 13 | flags: Array<{ 14 | name: string; 15 | value: boolean; 16 | ruleSet?: FlagRuleSet; 17 | }>; 18 | remoteConfig: Array<{ 19 | name: string; 20 | value: RemoteConfigValue; 21 | ruleSet?: FlagRuleSet; 22 | }>; 23 | }; 24 | 25 | export type LegacyAbbyDataResponse = { 26 | tests: Array<{ 27 | name: string; 28 | weights: number[]; 29 | }>; 30 | flags: Array<{ name: string; isEnabled: boolean }>; 31 | }; 32 | 33 | export type ExtractVariants< 34 | TestName extends string, 35 | Tests extends Record, 36 | > = Tests[TestName]["variants"][number]; 37 | -------------------------------------------------------------------------------- /apps/web/src/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | interface ButtonProps { 5 | as?: T; 6 | children?: React.ReactNode; 7 | } 8 | 9 | export function Button({ 10 | children, 11 | className, 12 | as, 13 | ...props 14 | }: ButtonProps & 15 | Omit, keyof ButtonProps>) { 16 | const Component = as || "button"; 17 | return ( 18 | 25 | {children} 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /packages/react/tests/setup.ts: -------------------------------------------------------------------------------- 1 | /// @ts-ignore it doesn't have types 2 | import matchers from "@testing-library/jest-dom/matchers"; 3 | import { cleanup } from "@testing-library/react"; 4 | import fetch from "node-fetch"; 5 | import { afterEach, expect } from "vitest"; 6 | import { server } from "./mocks/server"; 7 | 8 | /// @ts-ignore 9 | global.fetch = fetch; 10 | 11 | // Establish API mocking before all tests. 12 | beforeAll(() => server.listen()); 13 | 14 | // Clean up after the tests are finished. 15 | afterAll(() => server.close()); 16 | 17 | // extends Vitest's expect method with methods from react-testing-library 18 | expect.extend(matchers); 19 | 20 | // runs a cleanup after each test case (e.g. clearing jsdom) 21 | afterEach(() => { 22 | // Reset any request handlers that we may add during the tests, 23 | // so they don't affect other tests. 24 | server.resetHandlers(); 25 | cleanup(); 26 | }); 27 | -------------------------------------------------------------------------------- /packages/next/tests/setup.ts: -------------------------------------------------------------------------------- 1 | /// @ts-ignore it doesn't have types 2 | import matchers from "@testing-library/jest-dom/matchers"; 3 | import { cleanup } from "@testing-library/react"; 4 | import fetch from "node-fetch"; 5 | import { afterEach, expect } from "vitest"; 6 | import { server } from "./mocks/server"; 7 | 8 | /// @ts-ignore 9 | global.fetch = fetch; 10 | 11 | // Establish API mocking before all tests. 12 | beforeAll(() => server.listen()); 13 | // Reset any request handlers that we may add during the tests, 14 | // so they don't affect other tests. 15 | afterEach(() => server.resetHandlers()); 16 | // Clean up after the tests are finished. 17 | afterAll(() => server.close()); 18 | 19 | // extends Vitest's expect method with methods from react-testing-library 20 | expect.extend(matchers); 21 | 22 | // runs a cleanup after each test case (e.g. clearing jsdom) 23 | afterEach(() => { 24 | cleanup(); 25 | }); 26 | -------------------------------------------------------------------------------- /packages/remix/tests/setup.ts: -------------------------------------------------------------------------------- 1 | /// @ts-ignore it doesn't have types 2 | import matchers from "@testing-library/jest-dom/matchers"; 3 | import { cleanup } from "@testing-library/react"; 4 | import fetch from "node-fetch"; 5 | import { afterEach, expect } from "vitest"; 6 | import { server } from "./mocks/server"; 7 | 8 | /// @ts-ignore 9 | global.fetch = fetch; 10 | 11 | // Establish API mocking before all tests. 12 | beforeAll(() => server.listen()); 13 | // Reset any request handlers that we may add during the tests, 14 | // so they don't affect other tests. 15 | afterEach(() => server.resetHandlers()); 16 | // Clean up after the tests are finished. 17 | afterAll(() => server.close()); 18 | 19 | // extends Vitest's expect method with methods from react-testing-library 20 | expect.extend(matchers); 21 | 22 | // runs a cleanup after each test case (e.g. clearing jsdom) 23 | afterEach(() => { 24 | cleanup(); 25 | }); 26 | -------------------------------------------------------------------------------- /apps/web/src/components/Input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "lib/utils"; 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, ...props }, ref) => { 10 | return ( 11 | 20 | ); 21 | } 22 | ); 23 | Input.displayName = "Input"; 24 | 25 | export { Input }; 26 | -------------------------------------------------------------------------------- /packages/svelte/src/tests/mocks/handlers.ts: -------------------------------------------------------------------------------- 1 | import { ABBY_BASE_URL, type AbbyDataResponse } from "@tryabby/core"; 2 | import { rest } from "msw"; 3 | 4 | const returnData: AbbyDataResponse = { 5 | tests: [ 6 | { 7 | name: "test", 8 | weights: [1, 1, 1, 1], 9 | }, 10 | { 11 | name: "test2", 12 | weights: [1, 0], 13 | }, 14 | ], 15 | flags: [ 16 | { 17 | name: "flag1", 18 | value: true, 19 | }, 20 | ], 21 | remoteConfig: [ 22 | { 23 | name: "remoteConfig1", 24 | value: "FooBar", 25 | }, 26 | ], 27 | }; 28 | export const handlers = [ 29 | rest.get( 30 | `${ABBY_BASE_URL}api/dashboard/:projectId/data`, 31 | (_req, res, ctx) => { 32 | return res(ctx.json(returnData)); 33 | } 34 | ), 35 | rest.get(`${ABBY_BASE_URL}api/v2/data/:projectId`, (_req, res, ctx) => { 36 | return res(ctx.json(returnData)); 37 | }), 38 | ]; 39 | -------------------------------------------------------------------------------- /packages/cli/src/init.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs/promises"; 2 | import type { AbbyConfigFile, DynamicConfigKeys } from "@tryabby/core"; 3 | import * as prettier from "prettier"; 4 | 5 | export async function initAbbyConfig({ path }: { path: string }) { 6 | const dynamicConfig: Pick = { 7 | projectId: "", 8 | currentEnvironment: "", 9 | }; 10 | 11 | const staticConfig: Omit = { 12 | environments: [], 13 | }; 14 | 15 | const fileContent = ` 16 | import { defineConfig } from '@tryabby/core'; 17 | 18 | 19 | export default defineConfig(${JSON.stringify(dynamicConfig, null, 2)}, ${JSON.stringify( 20 | staticConfig, 21 | null, 22 | 2 23 | )}); 24 | `; 25 | 26 | await fs.writeFile( 27 | path, 28 | await prettier.format(fileContent, { parser: "typescript" }) 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20221205133208_/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `Test` ( 3 | `projectId` VARCHAR(191) NULL, 4 | `id` VARCHAR(191) NOT NULL, 5 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 6 | `updatedAt` DATETIME(3) NOT NULL, 7 | `name` VARCHAR(191) NOT NULL, 8 | 9 | INDEX `Test_projectId_idx`(`projectId`), 10 | PRIMARY KEY (`id`) 11 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 12 | 13 | -- CreateTable 14 | CREATE TABLE `Option` ( 15 | `id` VARCHAR(191) NOT NULL, 16 | `identifier` VARCHAR(191) NOT NULL, 17 | `chance` INTEGER NOT NULL, 18 | `testId` VARCHAR(191) NOT NULL, 19 | 20 | UNIQUE INDEX `Option_testId_identifier_key`(`testId`, `identifier`), 21 | PRIMARY KEY (`id`) 22 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 23 | 24 | -- CreateIndex 25 | CREATE INDEX `Project_userId_idx` ON `Project`(`userId`); 26 | -------------------------------------------------------------------------------- /apps/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "module": "esnext", 13 | "moduleResolution": "Bundler", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "preserve", 17 | "incremental": true, 18 | "noUncheckedIndexedAccess": true, 19 | "baseUrl": "src", 20 | "paths": { 21 | "server/*": ["server/*"], 22 | "pages/*": ["pages/*"], 23 | "components/*": ["components/*"] 24 | }, 25 | "types": ["vitest/globals"] 26 | }, 27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.cjs", "**/*.mjs"], 28 | "exclude": ["node_modules"] 29 | } 30 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20221210195541_add_stripe_info_to_projects/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[stripeCustomerId]` on the table `Project` will be added. If there are existing duplicate values, this will fail. 5 | - A unique constraint covering the columns `[stripeSubscriptionId]` on the table `Project` will be added. If there are existing duplicate values, this will fail. 6 | 7 | */ 8 | -- AlterTable 9 | ALTER TABLE `Project` ADD COLUMN `stripeCurrentPeriodEnd` DATETIME(3) NULL, 10 | ADD COLUMN `stripeCustomerId` VARCHAR(191) NULL, 11 | ADD COLUMN `stripePriceId` VARCHAR(191) NULL, 12 | ADD COLUMN `stripeSubscriptionId` VARCHAR(191) NULL; 13 | 14 | -- CreateIndex 15 | CREATE UNIQUE INDEX `Project_stripeCustomerId_key` ON `Project`(`stripeCustomerId`); 16 | 17 | -- CreateIndex 18 | CREATE UNIQUE INDEX `Project_stripeSubscriptionId_key` ON `Project`(`stripeSubscriptionId`); 19 | -------------------------------------------------------------------------------- /packages/node/tests/node.test.ts: -------------------------------------------------------------------------------- 1 | import { createAbby } from "../src/index"; 2 | 3 | const testVariants = ["OldFooter", "NewFooter"] as const; 4 | const test2Variants = [ 5 | "SimonsText", 6 | "MatthiasText", 7 | "TomsText", 8 | "TimsText", 9 | ] as const; 10 | 11 | it("should work properly", async () => { 12 | const abby = createAbby({ 13 | environments: [], 14 | currentEnvironment: "development", 15 | projectId: "123", 16 | tests: { 17 | test: { variants: testVariants }, 18 | test2: { 19 | variants: test2Variants, 20 | }, 21 | }, 22 | flags: ["flag1", "flag2"], 23 | }); 24 | 25 | await abby.loadProjectData(); 26 | 27 | // expect(abby.getFeatureFlag("flag1")).toBe(true); 28 | // expect(abby.getFeatureFlag("flag2")).toBe(false); 29 | expect(abby.getTestVariant("test")).to.be.oneOf(testVariants); 30 | expect(abby.getTestVariant("test2")).to.be.oneOf(test2Variants); 31 | }); 32 | -------------------------------------------------------------------------------- /apps/web/src/components/EventCounter.tsx: -------------------------------------------------------------------------------- 1 | import { AnimatePresence, motion } from "framer-motion"; 2 | import { trpc } from "utils/trpc"; 3 | 4 | export function EventCounter() { 5 | const { data } = trpc.events.getEventCount.useQuery(); 6 | 7 | return ( 8 |
9 | 10 | 17 | {new Intl.NumberFormat("en", { notation: "compact" }).format( 18 | data ?? 0 19 | )} 20 | 21 | 22 | 23 | Events processed and counting... 24 | 25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /apps/web/src/components/SignupButton.tsx: -------------------------------------------------------------------------------- 1 | import { useAbby } from "lib/abby"; 2 | import Link from "next/link"; 3 | import { twMerge } from "tailwind-merge"; 4 | 5 | export function SignupButton({ className }: { className?: string }) { 6 | const { variant, onAct } = useAbby("SignupButton"); 7 | return ( 8 |
9 | onAct()} 12 | className={twMerge( 13 | "mt-12 rounded-xl bg-ab_accent-background px-6 py-2 text-xl font-semibold text-ab_accent-foreground no-underline transition-transform duration-150 ease-in-out hover:scale-110", 14 | className 15 | )} 16 | > 17 | {variant === "A" && "Test Now"} 18 | {variant === "B" && "Sign Up for Free"} 19 | 20 | 21 | Free forever. No Credit Card required 22 | 23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /apps/web/src/env/server.mjs: -------------------------------------------------------------------------------- 1 | import { env as clientEnv, formatErrors } from "./client.mjs"; 2 | // @ts-check 3 | /** 4 | * This file is included in `/next.config.mjs` which ensures the app isn't built with invalid env vars. 5 | * It has to be a `.mjs`-file to be imported there. 6 | */ 7 | import { serverSchema } from "./schema.mjs"; 8 | 9 | const _serverEnv = serverSchema.safeParse(process.env); 10 | 11 | if (!_serverEnv.success) { 12 | console.error( 13 | "❌ Invalid environment variables:\n", 14 | ...formatErrors(_serverEnv.error.format()) 15 | ); 16 | throw new Error("Invalid environment variables"); 17 | } 18 | 19 | for (const key of Object.keys(_serverEnv.data)) { 20 | if (key.startsWith("NEXT_PUBLIC_")) { 21 | console.warn("❌ You are exposing a server-side env-variable:", key); 22 | 23 | throw new Error("You are exposing a server-side env-variable"); 24 | } 25 | } 26 | 27 | export const env = { ..._serverEnv.data, ...clientEnv }; 28 | -------------------------------------------------------------------------------- /apps/web/src/server/common/config-cache.ts: -------------------------------------------------------------------------------- 1 | import type { AbbyDataResponse } from "@tryabby/core"; 2 | import createCache from "./memory-cache"; 3 | 4 | const configCache = createCache({ 5 | name: "configCache", 6 | // expire after 24 hours 7 | expireAfterMilliseconds: 1000 * 60 * 60 * 24, 8 | }); 9 | 10 | type ConfigCacheKey = { 11 | environment: string; 12 | projectId: string; 13 | }; 14 | 15 | export abstract class ConfigCache { 16 | static getConfig({ environment, projectId }: ConfigCacheKey) { 17 | return configCache.get(projectId + environment); 18 | } 19 | 20 | static setConfig({ 21 | environment, 22 | projectId, 23 | value, 24 | }: ConfigCacheKey & { 25 | value: AbbyDataResponse; 26 | }) { 27 | configCache.set(projectId + environment, value); 28 | } 29 | 30 | static deleteConfig({ environment, projectId }: ConfigCacheKey) { 31 | configCache.delete(projectId + environment); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /apps/web/src/components/Feature.tsx: -------------------------------------------------------------------------------- 1 | import type { LucideIcon } from "lucide-react"; 2 | import type { IconType } from "react-icons"; 3 | 4 | type Props = { 5 | children: React.ReactNode; 6 | title: string; 7 | subtitle: string; 8 | icon: IconType | LucideIcon; 9 | }; 10 | 11 | export function Feature({ children, icon: Icon, subtitle, title }: Props) { 12 | return ( 13 |
14 |
15 | 16 |
17 |

{title}

18 |

19 | {subtitle} 20 |

21 |

{children}

22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /packages/devtools/src/lib/types.ts: -------------------------------------------------------------------------------- 1 | type Modifier = "command" | "ctrl" | "alt" | "shift" | "option" | "meta"; 2 | type Char = 3 | | "a" 4 | | "b" 5 | | "c" 6 | | "d" 7 | | "e" 8 | | "f" 9 | | "g" 10 | | "h" 11 | | "i" 12 | | "j" 13 | | "k" 14 | | "l" 15 | | "m" 16 | | "n" 17 | | "o" 18 | | "p" 19 | | "q" 20 | | "r" 21 | | "s" 22 | | "t" 23 | | "u" 24 | | "v" 25 | | "w" 26 | | "x" 27 | | "y" 28 | | "z"; 29 | type SpecialKey = 30 | | "." 31 | | "," 32 | | "/" 33 | | ";" 34 | | "'" 35 | | "[" 36 | | "]" 37 | | "-" 38 | | "=" 39 | | "enter" 40 | | "space" 41 | | "tab" 42 | | "backspace" 43 | | "delete" 44 | | "escape" 45 | | "up" 46 | | "down" 47 | | "left" 48 | | "right" 49 | | "pageup" 50 | | "pagedown" 51 | | "home" 52 | | "end"; 53 | 54 | type Key = Char | SpecialKey; 55 | 56 | export type Shortcut = 57 | | `${Modifier}+${Key}` 58 | | Key 59 | | `${Key} ${Key}` 60 | | (string & []); 61 | -------------------------------------------------------------------------------- /apps/web/src/lib/logsnag.ts: -------------------------------------------------------------------------------- 1 | import { env } from "env/server.mjs"; 2 | import { LogSnag } from "logsnag"; 3 | import type { PlanName } from "server/common/plans"; 4 | 5 | const logsnag = new LogSnag({ 6 | token: env.LOGSNAG_API_KEY, 7 | project: "abby", 8 | }); 9 | 10 | export function trackSignup() { 11 | if (process.env.NODE_ENV !== "production") return; 12 | return logsnag.publish({ 13 | channel: "user-register", 14 | event: "user-register", 15 | icon: "👋", 16 | notify: true, 17 | }); 18 | } 19 | 20 | export function trackPlanOverage( 21 | projectId: string, 22 | plan: PlanName | undefined, 23 | is80Percent?: boolean 24 | ) { 25 | if (process.env.NODE_ENV !== "production") return; 26 | return logsnag.publish({ 27 | channel: "plan-overrage", 28 | event: is80Percent ? "Limit reached" : "80% reached", 29 | icon: "⚠️", 30 | tags: { 31 | plan: plan ?? "HOBBY", 32 | projectId, 33 | }, 34 | notify: true, 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20230303181212_update_history/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `environmentId` on the `FeatureFlagHistory` table. All the data in the column will be lost. 5 | - You are about to drop the column `flagId` on the `FeatureFlagHistory` table. All the data in the column will be lost. 6 | - Added the required column `flagValueId` to the `FeatureFlagHistory` table without a default value. This is not possible if the table is not empty. 7 | 8 | */ 9 | -- DropIndex 10 | DROP INDEX `FeatureFlagHistory_flagId_environmentId_idx` ON `FeatureFlagHistory`; 11 | 12 | -- AlterTable 13 | ALTER TABLE `FeatureFlagHistory` DROP COLUMN `environmentId`, 14 | DROP COLUMN `flagId`, 15 | ADD COLUMN `flagValueId` VARCHAR(191) NOT NULL; 16 | 17 | -- AlterTable 18 | ALTER TABLE `FlagValue` ALTER COLUMN `id` DROP DEFAULT; 19 | 20 | -- CreateIndex 21 | CREATE INDEX `FeatureFlagHistory_flagValueId_idx` ON `FeatureFlagHistory`(`flagValueId`); 22 | -------------------------------------------------------------------------------- /apps/web/src/server/services/ProjectService.ts: -------------------------------------------------------------------------------- 1 | import { env } from "node:process"; 2 | import { ROLE } from "@prisma/client"; 3 | import { BETA_PRICE_ID } from "lib/stripe"; 4 | import { prisma } from "server/db/client"; 5 | 6 | export abstract class ProjectService { 7 | static async hasProjectAccess(projectId: string, userId: string) { 8 | return ( 9 | (await prisma.projectUser.count({ 10 | where: { 11 | projectId: projectId, 12 | userId: userId, 13 | }, 14 | })) > 0 15 | ); 16 | } 17 | static async createProject(input: { projectName: string; userId: string }) { 18 | return prisma.project.create({ 19 | data: { 20 | name: input.projectName, 21 | stripePriceId: env.NODE_ENV === "development" ? BETA_PRICE_ID : null, 22 | users: { 23 | create: { 24 | userId: input.userId, 25 | role: ROLE.ADMIN, 26 | }, 27 | }, 28 | }, 29 | }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/next/tests/mocks/handlers.ts: -------------------------------------------------------------------------------- 1 | import { ABBY_BASE_URL, type AbbyDataResponse } from "@tryabby/core"; 2 | import { rest } from "msw"; 3 | 4 | const returnData: AbbyDataResponse = { 5 | tests: [ 6 | { 7 | name: "test", 8 | weights: [1, 1, 1, 1], 9 | }, 10 | { 11 | name: "test2", 12 | weights: [1, 0], 13 | }, 14 | ], 15 | flags: [ 16 | { 17 | name: "flag1", 18 | value: true, 19 | }, 20 | { 21 | name: "flag2", 22 | value: false, 23 | }, 24 | ], 25 | remoteConfig: [ 26 | { 27 | name: "remoteConfig1", 28 | value: "FooBar", 29 | }, 30 | ], 31 | }; 32 | export const handlers = [ 33 | rest.get( 34 | `${ABBY_BASE_URL}api/dashboard/:projectId/data`, 35 | (_req, res, ctx) => { 36 | return res(ctx.json(returnData)); 37 | } 38 | ), 39 | rest.get(`${ABBY_BASE_URL}api/v2/data/:projectId`, (_req, res, ctx) => { 40 | return res(ctx.json(returnData)); 41 | }), 42 | ]; 43 | -------------------------------------------------------------------------------- /packages/node/tests/mocks/handlers.ts: -------------------------------------------------------------------------------- 1 | import { ABBY_BASE_URL, type AbbyDataResponse } from "@tryabby/core"; 2 | import { rest } from "msw"; 3 | 4 | const returnData: AbbyDataResponse = { 5 | tests: [ 6 | { 7 | name: "test", 8 | weights: [1, 1, 1, 1], 9 | }, 10 | { 11 | name: "test2", 12 | weights: [1, 0], 13 | }, 14 | ], 15 | flags: [ 16 | { 17 | name: "flag1", 18 | value: true, 19 | }, 20 | { 21 | name: "flag2", 22 | value: false, 23 | }, 24 | ], 25 | remoteConfig: [ 26 | { 27 | name: "remoteConfig1", 28 | value: "FooBar", 29 | }, 30 | ], 31 | }; 32 | export const handlers = [ 33 | rest.get( 34 | `${ABBY_BASE_URL}api/dashboard/:projectId/data`, 35 | (_req, res, ctx) => { 36 | return res(ctx.json(returnData)); 37 | } 38 | ), 39 | rest.get(`${ABBY_BASE_URL}api/v2/data/:projectId`, (_req, res, ctx) => { 40 | return res(ctx.json(returnData)); 41 | }), 42 | ]; 43 | -------------------------------------------------------------------------------- /packages/react/tests/mocks/handlers.ts: -------------------------------------------------------------------------------- 1 | import { ABBY_BASE_URL, type AbbyDataResponse } from "@tryabby/core"; 2 | import { rest } from "msw"; 3 | 4 | const returnData: AbbyDataResponse = { 5 | tests: [ 6 | { 7 | name: "test", 8 | weights: [1, 1, 1, 1], 9 | }, 10 | { 11 | name: "test2", 12 | weights: [1, 0], 13 | }, 14 | ], 15 | flags: [ 16 | { 17 | name: "flag1", 18 | value: true, 19 | }, 20 | { 21 | name: "flag2", 22 | value: false, 23 | }, 24 | ], 25 | remoteConfig: [ 26 | { 27 | name: "remoteConfig1", 28 | value: "FooBar", 29 | }, 30 | ], 31 | }; 32 | export const handlers = [ 33 | rest.get( 34 | `${ABBY_BASE_URL}api/dashboard/:projectId/data`, 35 | (_req, res, ctx) => { 36 | return res(ctx.json(returnData)); 37 | } 38 | ), 39 | rest.get(`${ABBY_BASE_URL}api/v2/data/:projectId`, (_req, res, ctx) => { 40 | return res(ctx.json(returnData)); 41 | }), 42 | ]; 43 | -------------------------------------------------------------------------------- /packages/remix/tests/mocks/handlers.ts: -------------------------------------------------------------------------------- 1 | import { ABBY_BASE_URL, type AbbyDataResponse } from "@tryabby/core"; 2 | import { rest } from "msw"; 3 | 4 | const returnData: AbbyDataResponse = { 5 | tests: [ 6 | { 7 | name: "test", 8 | weights: [1, 1, 1, 1], 9 | }, 10 | { 11 | name: "test2", 12 | weights: [1, 0], 13 | }, 14 | ], 15 | flags: [ 16 | { 17 | name: "flag1", 18 | value: true, 19 | }, 20 | { 21 | name: "flag2", 22 | value: false, 23 | }, 24 | ], 25 | remoteConfig: [ 26 | { 27 | name: "remoteConfig1", 28 | value: "FooBar", 29 | }, 30 | ], 31 | }; 32 | export const handlers = [ 33 | rest.get( 34 | `${ABBY_BASE_URL}api/dashboard/:projectId/data`, 35 | (_req, res, ctx) => { 36 | return res(ctx.json(returnData)); 37 | } 38 | ), 39 | rest.get(`${ABBY_BASE_URL}api/v2/data/:projectId`, (_req, res, ctx) => { 40 | return res(ctx.json(returnData)); 41 | }), 42 | ]; 43 | -------------------------------------------------------------------------------- /packages/cli/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @tryabby/cli 2 | 3 | ## 3.0.1 4 | 5 | ### Patch Changes 6 | 7 | - fix build 8 | 9 | ## 3.0.0 10 | 11 | ### Major Changes 12 | 13 | - add rules and user segments 14 | 15 | ## 2.0.0 16 | 17 | ### Major Changes 18 | 19 | - use typescript 5 20 | 21 | ## 1.2.1 22 | 23 | ### Patch Changes 24 | 25 | - add version to version command 26 | 27 | ## 1.2.0 28 | 29 | ### Minor Changes 30 | 31 | - update ai command 32 | 33 | ## 1.1.1 34 | 35 | ### Patch Changes 36 | 37 | - d05cb9a: add feature flag removal command 38 | 39 | ## 1.1.0 40 | 41 | ### Minor Changes 42 | 43 | - introduce new add commands 44 | 45 | ## 1.0.1 46 | 47 | ### Patch Changes 48 | 49 | - add fallback values to remote config 50 | 51 | ## 1.0.0 52 | 53 | ### Major Changes 54 | 55 | - add remote config 56 | 57 | ## 0.3.0 58 | 59 | ### Minor Changes 60 | 61 | - add auto login flow 62 | 63 | ## 0.1.0 64 | 65 | ### Minor Changes 66 | 67 | - add defineConfig helper to ease the usage with the cli 68 | -------------------------------------------------------------------------------- /apps/web/src/components/DashboardSection.tsx: -------------------------------------------------------------------------------- 1 | import { twMerge } from "tailwind-merge"; 2 | 3 | export function DashboardSection({ 4 | children, 5 | className, 6 | }: { 7 | children: React.ReactNode; 8 | className?: string; 9 | }) { 10 | return ( 11 |
14 | {children} 15 |
16 | ); 17 | } 18 | 19 | export function DashboardSectionTitle({ 20 | children, 21 | className, 22 | }: { 23 | children: React.ReactNode; 24 | className?: string; 25 | }) { 26 | return ( 27 |

{children}

28 | ); 29 | } 30 | 31 | export function DashboardSectionSubtitle({ 32 | children, 33 | className, 34 | }: { 35 | children: React.ReactNode; 36 | className?: string; 37 | }) { 38 | return ( 39 |

40 | {children} 41 |

42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /packages/angular/src/lib/get-remote-config.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, type PipeTransform } from "@angular/core"; 2 | import type { 3 | RemoteConfigValueString, 4 | RemoteConfigValueStringToType, 5 | } from "@tryabby/core"; 6 | // biome-ignore lint/style/useImportType: angular needs this 7 | import { Observable } from "rxjs"; 8 | // biome-ignore lint/style/useImportType: angular needs this 9 | import { AbbyService } from "./abby.service"; 10 | 11 | @Pipe({ 12 | name: "getAbbyRemoteConfig", 13 | }) 14 | export class GetRemoteConfigPipe< 15 | RemoteConfig extends Record, 16 | > implements PipeTransform 17 | { 18 | constructor(private abbyService: AbbyService) {} 19 | 20 | transform>( 21 | value: T 22 | ): Observable> { 23 | return this.abbyService.getRemoteConfig(value) as Observable< 24 | RemoteConfigValueStringToType 25 | >; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/svelte/src/tests/withAbby.test.ts: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | import { render, waitFor } from "@testing-library/svelte"; 3 | import { abby } from "./abby"; 4 | import testPage from "./pages/+test.svelte"; 5 | 6 | import { HttpService } from "@tryabby/core"; 7 | 8 | describe("withabby working", () => { 9 | it("works properly", async () => { 10 | const data = await HttpService.getProjectData({ 11 | projectId: "123", 12 | }); 13 | if (!data) throw new Error(""); 14 | abby.__abby__.init(data); 15 | 16 | const { getByText, queryByText } = render(testPage, { 17 | props: { 18 | data: { 19 | __abby__data: data, 20 | __abby_cookie: "", 21 | }, 22 | }, 23 | }); 24 | const flag1 = await waitFor(() => getByText("my super secret feature 1")); 25 | expect(flag1).toBeInTheDocument(); 26 | const remoteConfig1 = queryByText("my remoteConfig1 value"); 27 | expect(remoteConfig1).not.toBeInTheDocument(); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /apps/docs/pages/index.mdx: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | Abby is an open-source, fully-typed Feature Flagging & Remote Config service made for developers. 4 | 5 | It aims to be the simpliest way to do Feature Flags and Remote Configuration variables on your website. 6 | 7 | We tried to make Abby as simple as possible and create the **best** user experience. Therefore we decided to focus on `React` and `Next.js` for now. 8 | 9 | ## Getting Started 10 | 11 | Before you're able to use Abby, you need to create a free account first. 12 | After you created your account you're able to use Abby. Make sure to read the [React Docs](/integrations/react) to get started. 13 | 14 | ## Philosophy 15 | 16 | Here are some of the things that we believe in: 17 | 18 | - **Simplicity** - We believe that A/B testing should be as simple as possible. That's why we created Abby. 19 | - **Security** - All data is hosted in Europe and the code is open source. 20 | - **Transparency** - We don't store any user related data, everything is saved anonymously. 21 | -------------------------------------------------------------------------------- /apps/web/src/server/trpc/trpc.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError, initTRPC } from "@trpc/server"; 2 | import superjson from "superjson"; 3 | 4 | import type { Context } from "./context"; 5 | 6 | const t = initTRPC.context().create({ 7 | transformer: superjson, 8 | errorFormatter({ shape }) { 9 | return shape; 10 | }, 11 | }); 12 | 13 | export const router = t.router; 14 | 15 | /** 16 | * Unprotected procedure 17 | **/ 18 | export const publicProcedure = t.procedure; 19 | 20 | /** 21 | * Reusable middleware to ensure 22 | * users are logged in 23 | */ 24 | const isAuthed = t.middleware(({ ctx, next }) => { 25 | if (!ctx.session || !ctx.session.user) { 26 | throw new TRPCError({ code: "UNAUTHORIZED" }); 27 | } 28 | return next({ 29 | ctx: { 30 | // infers the `session` as non-nullable 31 | session: { ...ctx.session, user: ctx.session.user }, 32 | }, 33 | }); 34 | }); 35 | 36 | /** 37 | * Protected procedure 38 | **/ 39 | export const protectedProcedure = t.procedure.use(isAuthed); 40 | -------------------------------------------------------------------------------- /apps/web/src/types/plausible-events.ts: -------------------------------------------------------------------------------- 1 | import type { FeatureFlagType } from "@prisma/client"; 2 | import type { PlanName } from "server/common/plans"; 3 | 4 | export type Plan = PlanName | "HOBBY"; 5 | 6 | export type PlausibleEvents = { 7 | "Sign Up Clicked": never; 8 | "Plan Selected": { 9 | Plan: Plan; 10 | }; 11 | "Plan Upgrade Clicked": { 12 | Plan: Plan; 13 | }; 14 | "Project Created": never; 15 | "AB-Test Created": { 16 | "Amount Of Variants": number; 17 | }; 18 | "Environment Created": never; 19 | "Feature Flag Created": { 20 | "Feature Flag Type": FeatureFlagType; 21 | }; 22 | "Devtools Opened": never; 23 | "Devtools Interaction": { 24 | type: "Flag Updated" | "Variant Selected"; 25 | }; 26 | "API Project Data Retrieved": { 27 | projectId: string; 28 | }; 29 | "Dashboard Help Clicked": never; 30 | "Dashboard Code Clicked": never; 31 | }; 32 | 33 | export type ServerEvents = { 34 | flag_removal_pr_created: { 35 | files_changed: number; 36 | }; 37 | }; 38 | -------------------------------------------------------------------------------- /apps/web/src/components/RadioGroup.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | RadioGroup, 3 | RadioGroupItem as RadixRadioGroupItem, 4 | } from "components/ui/radio-group"; 5 | 6 | export type RadioGroupItem = { 7 | label: string; 8 | value: string; 9 | }; 10 | 11 | type ArrayLike = Array | ReadonlyArray; 12 | 13 | type Props = { 14 | items: ArrayLike; 15 | value: RadioGroupItem["value"]; 16 | onChange: (value: RadioGroupItem["value"]) => void; 17 | isLoading?: boolean; 18 | label?: string; 19 | }; 20 | 21 | export function RadioGroupComponent({ items, onChange, value }: Props) { 22 | return ( 23 | 28 | {items.map((item) => ( 29 |
30 | 31 |
{item.label}
32 |
33 | ))} 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /apps/web/src/instrumentation.ts: -------------------------------------------------------------------------------- 1 | export async function register() { 2 | if (process.env.NEXT_RUNTIME === "nodejs") { 3 | console.log("Registering workers..."); 4 | 5 | const workers = await Promise.all([ 6 | import("server/queue/AfterDataRequest"), 7 | import("server/queue/event"), 8 | ]); 9 | 10 | if (process.env.NEXT_RUNTIME === "nodejs") { 11 | await import("../sentry.server.config"); 12 | } 13 | 14 | // biome-ignore lint/suspicious/noExplicitAny: typings are off 15 | if (process.env.NEXT_RUNTIME === ("edge" as any)) { 16 | await import("../sentry.edge.config"); 17 | } 18 | 19 | const gracefulShutdown = async (signal: string) => { 20 | console.log(`Received ${signal}, closing server...`); 21 | await Promise.all(workers.map((w) => w.default.close())); 22 | // Other asynchronous closings 23 | process.exit(0); 24 | }; 25 | 26 | process.on("SIGINT", () => gracefulShutdown("SIGINT")); 27 | 28 | process.on("SIGTERM", () => gracefulShutdown("SIGTERM")); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.3" 2 | services: 3 | db: 4 | image: mariadb 5 | restart: always 6 | environment: 7 | MYSQL_DATABASE: "db" 8 | # So you don't have to use root, but you can if you like 9 | MYSQL_USER: "user" 10 | # You can use whatever password you like 11 | MYSQL_PASSWORD: "password" 12 | # Password for root access 13 | MYSQL_ROOT_PASSWORD: "password" 14 | ports: 15 | # : < MySQL Port running inside container> 16 | - "3306:3306" 17 | expose: 18 | # Opens port 3306 on the container 19 | - "3306" 20 | # Where our data will be persisted 21 | volumes: 22 | - my-db:/var/lib/mysql 23 | redis: 24 | image: redis 25 | restart: always 26 | ports: 27 | - "6379:6379" 28 | expose: 29 | - "6379" 30 | volumes: 31 | - my-redis:/data 32 | mailhog: 33 | image: mailhog/mailhog 34 | ports: 35 | - "1025:1025" 36 | - "8025:8025" 37 | # Names our volume 38 | volumes: 39 | my-db: 40 | my-redis: 41 | -------------------------------------------------------------------------------- /apps/web/src/api/helpers.ts: -------------------------------------------------------------------------------- 1 | import { env } from "env/server.mjs"; 2 | import type { MiddlewareHandler } from "hono"; 3 | import { getCookie } from "hono/cookie"; 4 | import type { Session } from "next-auth"; 5 | import { decode } from "next-auth/jwt"; 6 | 7 | export const authMiddleware: MiddlewareHandler<{ 8 | Variables: { 9 | user: NonNullable; 10 | }; 11 | }> = async (c, next) => { 12 | const tokenCookie = getCookie( 13 | c, 14 | env.NODE_ENV === "production" 15 | ? "__Secure-next-auth.session-token" 16 | : "next-auth.session-token" 17 | ); 18 | if (!tokenCookie) { 19 | return c.json({ error: "Unauthorized" }, { status: 401 }); 20 | } 21 | if (!env.NEXTAUTH_SECRET) { 22 | throw new Error("NEXTAUTH_SECRET is not set"); 23 | } 24 | const session = await decode({ 25 | secret: env.NEXTAUTH_SECRET, 26 | token: tokenCookie, 27 | }); 28 | if (!session || !session.user) { 29 | return c.json({ error: "Unauthorized" }, { status: 401 }); 30 | } 31 | c.set("user", session.user); 32 | return next(); 33 | }; 34 | -------------------------------------------------------------------------------- /apps/web/abby.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "@tryabby/core"; 2 | import * as validation from "@tryabby/core/validation"; 3 | 4 | export default defineConfig( 5 | { 6 | // biome-ignore lint/style/noNonNullAssertion:> 7 | projectId: process.env.NEXT_PUBLIC_ABBY_PROJECT_ID!, 8 | currentEnvironment: process.env.VERCEL_ENV ?? process.env.NODE_ENV, 9 | apiUrl: process.env.NEXT_PUBLIC_ABBY_API_URL, 10 | __experimentalCdnUrl: process.env.NEXT_PUBLIC_ABBY_CDN_URL, 11 | debug: process.env.NEXT_PUBLIC_ABBY_DEBUG === "true", 12 | }, 13 | { 14 | environments: ["development", "production"], 15 | tests: { 16 | SignupButton: { 17 | variants: ["A", "B"], 18 | }, 19 | TipsAndTricks: { 20 | variants: ["Blog"], 21 | }, 22 | }, 23 | flags: ["AdvancedTestStats", "showFooter", "test"], 24 | remoteConfig: { 25 | abc: "JSON", 26 | }, 27 | cookies: { disableByDefault: true, expiresInDays: 30 }, 28 | user: { 29 | id: validation.string(), 30 | email: validation.string(), 31 | }, 32 | } 33 | ); 34 | -------------------------------------------------------------------------------- /apps/web/src/components/FlagIcon.tsx: -------------------------------------------------------------------------------- 1 | import type { FeatureFlagType } from "@prisma/client"; 2 | import { Baseline, CurlyBraces, Hash, ToggleLeft } from "lucide-react"; 3 | import { match } from "ts-pattern"; 4 | import { Tooltip, TooltipContent, TooltipTrigger } from "./Tooltip"; 5 | 6 | type Props = { 7 | type: FeatureFlagType; 8 | className?: string; 9 | }; 10 | 11 | export function FlagIcon({ type, ...iconProps }: Props) { 12 | return ( 13 | <> 14 | 15 | 16 | 17 | This Flag has the type {type} 18 | 19 | 20 | 21 | {match(type) 22 | .with("BOOLEAN", () => ) 23 | .with("NUMBER", () => ) 24 | .with("STRING", () => ) 25 | .with("JSON", () => ) 26 | .exhaustive()} 27 | 28 | 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /apps/docs/public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /apps/web/src/env/client.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { clientEnv, clientSchema } from "./schema.mjs"; 3 | 4 | const _clientEnv = clientSchema.safeParse(clientEnv); 5 | 6 | export const formatErrors = ( 7 | /** @type {import('zod').ZodFormattedError,string>} */ 8 | errors 9 | ) => 10 | Object.entries(errors) 11 | .map(([name, value]) => { 12 | if (value && "_errors" in value) 13 | return `${name}: ${value._errors.join(", ")}\n`; 14 | }) 15 | .filter(Boolean); 16 | 17 | if (!_clientEnv.success) { 18 | console.error( 19 | "❌ Invalid environment variables:\n", 20 | ...formatErrors(_clientEnv.error.format()) 21 | ); 22 | throw new Error("Invalid environment variables"); 23 | } 24 | 25 | for (const key of Object.keys(_clientEnv.data)) { 26 | if (!key.startsWith("NEXT_PUBLIC_")) { 27 | console.warn( 28 | `❌ Invalid public environment variable name: ${key}. It must begin with 'NEXT_PUBLIC_'` 29 | ); 30 | 31 | throw new Error("Invalid public environment variable name"); 32 | } 33 | } 34 | 35 | export const env = _clientEnv.data; 36 | -------------------------------------------------------------------------------- /packages/tsconfig/angular-library.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Angular Library", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "lib": ["ES2022", "dom"], 7 | "module": "ES2022", 8 | "target": "ES2022", 9 | "strictPropertyInitialization": false, 10 | "experimentalDecorators": true, 11 | "baseUrl": "./", 12 | "outDir": "./dist/out-tsc", 13 | "forceConsistentCasingInFileNames": true, 14 | "strict": true, 15 | "noImplicitOverride": true, 16 | "noPropertyAccessFromIndexSignature": true, 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": true, 19 | "sourceMap": true, 20 | "declaration": false, 21 | "declarationMap": false, 22 | "downlevelIteration": true, 23 | "moduleResolution": "node", 24 | "importHelpers": true, 25 | "useDefineForClassFields": false, 26 | "allowSyntheticDefaultImports": true, 27 | "esModuleInterop": true, 28 | "skipLibCheck": true 29 | }, 30 | "types": ["node"], 31 | "scripts": { 32 | "postinstall": "ngcc" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /apps/web/src/api/index.ts: -------------------------------------------------------------------------------- 1 | import { makeConfigRoute } from "api/routes/v1_config"; 2 | import { makeProjectDataRoute } from "api/routes/v1_project_data"; 3 | import { Hono } from "hono"; 4 | import { cors } from "hono/cors"; 5 | import { makeHealthRoute } from "./routes/health"; 6 | import { makeIntegrationsRoute } from "./routes/integrations"; 7 | import { makeLegacyProjectDataRoute } from "./routes/legacy_project_data"; 8 | import { makeEventRoute } from "./routes/v1_event"; 9 | import { makeV2ProjectDataRoute } from "./routes/v2_project_data"; 10 | 11 | export const app = new Hono() 12 | .basePath("/api") 13 | // base middleware 14 | 15 | .use("*", cors({ origin: "*", maxAge: 86400 })) 16 | .route("/health", makeHealthRoute()) 17 | // legacy routes 18 | .route("/data", makeEventRoute()) 19 | .route("/dashboard", makeLegacyProjectDataRoute()) 20 | // v1 routes 21 | .route("/v1/config", makeConfigRoute()) 22 | .route("/v1/data", makeProjectDataRoute()) 23 | .route("/v1/track", makeEventRoute()) 24 | // v2 routes 25 | .route("/v2/data", makeV2ProjectDataRoute()) 26 | .route("/integrations", makeIntegrationsRoute()); 27 | -------------------------------------------------------------------------------- /apps/web/src/components/Progress.tsx: -------------------------------------------------------------------------------- 1 | import { twMerge } from "tailwind-merge"; 2 | 3 | type Props = { 4 | currentValue: number; 5 | maxValue: number; 6 | }; 7 | 8 | export function Progress({ currentValue, maxValue }: Props) { 9 | const currentValuePercentage = Math.min((currentValue / maxValue) * 100, 100); 10 | return ( 11 | // biome-ignore lint/a11y/useFocusableInteractive: 12 |
19 |
23 | 50 ? "text-secondary" : "text-primary" 27 | )} 28 | > 29 | {currentValuePercentage.toFixed(2)}% 30 | 31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /apps/web/src/pages/projects/[projectId]/remote-config.tsx: -------------------------------------------------------------------------------- 1 | import { DashboardHeader } from "components/DashboardHeader"; 2 | import { Layout } from "components/Layout"; 3 | import { FullPageLoadingSpinner } from "components/LoadingSpinner"; 4 | import { useProjectId } from "lib/hooks/useProjectId"; 5 | import type { NextPageWithLayout } from "pages/_app"; 6 | import { trpc } from "utils/trpc"; 7 | 8 | import { FeatureFlagPageContent } from "components/FlagPage"; 9 | 10 | const RemoteConfigPage: NextPageWithLayout = () => { 11 | const projectId = useProjectId(); 12 | 13 | const { data, isLoading, isError } = trpc.flags.getFlags.useQuery( 14 | { 15 | projectId, 16 | types: ["JSON", "STRING", "NUMBER"], 17 | }, 18 | { 19 | enabled: !!projectId, 20 | } 21 | ); 22 | 23 | if (isLoading || isError) return ; 24 | 25 | return ; 26 | }; 27 | 28 | RemoteConfigPage.getLayout = (page) => ( 29 | 30 | 31 | {page} 32 | 33 | ); 34 | 35 | export default RemoteConfigPage; 36 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250131071445_add_flag_rulesets/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `UserSegment` ( 3 | `id` VARCHAR(191) NOT NULL, 4 | `projectId` VARCHAR(191) NOT NULL, 5 | `name` VARCHAR(191) NOT NULL, 6 | `schema` JSON NOT NULL, 7 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 8 | `updatedAt` DATETIME(3) NOT NULL, 9 | 10 | INDEX `UserSegment_projectId_idx`(`projectId`), 11 | UNIQUE INDEX `UserSegment_projectId_name_key`(`projectId`, `name`), 12 | PRIMARY KEY (`id`) 13 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 14 | 15 | -- CreateTable 16 | CREATE TABLE `FlagRuleSet` ( 17 | `id` VARCHAR(191) NOT NULL, 18 | `flagValueId` VARCHAR(191) NOT NULL, 19 | `name` VARCHAR(191) NOT NULL, 20 | `rules` JSON NOT NULL, 21 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 22 | `updatedAt` DATETIME(3) NOT NULL, 23 | 24 | INDEX `FlagRuleSet_flagValueId_idx`(`flagValueId`), 25 | UNIQUE INDEX `FlagRuleSet_flagValueId_name_key`(`flagValueId`, `name`), 26 | PRIMARY KEY (`id`) 27 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 28 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20230104071050_add_feature_flags/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `FeatureFlag` ( 3 | `id` VARCHAR(191) NOT NULL, 4 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 5 | `updatedAt` DATETIME(3) NOT NULL, 6 | `name` VARCHAR(191) NOT NULL, 7 | `enabled` BOOLEAN NOT NULL DEFAULT false, 8 | `projectId` VARCHAR(191) NOT NULL, 9 | `environmentId` VARCHAR(191) NOT NULL, 10 | 11 | INDEX `FeatureFlag_projectId_idx`(`projectId`), 12 | INDEX `FeatureFlag_environmentId_idx`(`environmentId`), 13 | PRIMARY KEY (`id`) 14 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 15 | 16 | -- CreateTable 17 | CREATE TABLE `Environment` ( 18 | `id` VARCHAR(191) NOT NULL, 19 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 20 | `updatedAt` DATETIME(3) NOT NULL, 21 | `name` VARCHAR(191) NOT NULL, 22 | `projectId` VARCHAR(191) NOT NULL, 23 | 24 | INDEX `Environment_projectId_idx`(`projectId`), 25 | UNIQUE INDEX `Environment_projectId_name_key`(`projectId`, `name`), 26 | PRIMARY KEY (`id`) 27 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 28 | -------------------------------------------------------------------------------- /apps/web/src/server/trpc/router/_app.ts: -------------------------------------------------------------------------------- 1 | import { router } from "../trpc"; 2 | import { apiKeyRouter } from "./apikey"; 3 | import { authRouter } from "./auth"; 4 | import { couponRouter } from "./coupons"; 5 | import { environmentRouter } from "./environments"; 6 | import { eventRouter } from "./events"; 7 | import { exampleRouter } from "./example"; 8 | import { flagRouter } from "./flags"; 9 | import { inviteRouter } from "./invite"; 10 | import { miscRouter } from "./misc"; 11 | import { projectRouter } from "./project"; 12 | import { projectUserRouter } from "./project-user"; 13 | import { testRouter } from "./tests"; 14 | import { userRouter } from "./user"; 15 | 16 | export const appRouter = router({ 17 | example: exampleRouter, 18 | auth: authRouter, 19 | user: userRouter, 20 | project: projectRouter, 21 | projectUser: projectUserRouter, 22 | invite: inviteRouter, 23 | events: eventRouter, 24 | tests: testRouter, 25 | flags: flagRouter, 26 | environments: environmentRouter, 27 | coupons: couponRouter, 28 | misc: miscRouter, 29 | apikey: apiKeyRouter, 30 | }); 31 | 32 | // export type definition of API 33 | export type AppRouter = typeof appRouter; 34 | -------------------------------------------------------------------------------- /apps/web/src/utils/updateSession.ts: -------------------------------------------------------------------------------- 1 | import { env } from "node:process"; 2 | import { encode, getToken } from "next-auth/jwt"; 3 | import type { Context } from "server/trpc/context"; 4 | 5 | export const updateProjectsOnSession = async ( 6 | ctx: Context, 7 | projectId: string 8 | ) => { 9 | if (!env.NEXTAUTH_SECRET) return; 10 | // manually add the projectId to the token 11 | const jwt = await getToken({ req: ctx.req, secret: env.NEXTAUTH_SECRET }); 12 | if (!jwt) return; 13 | jwt.user.projectIds.push(projectId); 14 | const encodedJwt = await encode({ 15 | token: jwt, 16 | secret: env.NEXTAUTH_SECRET, 17 | }); 18 | 19 | if (env.NODE_ENV === "production") { 20 | ctx.res.setHeader("Set-Cookie", [ 21 | `__Secure-next-auth.session-token=${encodedJwt}; path=/; expires=${new Date( 22 | Date.now() + 30 * 24 * 60 * 60 * 1000 23 | ).toUTCString()}; httponly; secure; samesite=none`, 24 | ]); 25 | } else { 26 | ctx.res.setHeader("Set-Cookie", [ 27 | `next-auth.session-token=${encodedJwt}; path=/; expires=${new Date( 28 | Date.now() + 30 * 24 * 60 * 60 * 1000 29 | ).toUTCString()}; httponly`, 30 | ]); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/environment-badge.tsx: -------------------------------------------------------------------------------- 1 | import { getEnvironmentStyle } from "lib/environment-styles"; 2 | import { cn } from "lib/utils"; 3 | 4 | interface EnvironmentBadgeProps { 5 | name: string; 6 | className?: string; 7 | size?: "sm" | "default" | "lg"; 8 | } 9 | 10 | export function EnvironmentBadge({ 11 | name, 12 | className, 13 | size = "default", 14 | }: EnvironmentBadgeProps) { 15 | const style = getEnvironmentStyle(name); 16 | 17 | return ( 18 |
29 |
38 | {name} 39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /packages/core/src/defineConfig.ts: -------------------------------------------------------------------------------- 1 | import type { ABConfig, AbbyConfig, RemoteConfigValueString } from "."; 2 | import type { ValidatorType } from "./validation"; 3 | 4 | export const DYNAMIC_ABBY_CONFIG_KEYS = [ 5 | "projectId", 6 | "currentEnvironment", 7 | "debug", 8 | "apiUrl", 9 | "__experimentalCdnUrl", 10 | ] as const satisfies readonly (keyof AbbyConfig)[]; 11 | 12 | export type DynamicConfigKeys = (typeof DYNAMIC_ABBY_CONFIG_KEYS)[number]; 13 | 14 | export function defineConfig< 15 | const FlagName extends string, 16 | const Tests extends Record, 17 | const RemoteConfig extends Record, 18 | const RemoteConfigName extends Extract, 19 | const User extends Record = Record< 20 | string, 21 | ValidatorType 22 | >, 23 | >( 24 | dynamicConfig: Pick< 25 | AbbyConfig, 26 | DynamicConfigKeys 27 | >, 28 | config: Omit< 29 | AbbyConfig, 30 | DynamicConfigKeys 31 | > 32 | ) { 33 | return { ...dynamicConfig, ...config }; 34 | } 35 | -------------------------------------------------------------------------------- /packages/node/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @tryabby/node 2 | 3 | ## 7.0.1 4 | 5 | ### Patch Changes 6 | 7 | - fix build 8 | - Updated dependencies 9 | - @tryabby/core@7.0.1 10 | 11 | ## 7.0.0 12 | 13 | ### Major Changes 14 | 15 | - add rules and user segments 16 | 17 | ### Patch Changes 18 | 19 | - Updated dependencies 20 | - @tryabby/core@7.0.0 21 | 22 | ## 6.0.0 23 | 24 | ### Major Changes 25 | 26 | - use typescript 5 27 | 28 | ### Patch Changes 29 | 30 | - Updated dependencies 31 | - @tryabby/core@6.0.0 32 | 33 | ## 5.1.8 34 | 35 | ### Patch Changes 36 | 37 | - Updated dependencies 38 | - @tryabby/core@5.4.0 39 | 40 | ## 5.1.7 41 | 42 | ### Patch Changes 43 | 44 | - Updated dependencies [d05cb9a] 45 | - @tryabby/core@5.3.1 46 | 47 | ## 5.1.6 48 | 49 | ### Patch Changes 50 | 51 | - Updated dependencies [31d14b7] 52 | - @tryabby/core@5.3.0 53 | 54 | ## 5.1.5 55 | 56 | ### Patch Changes 57 | 58 | - Updated dependencies 59 | - @tryabby/core@5.2.0 60 | 61 | ## 5.1.4 62 | 63 | ### Patch Changes 64 | 65 | - Updated dependencies 66 | - @tryabby/core@5.1.4 67 | 68 | ## 5.1.3 69 | 70 | ### Patch Changes 71 | 72 | - add memory storage for tests 73 | - Updated dependencies 74 | - @tryabby/core@5.1.3 75 | -------------------------------------------------------------------------------- /apps/web/emails/index.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@react-email/render"; 2 | import { env } from "env/server.mjs"; 3 | import { createTransport } from "nodemailer"; 4 | import ContactFormularEmail, { 5 | type Props as ContactMailProps, 6 | } from "./ContactFormularEmail"; 7 | import InviteEmail, { type Props as InviteEmailProps } from "./invite"; 8 | 9 | const transporter = createTransport({ 10 | pool: true, 11 | url: env.EMAIL_SERVER, 12 | from: `Abby <${env.ABBY_FROM_EMAIL}>`, 13 | }); 14 | 15 | export function sendInviteEmail(props: InviteEmailProps) { 16 | const email = render(); 17 | 18 | return transporter.sendMail({ 19 | to: props.invitee.email, 20 | from: `Abby <${env.ABBY_FROM_EMAIL}>`, 21 | subject: `Join ${props.inviter.name} on Abby`, 22 | html: email, 23 | }); 24 | } 25 | 26 | export function sendContactFormularEmail(props: ContactMailProps) { 27 | const email = render(); 28 | const abbyContactAdress = "tim@tryabby.com"; 29 | return transporter.sendMail({ 30 | to: abbyContactAdress, 31 | from: `Abby <${env.ABBY_FROM_EMAIL}>`, 32 | subject: `New Message from ${props.name} ${props.surname}`, 33 | html: email, 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "abby", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "turbo run build", 7 | "build:prod": "turbo run build:prod", 8 | "build:packages": "turbo run build --filter=./packages/*", 9 | "test:packages": "turbo run test --filter=./packages/*", 10 | "dev": "turbo run dev --filter=./apps/web", 11 | "dev:packages": "turbo run dev --filter=./packages/*", 12 | "dev:docs": "turbo run dev --filter=./apps/docs", 13 | "dev:angular": "turbo run dev --filter=./apps/angular-example", 14 | "dev:all": "turbo run dev --filter=!./apps/docs", 15 | "lint": "biome lint --fix", 16 | "format": "biome format --write", 17 | "db:start": "docker-compose up -d", 18 | "db:migrate": "turbo run db:migrate --filter=./apps/web", 19 | "db:generate": "turbo run db:generate --filter=./apps/web", 20 | "db:stop": "docker-compose down", 21 | "ci:publish": "pnpm publish -r --access public" 22 | }, 23 | "devDependencies": { 24 | "@biomejs/biome": "1.9.4", 25 | "@changesets/cli": "^2.26.0", 26 | "prettier": "latest", 27 | "turbo": "^2.0.4" 28 | }, 29 | "engines": { 30 | "node": ">=22.8.0" 31 | }, 32 | "packageManager": "pnpm@9.9.0" 33 | } 34 | -------------------------------------------------------------------------------- /apps/web/src/components/LoadingSpinner.tsx: -------------------------------------------------------------------------------- 1 | import { twMerge } from "tailwind-merge"; 2 | 3 | export function LoadingSpinner({ 4 | height, 5 | width, 6 | className, 7 | }: { 8 | height?: string; 9 | width?: string; 10 | className?: string; 11 | }) { 12 | return ( 13 | 23 | Loading Spinner 24 | 32 | 37 | 38 | ); 39 | } 40 | 41 | export const FullPageLoadingSpinner = () => ( 42 |
43 | 44 |
45 | ); 46 | -------------------------------------------------------------------------------- /apps/web/src/lib/environment-styles.ts: -------------------------------------------------------------------------------- 1 | export const ENVIRONMENT_COLORS = { 2 | development: { 3 | bg: "bg-emerald-500/10", 4 | text: "text-emerald-600", 5 | border: "border-emerald-500/20", 6 | icon: "bg-emerald-500", 7 | }, 8 | staging: { 9 | bg: "bg-amber-500/10", 10 | text: "text-amber-600", 11 | border: "border-amber-500/20", 12 | icon: "bg-amber-500", 13 | }, 14 | production: { 15 | bg: "bg-blue-500/10", 16 | text: "text-blue-600", 17 | border: "border-blue-500/20", 18 | icon: "bg-blue-500", 19 | }, 20 | default: { 21 | bg: "bg-teal-500/10", 22 | text: "text-gray-300", 23 | border: "border-gray-500/20", 24 | icon: "bg-gray-500", 25 | }, 26 | }; 27 | 28 | export const getEnvironmentStyle = (environmentName: string) => { 29 | const name = environmentName.toLowerCase(); 30 | if (name.includes("dev") || name.includes("development")) { 31 | return ENVIRONMENT_COLORS.development; 32 | } 33 | if (name.includes("staging") || name.includes("test")) { 34 | return ENVIRONMENT_COLORS.staging; 35 | } 36 | if (name.includes("prod") || name.includes("production")) { 37 | return ENVIRONMENT_COLORS.production; 38 | } 39 | return ENVIRONMENT_COLORS.default; 40 | }; 41 | -------------------------------------------------------------------------------- /apps/web/src/server/trpc/router/apikey.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { generateRandomString, hashString } from "utils/apiKey"; 3 | import { z } from "zod"; 4 | import { protectedProcedure, router } from "../trpc"; 5 | 6 | export const apiKeyRouter = router({ 7 | createApiKey: protectedProcedure 8 | .input( 9 | z.object({ 10 | name: z.string(), 11 | }) 12 | ) 13 | .mutation(async ({ ctx, input }) => { 14 | const apiKey = generateRandomString(); 15 | const hashedApiKey = hashString(apiKey); 16 | 17 | await ctx.prisma.apiKey.create({ 18 | data: { 19 | hashedKey: hashedApiKey, 20 | name: input.name, 21 | validUntil: dayjs().add(1, "year").toDate(), 22 | userId: ctx.session.user.id, 23 | }, 24 | }); 25 | 26 | return apiKey; 27 | }), 28 | revokeApiKey: protectedProcedure 29 | .input( 30 | z.object({ 31 | id: z.string(), 32 | }) 33 | ) 34 | .mutation(async ({ ctx, input }) => { 35 | await ctx.prisma.apiKey.update({ 36 | where: { 37 | id: input.id, 38 | }, 39 | data: { 40 | revokedAt: new Date(), 41 | }, 42 | }); 43 | }), 44 | }); 45 | -------------------------------------------------------------------------------- /packages/angular/angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "angular": { 7 | "projectType": "library", 8 | "root": ".", 9 | "sourceRoot": "./src", 10 | "prefix": "lib", 11 | "architect": { 12 | "build": { 13 | "builder": "@angular-devkit/build-angular:ng-packagr", 14 | "options": { 15 | "project": "./ng-package.json" 16 | }, 17 | "configurations": { 18 | "production": { 19 | "tsConfig": "./tsconfig.lib.prod.json" 20 | }, 21 | "development": { 22 | "tsConfig": "./tsconfig.lib.json" 23 | } 24 | }, 25 | "defaultConfiguration": "production" 26 | }, 27 | "test": { 28 | "builder": "@angular-devkit/build-angular:karma", 29 | "options": { 30 | "tsConfig": "./tsconfig.spec.json", 31 | "polyfills": ["zone.js", "zone.js/testing"], 32 | "karmaConfig": "karma.conf.js" 33 | } 34 | } 35 | } 36 | } 37 | }, 38 | "cli": { 39 | "analytics": false 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /apps/web/src/server/services/InviteService.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from "server/db/client"; 2 | 3 | export abstract class InviteService { 4 | static async acceptInvite(inviteId: string, userId: string) { 5 | const invite = await prisma.projectInvite.findUnique({ 6 | where: { 7 | id: inviteId, 8 | }, 9 | }); 10 | 11 | if (!invite) { 12 | throw new Error("Invite not found"); 13 | } 14 | 15 | const user = await prisma.user.findUnique({ 16 | where: { 17 | id: userId, 18 | }, 19 | }); 20 | 21 | if (!user) { 22 | throw new Error("User not found"); 23 | } 24 | 25 | if (invite.email !== user.email) { 26 | throw new Error("User not invited"); 27 | } 28 | 29 | await prisma.projectUser.create({ 30 | data: { 31 | projectId: invite.projectId, 32 | userId: userId, 33 | }, 34 | }); 35 | 36 | await prisma.projectInvite.delete({ 37 | where: { 38 | id: inviteId, 39 | }, 40 | }); 41 | } 42 | 43 | static async getEventsByProjectId(projectId: string) { 44 | return prisma.event.findMany({ 45 | where: { 46 | test: { 47 | projectId, 48 | }, 49 | }, 50 | }); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /packages/react/tests/helpers.test.ts: -------------------------------------------------------------------------------- 1 | import { getRandomDecimal, getWeightedRandomVariant } from "../src/helpers"; 2 | 3 | describe("getWeightedRandomVariant", () => { 4 | it("should only return valid values", () => { 5 | const variants = ["A", "B", "C"] as const; 6 | 7 | const variant = getWeightedRandomVariant(variants); 8 | 9 | expect(variant).toBeDefined(); 10 | }); 11 | 12 | it("should work with weights", () => { 13 | const variants = ["A", "B", "C"] as const; 14 | 15 | for (let i = 0; i < 100; i++) { 16 | const variant = getWeightedRandomVariant(variants, [0, 0, 1]); 17 | 18 | expect(variant).toBe("C"); 19 | } 20 | 21 | for (let i = 0; i < 100; i++) { 22 | const variant = getWeightedRandomVariant(variants, [0.5, 0.5, 0]); 23 | 24 | expect(["A", "B"].includes(variant)).toBeTruthy(); 25 | expect(variant).not.toBe("C"); 26 | } 27 | }); 28 | }); 29 | 30 | describe("getRandomDecimal", () => { 31 | it("should return a number between 0 and 1", () => { 32 | for (let i = 0; i < 100; i++) { 33 | const decimal = getRandomDecimal(); 34 | expect(decimal).toBeGreaterThanOrEqual(0); 35 | expect(decimal).toBeLessThanOrEqual(1); 36 | } 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /packages/devtools/src/lib/storage.ts: -------------------------------------------------------------------------------- 1 | const DEVTOOLS_KEY = "abbyy-devtools"; 2 | 3 | export function getShowDevtools() { 4 | const storedValue = localStorage.getItem(DEVTOOLS_KEY); 5 | return { 6 | hasStoredValue: !!storedValue, 7 | showDevtools: localStorage.getItem(DEVTOOLS_KEY) === "true", 8 | }; 9 | } 10 | 11 | export function setShowDevtools(value: boolean) { 12 | localStorage.setItem(DEVTOOLS_KEY, JSON.stringify(value)); 13 | } 14 | 15 | type Sections = { 16 | flags: boolean; 17 | remoteConfig: boolean; 18 | tests: boolean; 19 | }; 20 | 21 | export function getSections(): Sections { 22 | try { 23 | const storedValue = localStorage.getItem("sections"); 24 | return storedValue 25 | ? JSON.parse(storedValue) 26 | : ({ flags: true, remoteConfig: true, tests: true } satisfies Sections); 27 | } catch (error) { 28 | console.error("Error retrieving sections from localStorage:", error); 29 | return { flags: true, remoteConfig: true, tests: true }; 30 | } 31 | } 32 | 33 | export function setSections(sections: Sections) { 34 | try { 35 | localStorage.setItem("sections", JSON.stringify(sections)); 36 | } catch (error) { 37 | console.error("Error saving sections to localStorage:", error); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/angular/src/lib/devtools.component.ts: -------------------------------------------------------------------------------- 1 | import { Input, type OnInit, ViewChild } from "@angular/core"; 2 | import { Component, type ElementRef } from "@angular/core"; 3 | import type { AbbyDevtoolProps } from "@tryabby/devtools"; 4 | import abbyDevTool from "@tryabby/devtools"; 5 | // biome-ignore lint/style/useImportType: angular 6 | import { AbbyService } from "./abby.service"; 7 | 8 | @Component({ 9 | selector: "abby-devtools", 10 | template: "", 11 | }) 12 | export class DevtoolsComponent implements OnInit { 13 | @Input() props: Omit; 14 | @ViewChild("devtoolsContainer", { static: true }) 15 | devtoolsContainerRef!: ElementRef; 16 | 17 | constructor(private readonly abby: AbbyService) {} 18 | 19 | ngOnInit(): void { 20 | if ( 21 | // biome-ignore lint/complexity/useLiteralKeys: angular 22 | this.props?.["dangerouslyForceShow"] || 23 | // biome-ignore lint/complexity/useLiteralKeys: angular 24 | process.env["NODE_ENV"] === "development" 25 | ) { 26 | const abbyInstance = this.abby.getAbbyInstance(); 27 | 28 | abbyDevTool.create({ 29 | ...this.props, 30 | abby: abbyInstance, 31 | }); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /docs/integrations.md: -------------------------------------------------------------------------------- 1 | # Abby Integration Checklist 2 | 3 | ## Philosophy 4 | 5 | The number 1 priority for every integration is **Developer Experience (DX)** 6 | This means the following: 7 | 8 | - Everything is typesafe (if the language / framework allows this) 9 | - There should be as few boilerplate as possible 10 | - The code should be documented and verbose 11 | - It should be so simple to use that you never need to open the docs after setting Abby up once 12 | 13 | ## Features 14 | 15 | ### Isomorphic (Client & Server) 16 | 17 | - Retrieve the Value of a Feature Flag (typesafety: parameter should be typed and only the possible names from the config) 18 | - Retrieve the current variant of the User for a given A/B Test (typesafety: parameter should be typed and only the possible names from the config, return value should also be only the potential values) 19 | 20 | ### Client 21 | 22 | - By default Feature Flags & A/B Tests should be rendered on the client 23 | - If possible there should be a central way of fetching the values from the Abby API 24 | - If there is a central way (e.g. React Context) data should be consumed from there 25 | - If not data needs to be fetched when consuming 26 | - If possible (Next.js, Sveltekit) Feature Flags should be rendered on the Server (SEO) 27 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Package Tests 2 | 3 | on: 4 | push: 5 | paths: 6 | - "packages/**" 7 | pull_request: 8 | paths: 9 | - "packages/**" 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Use Node.js 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: "22.8.0" 21 | 22 | - uses: pnpm/action-setup@v2 23 | name: Install pnpm 24 | id: pnpm-install 25 | with: 26 | version: 9.9.0 27 | run_install: false 28 | 29 | - name: Get pnpm store directory 30 | id: pnpm-cache 31 | shell: bash 32 | run: | 33 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 34 | 35 | - uses: actions/cache@v3 36 | name: Setup pnpm cache 37 | with: 38 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 39 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 40 | restore-keys: | 41 | ${{ runner.os }}-pnpm-store- 42 | 43 | - name: Install dependencies 44 | run: pnpm install 45 | - run: pnpm i 46 | - run: pnpm build:packages 47 | - run: pnpm test:packages 48 | -------------------------------------------------------------------------------- /packages/remix/tests/ssr.test.tsx: -------------------------------------------------------------------------------- 1 | import { json } from "@remix-run/node"; 2 | import { createRemixStub } from "@remix-run/testing"; 3 | import { render, screen, waitFor } from "@testing-library/react"; 4 | import { createAbby } from "../src"; 5 | 6 | test("renders loader data", async () => { 7 | const { AbbyProvider, useFeatureFlag, getAbbyData } = createAbby({ 8 | currentEnvironment: "", 9 | environments: [], 10 | projectId: "123", 11 | flags: ["flag1", "flag2"], 12 | }); 13 | 14 | function MyComponent() { 15 | const flag1 = useFeatureFlag("flag1"); 16 | const flag2 = useFeatureFlag("flag2"); 17 | return ( 18 |
19 |

Flag 1 is {flag1 ? "on" : "off"}

20 |

Flag 2 is {flag2 ? "on" : "off"}

21 |
22 | ); 23 | } 24 | 25 | const RemixStub = createRemixStub([ 26 | { 27 | path: "/", 28 | Component: () => ( 29 | 30 | 31 | 32 | ), 33 | async loader(ctx) { 34 | return json({ ...(await getAbbyData(ctx)) }); 35 | }, 36 | }, 37 | ]); 38 | 39 | render(); 40 | 41 | await waitFor(() => screen.findByText("Flag 1 is on")); 42 | await waitFor(() => screen.findByText("Flag 2 is off")); 43 | }); 44 | -------------------------------------------------------------------------------- /packages/cli/tests/mocks/handlers.ts: -------------------------------------------------------------------------------- 1 | import { ABBY_BASE_URL } from "@tryabby/core"; 2 | import { rest } from "msw"; 3 | 4 | export const handlers = [ 5 | rest.get(`${ABBY_BASE_URL}/api/v1/config/:projectId`, (req, res, ctx) => { 6 | const apiKey = req.url.searchParams.get("apiKey"); 7 | 8 | if (apiKey === "test") { 9 | return res( 10 | ctx.json({ 11 | projectId: "test", 12 | tests: { 13 | test1: { 14 | variants: ["A", "B", "C", "D"], 15 | }, 16 | test2: { 17 | variants: ["A", "B"], 18 | }, 19 | }, 20 | flags: ["flag1", "flag2"], 21 | }) 22 | ); 23 | } 24 | return res( 25 | ctx.json({ 26 | projectId: "test", 27 | tests: { 28 | test1: { 29 | variants: ["A", "B", "C", "D"], 30 | }, 31 | test2: { 32 | variants: ["A", "B"], 33 | }, 34 | test3: { 35 | variants: ["A", "B", "C", "D"], 36 | }, 37 | }, 38 | flags: ["flag1", "flag2", "flag3"], 39 | }) 40 | ); 41 | }), 42 | rest.put(`${ABBY_BASE_URL}api/v1/config/:projectId`, (_req, res, ctx) => { 43 | return res(ctx.json({ message: "Config updated" })); 44 | }), 45 | ]; 46 | -------------------------------------------------------------------------------- /packages/angular/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = (config) => { 5 | config.set({ 6 | basePath: "", 7 | frameworks: ["jasmine", "@angular-devkit/build-angular"], 8 | plugins: [ 9 | require("karma-jasmine"), 10 | require("karma-chrome-launcher"), 11 | require("@angular-devkit/build-angular/plugins/karma"), 12 | require("karma-junit-reporter"), 13 | ], 14 | client: { 15 | jasmine: { 16 | // you can add configuration options for Jasmine here 17 | // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html 18 | // for example, you can disable the random execution with `random: false` 19 | // or set a specific seed with `seed: 4321` 20 | random: false, 21 | }, 22 | clearContext: false, // leave Jasmine Spec Runner output visible in browser 23 | }, 24 | junitReporter: { 25 | useBrowserName: false, 26 | }, 27 | reporters: ["progress", "junit"], 28 | browsers: ["ChromeHeadless"], 29 | restartOnFileChange: true, 30 | autoWatch: false, 31 | singleRun: true, 32 | logLevel: config.LOG_INFO, 33 | port: 9876, 34 | }); 35 | }; 36 | -------------------------------------------------------------------------------- /apps/cdn/src/lib/config.ts: -------------------------------------------------------------------------------- 1 | import { type AbbyDataResponse, HttpService } from "@tryabby/core"; 2 | 3 | import type { Context } from "hono"; 4 | import { endTime, startTime } from "hono/timing"; 5 | import type { ZoneCache } from "./cache"; 6 | 7 | export class ConfigService { 8 | constructor( 9 | private readonly cache: ZoneCache<{ 10 | config: AbbyDataResponse; 11 | }> 12 | ) {} 13 | 14 | async retrieveConfig({ 15 | environment, 16 | projectId, 17 | c, 18 | }: { 19 | projectId: string; 20 | environment: string; 21 | c: Context; 22 | }) { 23 | const cacheKey = [projectId, environment].join(","); 24 | 25 | startTime(c, "cacheRead"); 26 | const [cachedData, reason] = await this.cache.get(c, "config", cacheKey); 27 | 28 | endTime(c, "cacheRead"); 29 | 30 | if (cachedData) { 31 | return [cachedData, true, reason] as const; 32 | } 33 | 34 | startTime(c, "remoteRead"); 35 | 36 | const data = await HttpService.getProjectData({ 37 | projectId, 38 | environment, 39 | }); 40 | 41 | if (!data) { 42 | throw new Error("Failed to fetch data"); 43 | } 44 | 45 | endTime(c, "remoteRead"); 46 | c.executionCtx.waitUntil(this.cache.set(c, "config", cacheKey, data)); 47 | 48 | return [data, false, reason] as const; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | import * as PopoverPrimitive from "@radix-ui/react-popover"; 2 | import * as React from "react"; 3 | 4 | import { cn } from "lib/utils"; 5 | 6 | const Popover = PopoverPrimitive.Root; 7 | 8 | const PopoverTrigger = PopoverPrimitive.Trigger; 9 | 10 | const PopoverContent = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 14 | 15 | 25 | 26 | )); 27 | PopoverContent.displayName = PopoverPrimitive.Content.displayName; 28 | 29 | export { Popover, PopoverTrigger, PopoverContent }; 30 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as SwitchPrimitives from "@radix-ui/react-switch"; 4 | import * as React from "react"; 5 | 6 | import { cn } from "lib/utils"; 7 | 8 | const Switch = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | 25 | 26 | )); 27 | Switch.displayName = SwitchPrimitives.Root.displayName; 28 | 29 | export { Switch }; 30 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"; 4 | import * as React from "react"; 5 | 6 | import { cn } from "lib/utils"; 7 | 8 | const TooltipProvider = TooltipPrimitive.Provider; 9 | 10 | const Tooltip = TooltipPrimitive.Root; 11 | 12 | const TooltipTrigger = TooltipPrimitive.Trigger; 13 | 14 | const TooltipContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, sideOffset = 4, ...props }, ref) => ( 18 | 19 | 28 | 29 | )); 30 | TooltipContent.displayName = TooltipPrimitive.Content.displayName; 31 | 32 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; 33 | -------------------------------------------------------------------------------- /packages/next/tests/cache.test.ts: -------------------------------------------------------------------------------- 1 | import { PromiseCache } from "../src/cache"; 2 | 3 | describe("PromiseCache", () => { 4 | beforeEach(() => { 5 | vi.useFakeTimers(); 6 | }); 7 | afterEach(() => { 8 | vi.restoreAllMocks(); 9 | }); 10 | 11 | it("should cache the result of a function", async () => { 12 | const cache = new PromiseCache(); 13 | const fn = vi.fn(async () => 1); 14 | let result = await cache.get("key", fn); 15 | expect(result).toBe(1); 16 | 17 | // The function should not be called again 18 | result = await cache.get("key", fn); 19 | expect(result).toBe(1); 20 | 21 | result = await cache.get("key", fn); 22 | expect(result).toBe(1); 23 | 24 | expect(fn).toHaveBeenCalledTimes(1); 25 | }); 26 | 27 | it("should not cache the result of a function if the ttl has expired", async () => { 28 | const cache = new PromiseCache(1000); 29 | const fn = vi.fn(async () => 1); 30 | let result = await cache.get("key", fn); 31 | expect(result).toBe(1); 32 | 33 | // The function should not be called again 34 | result = await cache.get("key", fn); 35 | expect(result).toBe(1); 36 | 37 | // Fast forward 1 second 38 | vi.advanceTimersByTime(1001); 39 | 40 | result = await cache.get("key", fn); 41 | expect(result).toBe(1); 42 | 43 | expect(fn).toHaveBeenCalledTimes(2); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /apps/web/src/pages/redeem.tsx: -------------------------------------------------------------------------------- 1 | import { MarketingLayout } from "components/MarketingLayout"; 2 | import Link from "next/link"; 3 | import type { NextPageWithLayout } from "./_app"; 4 | 5 | const RedemptionPage: NextPageWithLayout = () => { 6 | return ( 7 |
8 |
9 |

Hey there 👋

10 |

You are awesome 🥳

11 |

12 | Thank you very much for believing in Abby and buying a Lifetime 13 | License. In order to redeem your license, you need to create an 14 | account first. 15 |

16 |

17 | You can do this for free{" "} 18 | here 19 |

20 |

21 | After you signed up, you can then enter redeem your code at the bottom 22 | of the Settings page. 23 |

24 |

25 | Important: If you already have a subscription, make sure to cancel it 26 | first as it will be overridden. 27 |

28 |
29 |
30 | ); 31 | }; 32 | 33 | RedemptionPage.getLayout = (page) => ( 34 | {page} 35 | ); 36 | 37 | export default RedemptionPage; 38 | -------------------------------------------------------------------------------- /packages/remix/tests/cache.test.ts: -------------------------------------------------------------------------------- 1 | import { PromiseCache } from "../src/cache"; 2 | 3 | describe("PromiseCache", () => { 4 | beforeEach(() => { 5 | vi.useFakeTimers(); 6 | }); 7 | afterEach(() => { 8 | vi.restoreAllMocks(); 9 | }); 10 | 11 | it("should cache the result of a function", async () => { 12 | const cache = new PromiseCache(); 13 | const fn = vi.fn(async () => 1); 14 | let result = await cache.get("test", fn); 15 | expect(result).toBe(1); 16 | 17 | // The function should not be called again 18 | result = await cache.get("test", fn); 19 | expect(result).toBe(1); 20 | 21 | result = await cache.get("test", fn); 22 | expect(result).toBe(1); 23 | 24 | expect(fn).toHaveBeenCalledTimes(1); 25 | }); 26 | 27 | it("should not cache the result of a function if the ttl has expired", async () => { 28 | const cache = new PromiseCache(1000); 29 | const fn = vi.fn(async () => 1); 30 | let result = await cache.get("test", fn); 31 | expect(result).toBe(1); 32 | 33 | // The function should not be called again 34 | result = await cache.get("test", fn); 35 | expect(result).toBe(1); 36 | 37 | // Fast forward 1 second 38 | vi.advanceTimersByTime(1001); 39 | 40 | result = await cache.get("test", fn); 41 | expect(result).toBe(1); 42 | 43 | expect(fn).toHaveBeenCalledTimes(2); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Changesets 2 | on: 3 | push: 4 | branches: 5 | - main 6 | env: 7 | CI: true 8 | PNPM_CACHE_FOLDER: .pnpm-store 9 | jobs: 10 | version: 11 | timeout-minutes: 15 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: checkout code repository 15 | uses: actions/checkout@v3 16 | with: 17 | fetch-depth: 0 18 | - name: setup node.js 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: "22.8.0" 22 | - uses: pnpm/action-setup@v2 23 | name: Install pnpm 24 | id: pnpm-install 25 | with: 26 | version: 9.9.0 27 | run_install: false 28 | - name: Setup npmrc 29 | run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > .npmrc 30 | - name: setup pnpm config 31 | run: pnpm config set store-dir $PNPM_CACHE_FOLDER 32 | - name: install dependencies 33 | run: pnpm install --frozen-lockfile 34 | - name: build 35 | run: pnpm run build:packages 36 | - name: create and publish versions 37 | uses: changesets/action@v1 38 | with: 39 | commit: "chore: update versions" 40 | title: "chore: update versions" 41 | publish: pnpm ci:publish --no-git-checks 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | -------------------------------------------------------------------------------- /apps/web/src/utils/trpc.ts: -------------------------------------------------------------------------------- 1 | import { httpLink, loggerLink } from "@trpc/client"; 2 | import { createTRPCNext } from "@trpc/next"; 3 | import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; 4 | import superjson from "superjson"; 5 | 6 | import type { AppRouter } from "../server/trpc/router/_app"; 7 | 8 | const getBaseUrl = () => { 9 | if (typeof window !== "undefined") return ""; // browser should use relative url 10 | if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // SSR should use vercel url 11 | return `http://localhost:${process.env.PORT ?? 3000}`; // dev SSR should use localhost 12 | }; 13 | 14 | export const trpc = createTRPCNext({ 15 | config() { 16 | return { 17 | transformer: superjson, 18 | links: [ 19 | loggerLink({ 20 | enabled: (_opts) => process.env.NODE_ENV === "development", 21 | }), 22 | httpLink({ 23 | url: `${getBaseUrl()}/api/trpc`, 24 | }), 25 | ], 26 | }; 27 | }, 28 | ssr: false, 29 | }); 30 | 31 | /** 32 | * Inference helper for inputs 33 | * @example type HelloInput = RouterInputs['example']['hello'] 34 | **/ 35 | export type RouterInputs = inferRouterInputs; 36 | /** 37 | * Inference helper for outputs 38 | * @example type HelloOutput = RouterOutputs['example']['hello'] 39 | **/ 40 | export type RouterOutputs = inferRouterOutputs; 41 | -------------------------------------------------------------------------------- /packages/cli/src/check.ts: -------------------------------------------------------------------------------- 1 | import { HttpService } from "./http"; 2 | import { loadLocalConfig } from "./util"; 3 | 4 | /** 5 | * Verify that the local config is valid 6 | * This includes validating the local config against the schema 7 | * and checking that all tests and flags are present on the server 8 | * Think of this as a "lint + fetch" for the local config 9 | */ 10 | export async function verifyLocalConfig({ 11 | apiKey, 12 | apiUrl, 13 | configPath, 14 | }: { 15 | apiKey: string; 16 | apiUrl?: string; 17 | configPath?: string; 18 | }) { 19 | const { config: localConfig } = await loadLocalConfig({ configPath }); 20 | 21 | const remoteConfig = await HttpService.getConfigFromServer({ 22 | projectId: localConfig.projectId, 23 | apiKey, 24 | apiUrl, 25 | }); 26 | 27 | const invalidTests = Object.keys(remoteConfig.tests ?? {}).filter((key) => { 28 | return ( 29 | localConfig.tests[key] === undefined && 30 | localConfig.tests[key] !== remoteConfig.tests?.[key] 31 | ); 32 | }); 33 | 34 | const invalidFlags = Object.keys(remoteConfig.flags ?? {}).filter((key) => { 35 | return ( 36 | localConfig.flags[key] === undefined && 37 | localConfig.flags[key] !== remoteConfig.flags?.[key] 38 | ); 39 | }); 40 | 41 | return { 42 | isValid: invalidTests.length === 0 && invalidFlags.length === 0, 43 | invalidTests, 44 | invalidFlags, 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /apps/web/src/components/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"; 4 | import * as React from "react"; 5 | 6 | import { cn } from "lib/utils"; 7 | 8 | const TooltipProvider = TooltipPrimitive.Provider; 9 | 10 | const Tooltip = ({ ...props }) => ; 11 | Tooltip.displayName = TooltipPrimitive.Tooltip.displayName; 12 | 13 | const TooltipTrigger = TooltipPrimitive.Trigger; 14 | 15 | const TooltipContent = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, sideOffset = 4, ...props }, ref) => ( 19 | 20 | 29 | 30 | )); 31 | TooltipContent.displayName = TooltipPrimitive.Content.displayName; 32 | 33 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; 34 | -------------------------------------------------------------------------------- /apps/web/README.md: -------------------------------------------------------------------------------- 1 | # Create T3 App 2 | 3 | This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`. 4 | 5 | ## What's next? How do I make an app with this? 6 | 7 | We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary. 8 | 9 | If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help. 10 | 11 | - [Next.js](https://nextjs.org) 12 | - [NextAuth.js](https://next-auth.js.org) 13 | - [Prisma](https://prisma.io) 14 | - [Tailwind CSS](https://tailwindcss.com) 15 | - [tRPC](https://trpc.io) 16 | 17 | ## Learn More 18 | 19 | To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources: 20 | 21 | - [Documentation](https://create.t3.gg/) 22 | - [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) — Check out these awesome tutorials 23 | 24 | You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) — your feedback and contributions are welcome! 25 | 26 | ## How do I deploy this? 27 | 28 | Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel) and [Docker](https://create.t3.gg/en/deployment/docker) for more information. 29 | --------------------------------------------------------------------------------