Skip to content

Commit

Permalink
feat: add new v2 config route
Browse files Browse the repository at this point in the history
  • Loading branch information
cstrnt committed Aug 17, 2024
1 parent 64e65dd commit 137c81e
Show file tree
Hide file tree
Showing 40 changed files with 1,026 additions and 221 deletions.
1 change: 0 additions & 1 deletion apps/web/abby.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
{
Expand Down
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
Expand Up @@ -293,6 +293,7 @@ enum ApiRequestType {
enum ApiVersion {
V0
V1
V2
}

model ApiRequest {
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
Expand Up @@ -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");
Expand Down Expand Up @@ -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;
}

Expand Down
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
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/components/FlagPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/lib/abby.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Up @@ -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";
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/pages/devtools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>",
Expand Down
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>({
Expand All @@ -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 137c81e

Please sign in to comment.