From 78d992fda71eca0616c66da1097b6a5338f4b199 Mon Sep 17 00:00:00 2001 From: Ryan Salazar Date: Sun, 24 May 2026 23:26:02 +0000 Subject: [PATCH] ads: server-side impression beacon in middleware (AdSanity parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fires one impression per page render, per slot, server-side — mirrors AdSanity's WP-level counting model. Picks one banner from each slot's rotation pool per request. Edge-worker-cached pool refresh, 5min TTL. Closes the structural gap with historical AdSanity numbers (client-side counting was dropping 25-40% to ad blockers, plus the beacon failed silently on fast navigations). --- src/middleware.ts | 96 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 93 insertions(+), 3 deletions(-) diff --git a/src/middleware.ts b/src/middleware.ts index e739c81..d5c369a 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,8 +1,6 @@ import { NextResponse, type NextRequest } from "next/server"; // PR firms / writers authenticate at the distribute platform, not here. -// Anything that looks like a WordPress login or the bare /login path -// redirects there so old bookmarks don't dead-end. const AUTH_TARGET = "https://distribution.relevantmediaproperties.com/login"; const REDIRECT_PATHS = new Set([ @@ -14,8 +12,84 @@ const REDIRECT_PATHS = new Set([ "/register", ]); -export function middleware(req: NextRequest) { +// ─── Server-side impression beacon (AdSanity-equivalent) ──────────────────── +// +// Why: client-side beacons drop 20-50% to ad blockers, JS-disabled clients, +// and lost beacons on fast navigation. AdSanity counted server-side — every +// WP page render = 1 impression per zone. To get parity with the historical +// numbers our advertisers expect, we fire impressions here on every HTML +// page request. +// +// For each slot (728x90, 300x600, 300x250) we pick ONE banner from the +// rotation pool per page render — same model AdSanity used. The rotation +// pool is fetched from advertising-rmp and cached per-edge-worker for 5min. + +const RMP_BASE = + (process.env.NEXT_PUBLIC_RMP_ADS_API + || "https://advertising.relevantmediaproperties.com").replace(/\/$/, ""); + +const PROPERTY = "broadcastbeat"; +const SLOTS = ["728x90", "300x600", "300x250"] as const; +type Slot = typeof SLOTS[number]; + +// Edge worker-local cache. Each region's edge will refresh independently. +let poolCache: Record = { "728x90": [], "300x600": [], "300x250": [] }; +let poolLoadedAt = 0; +const POOL_TTL_MS = 5 * 60 * 1000; + +async function loadPool() { + if (Date.now() - poolLoadedAt < POOL_TTL_MS) return; + try { + const results = await Promise.all( + SLOTS.map(async (size) => { + const res = await fetch(`${RMP_BASE}/api/banners/${PROPERTY}/${size}`, { + cache: "no-store", + }); + if (!res.ok) return [size, [] as string[]] as const; + const d = (await res.json()) as { banners?: Array<{ campaign_id?: string }> }; + return [size, (d.banners || []).map((b) => b.campaign_id).filter(Boolean) as string[]] as const; + }) + ); + const next: Record = { "728x90": [], "300x600": [], "300x250": [] }; + for (const [size, ids] of results) next[size] = ids; + poolCache = next; + poolLoadedAt = Date.now(); + } catch { + // keep stale cache on failure + } +} + +// Paths that don't count as a "page view" — assets, APIs, edge probes. +const SKIP_RE = /^\/(api|_next|legacy|favicon|robots\.txt|sitemap|opensearch|.*\.(?:css|js|map|png|jpg|jpeg|gif|webp|svg|ico|woff|woff2|ttf|eot|mp4|webm|xml|txt|json))$/i; + +function fireImpression(req: NextRequest, campaign_id: string) { + const url = new URL(req.url); + fetch(`${RMP_BASE}/api/track/impression`, { + method: "POST", + headers: { + "content-type": "application/json", + "user-agent": req.headers.get("user-agent") || "", + "x-forwarded-for": + req.headers.get("x-forwarded-for") || + req.headers.get("cf-connecting-ip") || + "", + "referer": req.headers.get("referer") || "", + }, + body: JSON.stringify({ + campaign_id, + page_path: url.pathname, + page_url: req.url, + viewport: false, + }), + cache: "no-store", + keepalive: true, + }).catch(() => undefined); +} + +export async function middleware(req: NextRequest) { const path = req.nextUrl.pathname; + + // 1) auth redirects (legacy WP-style logins) if ( REDIRECT_PATHS.has(path) || path.startsWith("/wp-admin/") || @@ -23,11 +97,27 @@ export function middleware(req: NextRequest) { ) { return NextResponse.redirect(AUTH_TARGET, 301); } + + // 2) server-side impression beacons per page render + if (!SKIP_RE.test(path)) { + await loadPool(); + for (const slot of SLOTS) { + const pool = poolCache[slot]; + if (pool.length === 0) continue; + const picked = pool[Math.floor(Math.random() * pool.length)]; + fireImpression(req, picked); + } + } + return NextResponse.next(); } +// Match every path (impression beacon needs site-wide coverage) — the +// SKIP_RE inside the handler filters out asset/API requests so we only +// count real page views. export const config = { matcher: [ + "/((?!api/|_next/|legacy/|.*\\.).*)", "/login", "/wp-login", "/wp-login.php",