diff --git a/apps/web/abTest/middlewareFactory.ts b/apps/web/abTest/middlewareFactory.ts new file mode 100644 index 0000000000..bb2115e47a --- /dev/null +++ b/apps/web/abTest/middlewareFactory.ts @@ -0,0 +1,50 @@ +import { getBucket } from "abTest/utils"; +import type { NextMiddleware, NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import z from "zod"; + +const ROUTES: [RegExp, boolean][] = [ + [/^\/event-types$/, Boolean(process.env.APP_ROUTER_EVENT_TYPES_ENABLED)], +]; + +const FUTURE_ROUTES_OVERRIDE_COOKIE_NAME = "x-calcom-future-routes-override"; +const FUTURE_ROUTES_ENABLED_COOKIE_NAME = "x-calcom-future-routes-enabled"; + +const bucketSchema = z.union([z.literal("legacy"), z.literal("future")]).default("legacy"); + +export const abTestMiddlewareFactory = + (next: (req: NextRequest) => Promise>): NextMiddleware => + async (req: NextRequest) => { + const response = await next(req); + + const { pathname } = req.nextUrl; + + const override = req.cookies.has(FUTURE_ROUTES_OVERRIDE_COOKIE_NAME); + + const route = ROUTES.find(([regExp]) => regExp.test(pathname)) ?? null; + + const enabled = route !== null ? route[1] || override : false; + + if (pathname.includes("future") || !enabled) { + return response; + } + + const safeParsedBucket = override + ? { success: true as const, data: "future" as const } + : bucketSchema.safeParse(req.cookies.get(FUTURE_ROUTES_ENABLED_COOKIE_NAME)?.value); + + if (!safeParsedBucket.success) { + // cookie does not exist or it has incorrect value + + const res = NextResponse.next(response); + res.cookies.set(FUTURE_ROUTES_ENABLED_COOKIE_NAME, getBucket(), { expires: 1000 * 60 * 30 }); // 30 min in ms + return res; + } + + const bucketUrlPrefix = safeParsedBucket.data === "future" ? "future" : ""; + + const url = req.nextUrl.clone(); + url.pathname = `${bucketUrlPrefix}${pathname}/`; + + return NextResponse.rewrite(url, response); + }; diff --git a/apps/web/abTest/utils.ts b/apps/web/abTest/utils.ts new file mode 100644 index 0000000000..ed40c9fca9 --- /dev/null +++ b/apps/web/abTest/utils.ts @@ -0,0 +1,9 @@ +import { AB_TEST_BUCKET_PROBABILITY } from "@calcom/lib/constants"; + +const cryptoRandom = () => { + return crypto.getRandomValues(new Uint8Array(1))[0] / 0xff; +}; + +export const getBucket = () => { + return cryptoRandom() * 100 < AB_TEST_BUCKET_PROBABILITY ? "future" : "legacy"; +}; diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 59ac3a80ad..cc5fb76143 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -106,3 +106,4 @@ export const APP_CREDENTIAL_SHARING_ENABLED = export const DEFAULT_LIGHT_BRAND_COLOR = "#292929"; export const DEFAULT_DARK_BRAND_COLOR = "#fafafa"; +export const AB_TEST_BUCKET_PROBABILITY = Number(process.env.AB_TEST_BUCKET_PROBABILITY ?? "10"); diff --git a/turbo.json b/turbo.json index 7f1282d0c4..2b171f6723 100644 --- a/turbo.json +++ b/turbo.json @@ -194,9 +194,11 @@ }, "globalDependencies": ["yarn.lock"], "globalEnv": [ + "AB_TEST_BUCKET_PROBABILITY", "ALLOWED_HOSTNAMES", "ANALYZE", "API_KEY_PREFIX", + "APP_ROUTER_EVENT_TYPES_ENABLED", "APP_USER_NAME", "BASECAMP3_CLIENT_ID", "BASECAMP3_CLIENT_SECRET",