Skip to content

fix: Dominik feedback #4

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ To configure your Optimizely webhook:
3. Select the "Webhooks" tab.
4. Click "Create New Webhook".
5. Provide a name for your webhook.
6. Set the URL to the location of your Next.js Optimizely webhook route (`https://[project domain]/api/.well-known/vercel/webhooks/optimizely`).
6. Set the URL to the location of your Next.js Optimizely webhook route (`https://[project domain]/api/optimizely`).
7. Under events, ensure the following events are selected:
1. Datafile: Updated
2. Flag: Created, Updated, Archived, Deleted
Expand All @@ -88,7 +88,7 @@ To configure your Optimizely webhook:
| `/app/product/[slug]/page.tsx` | Product detail page |
| `/app/cart/page.tsx` | Cart page |
| `/app/.well-known/vercel/flags/route.ts` | API route exposing flags to toolbar |
| `/app/.well-known/vercel/webhooks/optimizely/route.ts` | API route called by optimizely to store experimentation data in Vercel Edge Config |
| `/app/api/optimizely/route.ts` | API route called by optimizely to store experimentation data in Vercel Edge Config |
| `/lib/actions.ts` | File containing server actions (e.g. track purchase event) |
| `/lib/flags.ts` | Contains declared flags and precomputed flags |
| `/middleware.ts` | Evaluates precomputed flags, set new shopper cookie |
Expand Down
154 changes: 0 additions & 154 deletions app/.well-known/vercel/webhooks/optimizely/route.ts

This file was deleted.

158 changes: 158 additions & 0 deletions app/api/optimizely/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { NextResponse } from "next/server";
import crypto from "crypto";
import { z } from "zod";

export async function POST(req: Request) {
try {
// Read the request body once
const text = await req.text();
const body = JSON.parse(text);

// Verify the webhook request came from Optimizely
const isVerified = await verifyOptimizelyWebhook(req.headers, text);

if (!isVerified) {
return NextResponse.json(
{ success: false, message: "Invalid webhook request" },
{ status: 401 }
);
}

// Validate the request body
const { data, success, error } =
optimizelyWebhookBodySchema.safeParse(body);

if (!success) {
const errorMessages = error.issues.map(
(issue) => `${issue.path.join(".")}: ${issue.message}`
);
return NextResponse.json(
{
success: false,
message: "Invalid request body",
errors: errorMessages,
},
{ status: 400 }
);
}

const { origin_url } = data.data;

const response = await fetch(origin_url);

if (!response.ok) {
throw new Error(
`Failed to fetch JSON from Optimizely CDN: ${response.statusText}`
);
}

const datafile = await response.json();

await updateEdgeConfig(datafile);

return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
console.error("Error processing webhook:", error);
return NextResponse.json(
{ success: false, message: "Internal server error" },
{ status: 500 }
);
}
}

async function verifyOptimizelyWebhook(
headers: Headers,
body: string
): Promise<boolean> {
try {
const WEBHOOK_SECRET = process.env.OPTIMIZELY_WEBHOOK_SECRET;
if (!WEBHOOK_SECRET) {
throw new Error("Missing OPTIMIZELY_WEBHOOK_SECRET environment variable");
}

const signature = headers.get("X-Hub-Signature");
if (!signature) {
throw new Error("Missing X-Hub-Signature header");
}

const [algorithm, hash] = signature.split("=");
if (algorithm !== "sha1" || !hash) {
throw new Error("Invalid signature format");
}

const hmac = crypto.createHmac("sha1", WEBHOOK_SECRET);
const digest = hmac.update(body).digest("hex");

return crypto.timingSafeEqual(
Buffer.from(hash, "hex"),
Buffer.from(digest, "hex")
);
} catch (error) {
if (error instanceof Error) {
console.error("Error verifying webhook:", error.message);
} else {
console.error("An unknown error occured: ", error)
}
return false;
}
}

async function updateEdgeConfig(datafile: any) {
const { EDGE_CONFIG, TEAM_ID, API_TOKEN } = process.env;

if (!EDGE_CONFIG) {
throw new Error("Missing Vercel EDGE_CONFIG environment variable");
}

if (!TEAM_ID) {
throw new Error("Missing Vercel TEAM_ID environment variable");
}

if (!API_TOKEN) {
throw new Error("Missing Vercel API_TOKEN environment variable");
}

const match = EDGE_CONFIG.match(/\/([^\/?]+)\?/);
const edgeConfigID = match?.[1];

if (!edgeConfigID) {
throw new Error("Invalid EDGE_CONFIG environment variable");
}

const edgeConfigEndpoint = `https://api.vercel.com/v1/edge-config/${edgeConfigID}/items?teamId=${TEAM_ID}`;

const response = await fetch(edgeConfigEndpoint, {
method: "PATCH",
headers: {
Authorization: `Bearer ${API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
items: [
{
operation: "upsert",
key: "datafile",
value: datafile,
},
],
}),
});

if (!response.ok) {
throw new Error(`Failed to update Edge Config: ${response.statusText}`);
}

return response;
}

const optimizelyWebhookBodySchema = z.object({
project_id: z.number(),
timestamp: z.number(),
event: z.string(),
data: z.object({
revision: z.number(),
origin_url: z.string().url(),
cdn_url: z.string().url(),
environment: z.string(),
}),
});
File renamed without changes.
File renamed without changes.
6 changes: 6 additions & 0 deletions lib/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,9 @@ export async function placeOrder() {
await trackProductPurchase();
redirect("/success");
}

export async function getShopperCookie(): Promise<string | "default"> {
const cookieStore = await cookies();
const cookie = cookieStore.get("shopper");
return cookie?.value ? cookie.value : "default";
}
12 changes: 6 additions & 6 deletions lib/flags.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import optimizely from "@optimizely/optimizely-sdk";
import { unstable_flag as flag } from "@vercel/flags/next";
import { getShopperFromHeaders } from "./utils";
import { getShopperCookie } from "./actions";
import { get } from "@vercel/edge-config";

export const showBuyNowFlag = flag<{
Expand All @@ -13,7 +13,7 @@ export const showBuyNowFlag = flag<{
{ label: "Hide", value: { enabled: false } },
{ label: "Show", value: { enabled: true } },
],
async decide({ headers }) {
async decide() {
const datafile = await get("datafile");

if (!datafile) {
Expand All @@ -26,7 +26,7 @@ export const showBuyNowFlag = flag<{
const client = optimizely.createInstance({
datafile: datafile as object,
eventDispatcher: {
dispatchEvent: (event) => {},
dispatchEvent: (_event) => {},
},
});

Expand All @@ -36,7 +36,7 @@ export const showBuyNowFlag = flag<{

await client.onReady({ timeout: 500 });

const shopper = getShopperFromHeaders(headers);
const shopper = await getShopperCookie();
const context = client.createUserContext(shopper);

if (!context) {
Expand Down Expand Up @@ -64,7 +64,7 @@ export const showPromoBannerFlag = flag<boolean>({
{ value: false, label: "Hide" },
{ value: true, label: "Show" },
],
async decide({ headers }) {
async decide() {
const datafile = await get("datafile");

if (!datafile) {
Expand All @@ -87,7 +87,7 @@ export const showPromoBannerFlag = flag<boolean>({

await client.onReady({ timeout: 500 });

const shopper = getShopperFromHeaders(headers);
const shopper = await getShopperCookie();
const context = client!.createUserContext(shopper);

if (!context) {
Expand Down
Loading