Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
type AbbyData,
hashStringToInt32,
serializeAbbyData,
} 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 @@ -13,6 +13,7 @@ import { RadioSelect } from "./RadioSelect";

import { Toggle } from "./Toggle";

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

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 @@ -16,7 +16,7 @@ import { Input } from "components/ui/input";
import Fuse from "fuse.js";
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,14 +8,14 @@ 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";
import "@fontsource/martian-mono/600.css";

import "../styles/shadcn.css";
import "@code-hike/mdx/dist/index.css";
import { AbbyDevtools, AbbyProvider, withAbby } from "lib/abby";
import PlausibleProvider from "next-plausible";

export type NextPageWithLayout<P = unknown, IP = P> = NextPage<P, IP> & {
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