Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add new v2 config route
Browse files Browse the repository at this point in the history
cstrnt committed Aug 17, 2024

Verified

This commit was signed with the committer’s verified signature.
erikmd Erik Martin-Dorel
1 parent d5b6ee1 commit a48c3c0
Showing 32 changed files with 1,021 additions and 198 deletions.
1 change: 0 additions & 1 deletion apps/web/abby.config.ts
Original file line number Diff line number Diff line change
@@ -6,7 +6,6 @@ export default defineConfig(
projectId: process.env.NEXT_PUBLIC_ABBY_PROJECT_ID!,
currentEnvironment: process.env.VERCEL_ENV ?? process.env.NODE_ENV,
apiUrl: process.env.NEXT_PUBLIC_ABBY_API_URL,
__experimentalCdnUrl: process.env.NEXT_PUBLIC_ABBY_CDN_URL,
debug: process.env.NEXT_PUBLIC_ABBY_DEBUG === "true",
},
{
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `ApiRequest` MODIFY `apiVersion` ENUM('V0', 'V1', 'V2') NOT NULL DEFAULT 'V0';
1 change: 1 addition & 0 deletions apps/web/prisma/schema.prisma
Original file line number Diff line number Diff line change
@@ -293,6 +293,7 @@ enum ApiRequestType {
enum ApiVersion {
V0
V1
V2
}

model ApiRequest {
5 changes: 4 additions & 1 deletion apps/web/src/api/index.ts
Original file line number Diff line number Diff line change
@@ -6,6 +6,7 @@ import { logger } from "hono/logger";
import { makeHealthRoute } from "./routes/health";
import { makeLegacyProjectDataRoute } from "./routes/legacy_project_data";
import { makeEventRoute } from "./routes/v1_event";
import { makeV2ProjectDataRoute } from "./routes/v2_project_data";

export const app = new Hono()
.basePath("/api")
@@ -19,4 +20,6 @@ export const app = new Hono()
// v1 routes
.route("/v1/config", makeConfigRoute())
.route("/v1/data", makeProjectDataRoute())
.route("/v1/track", makeEventRoute());
.route("/v1/track", makeEventRoute())
// v2 routes
.route("/v2/data", makeV2ProjectDataRoute());
13 changes: 11 additions & 2 deletions apps/web/src/api/routes/v1_project_data.ts
Original file line number Diff line number Diff line change
@@ -21,7 +21,11 @@ async function getAbbyResponseWithCache({
c: Context;
}) {
startTime(c, "readCache");
const cachedConfig = ConfigCache.getConfig({ environment, projectId });
const cachedConfig = ConfigCache.getConfig({
environment,
projectId,
apiVersion: "v1",
});
endTime(c, "readCache");

c.header(X_ABBY_CACHE_HEADER, cachedConfig !== undefined ? "HIT" : "MISS");
@@ -72,7 +76,12 @@ async function getAbbyResponseWithCache({
}),
} satisfies AbbyDataResponse;

ConfigCache.setConfig({ environment, projectId, value: response });
ConfigCache.setConfig({
environment,
projectId,
value: response,
apiVersion: "v1",
});
return response;
}

190 changes: 190 additions & 0 deletions apps/web/src/api/routes/v2_project_data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { zValidator } from "@hono/zod-validator";
import {
ABBY_WINDOW_KEY,
hashStringToInt32,
serializeAbbyData,
type AbbyData,
} from "@tryabby/core";
import { type Context, Hono } from "hono";
import { cors } from "hono/cors";
import { endTime, startTime, timing } from "hono/timing";
import { transformFlagValue } from "lib/flags";
import { ConfigCache } from "server/common/config-cache";
import { prisma } from "server/db/client";
import { afterDataRequestQueue } from "server/queue/queues";
import { z } from "zod";

export const X_ABBY_CACHE_HEADER = "X-Abby-Cache";

async function getAbbyResponseWithCache({
environment,
projectId,
c,
}: {
environment: string;
projectId: string;
c: Context;
}) {
startTime(c, "readCache");
const cachedConfig = ConfigCache.getConfig({
environment,
projectId,
apiVersion: "v2",
});
endTime(c, "readCache");

c.header(X_ABBY_CACHE_HEADER, cachedConfig !== undefined ? "HIT" : "MISS");
if (cachedConfig) {
return serializeAbbyData(cachedConfig as AbbyData);
}

startTime(c, "db");
const [dbTests, dbFlags] = await Promise.all([
prisma.test.findMany({
where: {
projectId,
},
include: { options: { select: { chance: true } } },
}),
prisma.featureFlagValue.findMany({
where: {
environment: {
name: environment,
projectId,
},
},
include: { flag: { select: { name: true, type: true } } },
}),
]);
endTime(c, "db");

const flags = dbFlags.filter(({ flag }) => flag.type === "BOOLEAN");

const remoteConfigs = dbFlags.filter(({ flag }) => flag.type !== "BOOLEAN");

const response = {
tests: dbTests.map((test) => ({
name: hashStringToInt32(test.name).toString(),
weights: test.options.map((o) => o.chance.toNumber()),
})),
flags: flags.map((flagValue) => {
return {
name: hashStringToInt32(flagValue.flag.name).toString(),
value: transformFlagValue(flagValue.value, flagValue.flag.type),
};
}),
remoteConfig: remoteConfigs.map((flagValue) => {
return {
name: hashStringToInt32(flagValue.flag.name).toString(),
value: transformFlagValue(flagValue.value, flagValue.flag.type),
};
}),
} satisfies AbbyData;

ConfigCache.setConfig({
environment,
projectId,
value: response,
apiVersion: "v2",
});
return serializeAbbyData(response);
}

export function makeV2ProjectDataRoute() {
const app = new Hono()
.get(
"/:projectId",
cors({
origin: "*",
maxAge: 86400,
}),
zValidator(
"query",
z.object({
environment: z.string(),
})
),
timing(),
async (c) => {
const projectId = c.req.param("projectId");
const { environment } = c.req.valid("query");

const now = performance.now();

try {
startTime(c, "getAbbyResponseWithCache");
const response = await getAbbyResponseWithCache({
projectId,
environment,
c,
});
endTime(c, "getAbbyResponseWithCache");

const duration = performance.now() - now;

afterDataRequestQueue.add("after-data-request", {
apiVersion: "V2",
functionDuration: duration,
projectId,
});

return c.json(response);
} catch (e) {
console.error(e);
return c.json({ error: "Internal server error" }, { status: 500 });
}
}
)
.get(
"/:projectId/script.js",
cors({
origin: "*",
maxAge: 86400,
}),
zValidator(
"query",
z.object({
environment: z.string(),
})
),
timing(),
async (c) => {
const projectId = c.req.param("projectId");
const { environment } = c.req.valid("query");

const now = performance.now();

try {
startTime(c, "getAbbyResponseWithCache");
const response = await getAbbyResponseWithCache({
projectId,
environment,
c,
});
endTime(c, "getAbbyResponseWithCache");

const jsContent = `window.${ABBY_WINDOW_KEY} = ${JSON.stringify(
response
)}`;

const duration = performance.now() - now;

afterDataRequestQueue.add("after-data-request", {
apiVersion: "V2",
functionDuration: duration,
projectId,
});

return c.text(jsContent, {
headers: {
"Content-Type": "application/javascript",
},
});
} catch (e) {
console.error(e);
return c.json({ error: "Internal server error" }, { status: 500 });
}
}
);
return app;
}
7 changes: 6 additions & 1 deletion apps/web/src/components/AddFeatureFlagModal.tsx
Original file line number Diff line number Diff line change
@@ -15,6 +15,7 @@ import { Toggle } from "./Toggle";

import { useTracking } from "lib/tracking";
import { Input } from "./ui/input";
import { SAFE_NAME_REGEX } from "@tryabby/core";

type Props = {
onClose: () => void;
@@ -168,7 +169,6 @@ export const AddFeatureFlagModal = ({
projectId,
isRemoteConfig,
}: Props) => {
const _inputRef = useRef<HTMLInputElement>(null);
const ctx = trpc.useContext();
const stateRef = useRef<FlagFormValues>();
const trackEvent = useTracking();
@@ -202,6 +202,11 @@ export const AddFeatureFlagModal = ({
if (!stateRef.current?.value) {
errors.value = "Value is required";
}

if (SAFE_NAME_REGEX.test(trimmedName) === false) {
errors.name =
"Invalid name. Only letters, numbers, and underscores are allowed.";
}
if (Object.keys(errors).length > 0) {
setErrors(errors);
return;
6 changes: 5 additions & 1 deletion apps/web/src/components/FlagPage.tsx
Original file line number Diff line number Diff line change
@@ -15,7 +15,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "components/Tooltip";
import { Input } from "components/ui/input";
import { useProjectId } from "lib/hooks/useProjectId";
import { EditIcon, FileEditIcon, Search, TrashIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import { AiOutlinePlus } from "react-icons/ai";
import { BiInfoCircle } from "react-icons/bi";
@@ -188,6 +188,10 @@ export const FeatureFlagPageContent = ({
setFlags(results.map((result) => result.item));
};

useEffect(() => {
setFlags(data.flags);
}, [data.flags]);

const activeFlag = data.flags.find((flag) => flag.id === activeFlagInfo?.id);

if (data.environments.length === 0)
1 change: 1 addition & 0 deletions apps/web/src/lib/abby.tsx
Original file line number Diff line number Diff line change
@@ -13,6 +13,7 @@ export const {
getABTestValue,
withAbbyApiHandler,
getABResetFunction,
useRemoteConfig,
} = createAbby(abbyConfig);

export const AbbyDevtools = withDevtools(abbyDevtools, {});
2 changes: 1 addition & 1 deletion apps/web/src/pages/_app.tsx
Original file line number Diff line number Diff line change
@@ -8,7 +8,6 @@ import { trpc } from "../utils/trpc";

import { TooltipProvider } from "components/Tooltip";
import { env } from "env/client.mjs";
import { AbbyDevtools, AbbyProvider, withAbby } from "lib/abby";
import type { NextPage } from "next";
import { useRouter } from "next/router";
import type { ReactElement, ReactNode } from "react";
@@ -17,6 +16,7 @@ import "@fontsource/martian-mono/600.css";
import "../styles/shadcn.css";
import "@code-hike/mdx/dist/index.css";
import PlausibleProvider from "next-plausible";
import { AbbyDevtools, AbbyProvider, withAbby } from "lib/abby";

export type NextPageWithLayout<P = unknown, IP = P> = NextPage<P, IP> & {
getLayout?: (page: ReactElement) => ReactNode;
1 change: 1 addition & 0 deletions apps/web/src/pages/devtools.tsx
Original file line number Diff line number Diff line change
@@ -205,6 +205,7 @@ export const getStaticProps = async () => {
const data = await HttpService.getProjectData({
projectId: config.projectId,
environment: config.currentEnvironment,
experimental: config.experimental,
});
return {
props: { abbyData: data },
1 change: 1 addition & 0 deletions apps/web/src/pages/index.tsx
Original file line number Diff line number Diff line change
@@ -380,6 +380,7 @@ export const getStaticProps = async () => {
const data = await HttpService.getProjectData({
projectId: config.projectId,
environment: config.currentEnvironment,
experimental: config.experimental,
});
const codeSnippet = await generateCodeSnippets({
projectId: "<PROJECT_ID>",
29 changes: 21 additions & 8 deletions apps/web/src/server/common/config-cache.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AbbyDataResponse } from "@tryabby/core";
import type { AbbyConfigFile, AbbyDataResponse } from "@tryabby/core";
import createCache from "./memory-cache";

const configCache = createCache<string, AbbyDataResponse>({
@@ -10,24 +10,37 @@ const configCache = createCache<string, AbbyDataResponse>({
type ConfigCacheKey = {
environment: string;
projectId: string;
apiVersion: NonNullable<AbbyConfigFile["experimental"]>["apiVersion"];
};

export abstract class ConfigCache {
static getConfig({ environment, projectId }: ConfigCacheKey) {
return configCache.get(projectId + environment);
private static getCacheKey({
apiVersion,
environment,
projectId,
}: ConfigCacheKey) {
return [projectId, environment, apiVersion].join(":");
}
static getConfig(opts: ConfigCacheKey) {
return configCache.get(ConfigCache.getCacheKey(opts));
}

static setConfig({
environment,
projectId,
value,
...opts
}: ConfigCacheKey & {
value: AbbyDataResponse;
}) {
configCache.set(projectId + environment, value);
configCache.set(ConfigCache.getCacheKey(opts), value);
}

static deleteConfig({ environment, projectId }: ConfigCacheKey) {
configCache.delete(projectId + environment);
static deleteConfig(opts: Omit<ConfigCacheKey, "apiVersion">) {
const apiVersionsToClear: Array<ConfigCacheKey["apiVersion"]> = [
"v1",
"v2",
];
for (const apiVersion of apiVersionsToClear) {
configCache.delete(ConfigCache.getCacheKey({ ...opts, apiVersion }));
}
}
}
Loading

0 comments on commit a48c3c0

Please sign in to comment.