diff --git a/README.md b/README.md index 5f99b56..cc30b5e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 | diff --git a/app/.well-known/vercel/webhooks/optimizely/route.ts b/app/.well-known/vercel/webhooks/optimizely/route.ts deleted file mode 100644 index e50c23a..0000000 --- a/app/.well-known/vercel/webhooks/optimizely/route.ts +++ /dev/null @@ -1,154 +0,0 @@ -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 { - 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) { - console.error("Error verifying webhook:", error.message); - 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(), - }), -}); diff --git a/app/api/optimizely/route.ts b/app/api/optimizely/route.ts new file mode 100644 index 0000000..0fb49f6 --- /dev/null +++ b/app/api/optimizely/route.ts @@ -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 { + 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(), + }), +}); diff --git a/app/[code]/layout.tsx b/app/precomputed/[code]/layout.tsx similarity index 100% rename from app/[code]/layout.tsx rename to app/precomputed/[code]/layout.tsx diff --git a/app/[code]/page.tsx b/app/precomputed/[code]/page.tsx similarity index 100% rename from app/[code]/page.tsx rename to app/precomputed/[code]/page.tsx diff --git a/lib/actions.ts b/lib/actions.ts index adcff11..49fb9f0 100644 --- a/lib/actions.ts +++ b/lib/actions.ts @@ -54,3 +54,9 @@ export async function placeOrder() { await trackProductPurchase(); redirect("/success"); } + +export async function getShopperCookie(): Promise { + const cookieStore = await cookies(); + const cookie = cookieStore.get("shopper"); + return cookie?.value ? cookie.value : "default"; +} \ No newline at end of file diff --git a/lib/flags.ts b/lib/flags.ts index 232083d..f32daa6 100644 --- a/lib/flags.ts +++ b/lib/flags.ts @@ -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<{ @@ -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) { @@ -26,7 +26,7 @@ export const showBuyNowFlag = flag<{ const client = optimizely.createInstance({ datafile: datafile as object, eventDispatcher: { - dispatchEvent: (event) => {}, + dispatchEvent: (_event) => {}, }, }); @@ -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) { @@ -64,7 +64,7 @@ export const showPromoBannerFlag = flag({ { value: false, label: "Hide" }, { value: true, label: "Show" }, ], - async decide({ headers }) { + async decide() { const datafile = await get("datafile"); if (!datafile) { @@ -87,7 +87,7 @@ export const showPromoBannerFlag = flag({ await client.onReady({ timeout: 500 }); - const shopper = getShopperFromHeaders(headers); + const shopper = await getShopperCookie(); const context = client!.createUserContext(shopper); if (!context) { diff --git a/lib/utils.ts b/lib/utils.ts index a5c8644..b959b42 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,5 +1,4 @@ import { type ClassValue, clsx } from "clsx"; -import { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers"; import { twMerge } from "tailwind-merge"; const formatter = new Intl.NumberFormat("en-US", { @@ -11,20 +10,6 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } -export function getShopperFromHeaders( - headers: ReadonlyHeaders -): string | "default" { - const cookieString = headers.get("cookie"); - if (!cookieString) { - return "default"; - } - const cookies = cookieString.split("; "); - const cookie = cookies.find((cookie: any) => - cookie.startsWith("shopper" + "=") - ); - return cookie ? cookie.split("=")[1] : "default"; -} - export function formatUSD(amount: number) { return formatter.format(amount); } diff --git a/middleware.ts b/middleware.ts index 886a81d..2fad876 100644 --- a/middleware.ts +++ b/middleware.ts @@ -10,6 +10,12 @@ export const config = { export async function middleware(request: NextRequest, event: NextFetchEvent) { let response = NextResponse.next(); + // set a shopper cookie if one doesn't exist or has been cleared + if (!request.cookies.has("shopper")) { + const newShopperId = Math.random().toString(36).substring(2); + response.cookies.set("shopper", newShopperId); + } + const context = { /* pass context on whatever your flag will need */ event, @@ -21,17 +27,11 @@ export async function middleware(request: NextRequest, event: NextFetchEvent) { // rewrites the request to the variant for this flag combination const nextUrl = new URL( - `/${code}${request.nextUrl.pathname}${request.nextUrl.search}`, + `/precomputed/${code}${request.nextUrl.pathname}${request.nextUrl.search}`, request.url ); response = NextResponse.rewrite(nextUrl, { request }); } - // set a shopper cookie if one doesn't exist or has been cleared - if (!request.cookies.has("shopper")) { - const newShopperId = Math.random().toString(36).substring(2); - response.cookies.set("shopper", newShopperId); - } - return response; }