mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
This reverts commit https://github.com/supabase/supabase/commit/04e63dfb2e00c43f8c57e538b8056aa647f4e5b4 since it was causing the Studio app to rerender the full page on every link navigation.
This commit is contained in:
1 parent
14d36e9480
commit
85e6b1143f
7 files changed
+58
-709
No files matched your search
+11
-33
@@ -1,23 +1,17 @@
|
||||
import { clientSdkIds } from '~/content/navigation.references'
|
||||
import { BASE_PATH } from '~/lib/constants'
|
||||
import { stampFirstReferrerCookie } from 'common/first-referrer-cookie'
|
||||
import { isbot } from 'isbot'
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
|
||||
import { clientSdkIds } from '~/content/navigation.references'
|
||||
import { BASE_PATH } from '~/lib/constants'
|
||||
|
||||
const REFERENCE_PATH = `${BASE_PATH ?? ''}/reference`
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const url = new URL(request.url)
|
||||
|
||||
// Non-reference paths: just handle the first-referrer cookie and pass through
|
||||
if (!url.pathname.startsWith(REFERENCE_PATH)) {
|
||||
const response = NextResponse.next()
|
||||
stampFirstReferrerCookie(request, response)
|
||||
return response
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// Reference paths: existing rewrite logic with cookie stamping on every response
|
||||
|
||||
if (isbot(request.headers.get('user-agent'))) {
|
||||
let [, lib, maybeVersion, ...slug] = url.pathname.replace(REFERENCE_PATH, '').split('/')
|
||||
|
||||
@@ -30,9 +24,7 @@ export function middleware(request: NextRequest) {
|
||||
if (slug.length > 0) {
|
||||
const rewriteUrl = new URL(url)
|
||||
rewriteUrl.pathname = (BASE_PATH ?? '') + '/api/crawlers'
|
||||
const response = NextResponse.rewrite(rewriteUrl)
|
||||
stampFirstReferrerCookie(request, response)
|
||||
return response
|
||||
return NextResponse.rewrite(rewriteUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,42 +33,28 @@ export function middleware(request: NextRequest) {
|
||||
|
||||
if (lib === 'cli') {
|
||||
const rewritePath = [REFERENCE_PATH, 'cli'].join('/')
|
||||
const response = NextResponse.rewrite(new URL(rewritePath, request.url))
|
||||
stampFirstReferrerCookie(request, response)
|
||||
return response
|
||||
return NextResponse.rewrite(new URL(rewritePath, request.url))
|
||||
}
|
||||
|
||||
if (lib === 'api') {
|
||||
const rewritePath = [REFERENCE_PATH, 'api'].join('/')
|
||||
const response = NextResponse.rewrite(new URL(rewritePath, request.url))
|
||||
stampFirstReferrerCookie(request, response)
|
||||
return response
|
||||
return NextResponse.rewrite(new URL(rewritePath, request.url))
|
||||
}
|
||||
|
||||
if (lib?.startsWith('self-hosting-')) {
|
||||
const rewritePath = [REFERENCE_PATH, lib].join('/')
|
||||
const response = NextResponse.rewrite(new URL(rewritePath, request.url))
|
||||
stampFirstReferrerCookie(request, response)
|
||||
return response
|
||||
return NextResponse.rewrite(new URL(rewritePath, request.url))
|
||||
}
|
||||
|
||||
if (clientSdkIds.includes(lib)) {
|
||||
const version = /v\d+/.test(maybeVersion) ? maybeVersion : null
|
||||
const rewritePath = [REFERENCE_PATH, lib, version].filter(Boolean).join('/')
|
||||
const response = NextResponse.rewrite(new URL(rewritePath, request.url))
|
||||
stampFirstReferrerCookie(request, response)
|
||||
return response
|
||||
return NextResponse.rewrite(new URL(rewritePath, request.url))
|
||||
}
|
||||
|
||||
const response = NextResponse.next()
|
||||
stampFirstReferrerCookie(request, response)
|
||||
return response
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
// Broadened from `/reference/:path*` to stamp first-referrer cookies on all
|
||||
// docs pages, not just reference paths. Excludes Next.js internals and static files.
|
||||
'/((?!api|_next/static|_next/image|favicon.ico|__nextjs).*)',
|
||||
],
|
||||
matcher: '/reference/:path*',
|
||||
}
|
||||
+12
-21
@@ -1,8 +1,10 @@
|
||||
import { stampFirstReferrerCookie } from 'common/first-referrer-cookie'
|
||||
import { IS_PLATFORM } from 'lib/constants'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
export const config = {
|
||||
matcher: '/api/:function*',
|
||||
}
|
||||
|
||||
// [Joshen] Return 404 for all next.js API endpoints EXCEPT the ones we use in hosted:
|
||||
const HOSTED_SUPPORTED_API_URLS = [
|
||||
'/ai/sql/generate-v4',
|
||||
@@ -27,24 +29,13 @@ const HOSTED_SUPPORTED_API_URLS = [
|
||||
]
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
// API route filtering for hosted platform
|
||||
if (request.nextUrl.pathname.startsWith('/api/')) {
|
||||
if (
|
||||
IS_PLATFORM &&
|
||||
!HOSTED_SUPPORTED_API_URLS.some((url) => request.nextUrl.pathname.endsWith(url))
|
||||
) {
|
||||
return Response.json(
|
||||
{ success: false, message: 'Endpoint not supported on hosted' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
if (
|
||||
IS_PLATFORM &&
|
||||
!HOSTED_SUPPORTED_API_URLS.some((url) => request.nextUrl.pathname.endsWith(url))
|
||||
) {
|
||||
return Response.json(
|
||||
{ success: false, message: 'Endpoint not supported on hosted' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const response = NextResponse.next()
|
||||
stampFirstReferrerCookie(request, response)
|
||||
return response
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!_next/static|_next/image|favicon.ico|__nextjs).*)'],
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { stampFirstReferrerCookie } from 'common/first-referrer-cookie'
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const response = NextResponse.next()
|
||||
stampFirstReferrerCookie(request, response)
|
||||
return response
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
// Match all paths except Next.js internals and static files
|
||||
'/((?!api|_next/static|_next/image|favicon.ico|__nextjs).*)',
|
||||
],
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildFirstReferrerData,
|
||||
FIRST_REFERRER_COOKIE_NAME,
|
||||
hasPaidSignals,
|
||||
isExternalReferrer,
|
||||
parseFirstReferrerCookie,
|
||||
serializeFirstReferrerCookie,
|
||||
shouldRefreshCookie,
|
||||
} from './first-referrer-cookie'
|
||||
|
||||
describe('first-referrer-cookie', () => {
|
||||
describe('isExternalReferrer', () => {
|
||||
it('returns false for supabase domains', () => {
|
||||
expect(isExternalReferrer('https://supabase.com')).toBe(false)
|
||||
expect(isExternalReferrer('https://www.supabase.com')).toBe(false)
|
||||
expect(isExternalReferrer('https://docs.supabase.com')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for external domains', () => {
|
||||
expect(isExternalReferrer('https://google.com')).toBe(true)
|
||||
expect(isExternalReferrer('https://chatgpt.com')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for http:// referrers', () => {
|
||||
expect(isExternalReferrer('http://google.com')).toBe(true)
|
||||
expect(isExternalReferrer('http://example.org/page')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for invalid values', () => {
|
||||
expect(isExternalReferrer('')).toBe(false)
|
||||
expect(isExternalReferrer('not-a-url')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildFirstReferrerData', () => {
|
||||
it('handles malformed landing URL gracefully', () => {
|
||||
const data = buildFirstReferrerData({
|
||||
referrer: 'https://google.com',
|
||||
landingUrl: 'not-a-valid-url',
|
||||
})
|
||||
|
||||
expect(data.referrer).toBe('https://google.com')
|
||||
expect(data.landing_url).toBe('not-a-valid-url')
|
||||
expect(data.utms).toEqual({})
|
||||
expect(data.click_ids).toEqual({})
|
||||
})
|
||||
|
||||
it('extracts utm and click-id params from landing url', () => {
|
||||
const data = buildFirstReferrerData({
|
||||
referrer: 'https://www.google.com/',
|
||||
landingUrl:
|
||||
'https://supabase.com/pricing?utm_source=google&utm_medium=cpc&utm_campaign=test&gclid=abc123&msclkid=xyz456',
|
||||
})
|
||||
|
||||
expect(data.referrer).toBe('https://www.google.com/')
|
||||
expect(data.landing_url).toBe(
|
||||
'https://supabase.com/pricing?utm_source=google&utm_medium=cpc&utm_campaign=test&gclid=abc123&msclkid=xyz456'
|
||||
)
|
||||
|
||||
expect(data.utms).toEqual({
|
||||
utm_source: 'google',
|
||||
utm_medium: 'cpc',
|
||||
utm_campaign: 'test',
|
||||
})
|
||||
|
||||
expect(data.click_ids).toEqual({
|
||||
gclid: 'abc123',
|
||||
msclkid: 'xyz456',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('serialize / parse', () => {
|
||||
it('round-trips valid cookie payloads', () => {
|
||||
const input = buildFirstReferrerData({
|
||||
referrer: 'https://www.google.com/',
|
||||
landingUrl: 'https://supabase.com/pricing?utm_source=google',
|
||||
})
|
||||
|
||||
const encoded = serializeFirstReferrerCookie(input)
|
||||
const parsed = parseFirstReferrerCookie(`${FIRST_REFERRER_COOKIE_NAME}=${encoded}`)
|
||||
|
||||
expect(parsed).toEqual(input)
|
||||
})
|
||||
|
||||
it('returns null for empty string', () => {
|
||||
expect(parseFirstReferrerCookie('')).toBeNull()
|
||||
})
|
||||
|
||||
it('parses cookie from header with multiple cookies', () => {
|
||||
const input = buildFirstReferrerData({
|
||||
referrer: 'https://google.com/',
|
||||
landingUrl: 'https://supabase.com/',
|
||||
})
|
||||
const encoded = serializeFirstReferrerCookie(input)
|
||||
const header = `session=abc123; ${FIRST_REFERRER_COOKIE_NAME}=${encoded}; theme=dark`
|
||||
|
||||
expect(parseFirstReferrerCookie(header)).toEqual(input)
|
||||
})
|
||||
|
||||
it('returns null for malformed json', () => {
|
||||
expect(parseFirstReferrerCookie(`${FIRST_REFERRER_COOKIE_NAME}=%7Bnot-json`)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for invalid payload shape', () => {
|
||||
const encoded = encodeURIComponent(JSON.stringify({ foo: 'bar' }))
|
||||
expect(parseFirstReferrerCookie(`${FIRST_REFERRER_COOKIE_NAME}=${encoded}`)).toBeNull()
|
||||
})
|
||||
|
||||
it('drops non-string values in utms/click_ids', () => {
|
||||
const encoded = encodeURIComponent(
|
||||
JSON.stringify({
|
||||
referrer: 'https://www.google.com/',
|
||||
landing_url: 'https://supabase.com/pricing',
|
||||
utms: { utm_source: 'google', utm_medium: 123 },
|
||||
click_ids: { gclid: 'abc', msclkid: null },
|
||||
ts: 123,
|
||||
})
|
||||
)
|
||||
|
||||
const parsed = parseFirstReferrerCookie(`${FIRST_REFERRER_COOKIE_NAME}=${encoded}`)
|
||||
|
||||
expect(parsed).toEqual({
|
||||
referrer: 'https://www.google.com/',
|
||||
landing_url: 'https://supabase.com/pricing',
|
||||
utms: { utm_source: 'google' },
|
||||
click_ids: { gclid: 'abc' },
|
||||
ts: 123,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasPaidSignals', () => {
|
||||
it('detects click IDs', () => {
|
||||
expect(hasPaidSignals(new URL('https://supabase.com/?gclid=abc'))).toBe(true)
|
||||
expect(hasPaidSignals(new URL('https://supabase.com/?fbclid=abc'))).toBe(true)
|
||||
expect(hasPaidSignals(new URL('https://supabase.com/?msclkid=abc'))).toBe(true)
|
||||
expect(hasPaidSignals(new URL('https://supabase.com/?gbraid=abc'))).toBe(true)
|
||||
expect(hasPaidSignals(new URL('https://supabase.com/?wbraid=abc'))).toBe(true)
|
||||
expect(hasPaidSignals(new URL('https://supabase.com/?rdt_cid=abc'))).toBe(true)
|
||||
expect(hasPaidSignals(new URL('https://supabase.com/?ttclid=abc'))).toBe(true)
|
||||
expect(hasPaidSignals(new URL('https://supabase.com/?twclid=abc'))).toBe(true)
|
||||
expect(hasPaidSignals(new URL('https://supabase.com/?li_fat_id=abc'))).toBe(true)
|
||||
})
|
||||
|
||||
it('detects paid utm_medium values', () => {
|
||||
expect(hasPaidSignals(new URL('https://supabase.com/?utm_medium=cpc'))).toBe(true)
|
||||
expect(hasPaidSignals(new URL('https://supabase.com/?utm_medium=ppc'))).toBe(true)
|
||||
expect(hasPaidSignals(new URL('https://supabase.com/?utm_medium=paid_search'))).toBe(true)
|
||||
expect(hasPaidSignals(new URL('https://supabase.com/?utm_medium=paidsocial'))).toBe(true)
|
||||
expect(hasPaidSignals(new URL('https://supabase.com/?utm_medium=paid_social'))).toBe(true)
|
||||
expect(hasPaidSignals(new URL('https://supabase.com/?utm_medium=display'))).toBe(true)
|
||||
})
|
||||
|
||||
it('is case-insensitive for utm_medium', () => {
|
||||
expect(hasPaidSignals(new URL('https://supabase.com/?utm_medium=CPC'))).toBe(true)
|
||||
expect(hasPaidSignals(new URL('https://supabase.com/?utm_medium=Paid_Search'))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for organic traffic', () => {
|
||||
expect(hasPaidSignals(new URL('https://supabase.com/'))).toBe(false)
|
||||
expect(hasPaidSignals(new URL('https://supabase.com/?utm_source=google'))).toBe(false)
|
||||
expect(hasPaidSignals(new URL('https://supabase.com/?utm_medium=email'))).toBe(false)
|
||||
expect(hasPaidSignals(new URL('https://supabase.com/?utm_medium=organic'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldRefreshCookie', () => {
|
||||
it('stamps when no cookie and external referrer', () => {
|
||||
expect(
|
||||
shouldRefreshCookie(false, {
|
||||
referrer: 'https://google.com',
|
||||
url: 'https://supabase.com/',
|
||||
})
|
||||
).toEqual({ stamp: true })
|
||||
})
|
||||
|
||||
it('skips when no cookie and internal referrer', () => {
|
||||
expect(
|
||||
shouldRefreshCookie(false, {
|
||||
referrer: 'https://supabase.com/docs',
|
||||
url: 'https://supabase.com/dashboard',
|
||||
})
|
||||
).toEqual({ stamp: false })
|
||||
})
|
||||
|
||||
it('skips when cookie exists and no paid signals', () => {
|
||||
expect(
|
||||
shouldRefreshCookie(true, {
|
||||
referrer: 'https://google.com',
|
||||
url: 'https://supabase.com/',
|
||||
})
|
||||
).toEqual({ stamp: false })
|
||||
})
|
||||
|
||||
it('refreshes when cookie exists but URL has paid signals', () => {
|
||||
expect(
|
||||
shouldRefreshCookie(true, {
|
||||
referrer: 'https://google.com',
|
||||
url: 'https://supabase.com/?gclid=abc123',
|
||||
})
|
||||
).toEqual({ stamp: true })
|
||||
|
||||
expect(
|
||||
shouldRefreshCookie(true, {
|
||||
referrer: 'https://google.com',
|
||||
url: 'https://supabase.com/?utm_medium=cpc&utm_source=google',
|
||||
})
|
||||
).toEqual({ stamp: true })
|
||||
})
|
||||
|
||||
it('skips when no cookie and no referrer (direct navigation)', () => {
|
||||
expect(shouldRefreshCookie(false, { referrer: '', url: 'https://supabase.com/' })).toEqual({
|
||||
stamp: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('handles malformed URL gracefully', () => {
|
||||
expect(
|
||||
shouldRefreshCookie(true, {
|
||||
referrer: 'https://google.com',
|
||||
url: 'not-a-valid-url',
|
||||
})
|
||||
).toEqual({ stamp: false })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,307 +0,0 @@
|
||||
/**
|
||||
* Shared utilities for the cross-app first-referrer handoff cookie.
|
||||
*
|
||||
* The `_sb_first_referrer` cookie is written by edge middleware on `apps/www`,
|
||||
* `apps/docs`, and `apps/studio` when a user arrives from an
|
||||
* external source. Studio reads it on the first telemetry pageview to recover
|
||||
* external attribution context that would otherwise be lost at the app boundary.
|
||||
*
|
||||
* The cookie is normally write-once (365-day TTL, domain=supabase.com), but is
|
||||
* refreshed when a returning visitor arrives with paid traffic signals (click IDs
|
||||
* or paid UTM medium values) to ensure paid attribution overrides stale organic data.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structural types for Next.js middleware request/response
|
||||
// ---------------------------------------------------------------------------
|
||||
// Using structural interfaces instead of importing NextRequest/NextResponse
|
||||
// avoids version conflicts when different apps pin different Next.js versions
|
||||
// (e.g. studio on Next 15, docs/www on Next 16).
|
||||
|
||||
interface MiddlewareRequest {
|
||||
headers: { get(name: string): string | null }
|
||||
cookies: { has(name: string): boolean }
|
||||
url: string
|
||||
nextUrl: { hostname: string }
|
||||
}
|
||||
|
||||
interface MiddlewareResponse {
|
||||
cookies: {
|
||||
set(
|
||||
name: string,
|
||||
value: string,
|
||||
options?: {
|
||||
path?: string
|
||||
sameSite?: 'lax' | 'strict' | 'none'
|
||||
secure?: boolean
|
||||
domain?: string
|
||||
maxAge?: number
|
||||
}
|
||||
): void
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const FIRST_REFERRER_COOKIE_NAME = '_sb_first_referrer'
|
||||
|
||||
/** 365 days in seconds */
|
||||
export const FIRST_REFERRER_COOKIE_MAX_AGE = 365 * 24 * 60 * 60
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FirstReferrerData {
|
||||
/** The external referrer URL (e.g. https://www.google.com/) */
|
||||
referrer: string
|
||||
/** The landing URL on our site when the external referrer was captured */
|
||||
landing_url: string
|
||||
/** UTM params parsed from the landing URL (e.g. utm_source, utm_medium) */
|
||||
utms: Record<string, string>
|
||||
/** Ad-network click IDs parsed from the landing URL */
|
||||
click_ids: Record<string, string>
|
||||
/** Unix timestamp (ms) when the cookie was written */
|
||||
ts: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Referrer classification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns true if the referrer URL points to an external (non-Supabase) domain.
|
||||
* Handles malformed URLs gracefully by returning false.
|
||||
*/
|
||||
export function isExternalReferrer(referrer: string): boolean {
|
||||
if (!referrer) return false
|
||||
try {
|
||||
const hostname = new URL(referrer).hostname
|
||||
return hostname !== 'supabase.com' && !hostname.endsWith('.supabase.com')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UTM + click-ID extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const UTM_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'] as const
|
||||
|
||||
const CLICK_ID_KEYS = [
|
||||
'gclid', // Google Ads
|
||||
'gbraid', // Google Ads (iOS)
|
||||
'wbraid', // Google Ads (iOS)
|
||||
'msclkid', // Microsoft Ads (Bing)
|
||||
'fbclid', // Meta (Facebook/Instagram)
|
||||
'rdt_cid', // Reddit Ads
|
||||
'ttclid', // TikTok Ads
|
||||
'twclid', // X Ads (Twitter)
|
||||
'li_fat_id', // LinkedIn Ads
|
||||
] as const
|
||||
|
||||
function pickParams(
|
||||
searchParams: URLSearchParams,
|
||||
keys: readonly string[]
|
||||
): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
for (const key of keys) {
|
||||
const value = searchParams.get(key)
|
||||
if (value) {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function toStringRecord(value: unknown): Record<string, string> {
|
||||
if (!value || typeof value !== 'object') return {}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).filter(
|
||||
([key, v]) => typeof key === 'string' && typeof v === 'string'
|
||||
)
|
||||
) as Record<string, string>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build cookie payload from a request (edge-compatible)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a `FirstReferrerData` payload from raw request values.
|
||||
* Intended for use in Next.js middleware where `document` is not available.
|
||||
*/
|
||||
export function buildFirstReferrerData({
|
||||
referrer,
|
||||
landingUrl,
|
||||
}: {
|
||||
referrer: string
|
||||
landingUrl: string
|
||||
}): FirstReferrerData {
|
||||
let utms: Record<string, string> = {}
|
||||
let click_ids: Record<string, string> = {}
|
||||
|
||||
try {
|
||||
const url = new URL(landingUrl)
|
||||
utms = pickParams(url.searchParams, UTM_KEYS)
|
||||
click_ids = pickParams(url.searchParams, CLICK_ID_KEYS)
|
||||
} catch {
|
||||
// If landing URL is malformed, just skip param extraction
|
||||
}
|
||||
|
||||
return {
|
||||
referrer,
|
||||
landing_url: landingUrl,
|
||||
utms,
|
||||
click_ids,
|
||||
ts: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Serialize / parse
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function serializeFirstReferrerCookie(data: FirstReferrerData): string {
|
||||
return encodeURIComponent(JSON.stringify(data))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Paid-signal detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PAID_UTM_MEDIUMS = new Set([
|
||||
'cpc',
|
||||
'ppc',
|
||||
'paid_search',
|
||||
'paidsocial',
|
||||
'paid_social',
|
||||
'display',
|
||||
])
|
||||
|
||||
/**
|
||||
* Returns true if the URL contains ad-network click IDs or paid UTM medium values.
|
||||
* These indicate the user arrived via a paid campaign, which should override
|
||||
* stale organic attribution.
|
||||
*/
|
||||
export function hasPaidSignals(url: URL): boolean {
|
||||
for (const key of CLICK_ID_KEYS) {
|
||||
if (url.searchParams.has(key)) return true
|
||||
}
|
||||
const medium = url.searchParams.get('utm_medium')?.toLowerCase()
|
||||
return medium !== undefined && PAID_UTM_MEDIUMS.has(medium)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides whether the first-referrer cookie should be (re-)stamped.
|
||||
*
|
||||
* - No cookie + external referrer → stamp (first visit attribution)
|
||||
* - Cookie exists + paid signals in URL → stamp (paid traffic refresh)
|
||||
* - Otherwise → skip
|
||||
*/
|
||||
export function shouldRefreshCookie(
|
||||
existingCookie: boolean,
|
||||
request: { referrer: string; url: string }
|
||||
): { stamp: boolean } {
|
||||
if (!existingCookie) {
|
||||
return { stamp: isExternalReferrer(request.referrer) }
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(request.url)
|
||||
return { stamp: hasPaidSignals(url) }
|
||||
} catch {
|
||||
return { stamp: false }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Middleware helper — shared across apps/www, apps/docs, and apps/studio
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Stamp the first-referrer cookie on a Next.js middleware response if the
|
||||
* request warrants it. This is the single entry point for all app middleware
|
||||
* files — call it with the incoming request and outgoing response.
|
||||
*
|
||||
* On *.supabase.com the cookie is set with `domain=supabase.com` so it's
|
||||
* readable across all subdomains (www, docs, studio). On other hosts
|
||||
* (localhost, preview deploys) the domain is left unset so the browser
|
||||
* stores a host-only cookie instead of rejecting an invalid domain.
|
||||
*/
|
||||
export function stampFirstReferrerCookie(request: MiddlewareRequest, response: MiddlewareResponse): void {
|
||||
const referrer = request.headers.get('referer') ?? ''
|
||||
|
||||
const { stamp } = shouldRefreshCookie(request.cookies.has(FIRST_REFERRER_COOKIE_NAME), {
|
||||
referrer,
|
||||
url: request.url,
|
||||
})
|
||||
|
||||
if (!stamp) return
|
||||
|
||||
const data = buildFirstReferrerData({
|
||||
referrer,
|
||||
landingUrl: request.url,
|
||||
})
|
||||
|
||||
response.cookies.set(FIRST_REFERRER_COOKIE_NAME, serializeFirstReferrerCookie(data), {
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
...(request.nextUrl.hostname === 'supabase.com' ||
|
||||
request.nextUrl.hostname.endsWith('.supabase.com')
|
||||
? { domain: 'supabase.com', secure: true }
|
||||
: {}),
|
||||
maxAge: FIRST_REFERRER_COOKIE_MAX_AGE,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parse cookie from document.cookie header (client-side)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function parseFirstReferrerCookie(cookieHeader: string): FirstReferrerData | null {
|
||||
try {
|
||||
const cookies = cookieHeader.split(';')
|
||||
const match = cookies
|
||||
.map((c) => c.trim())
|
||||
.find((c) => c.startsWith(`${FIRST_REFERRER_COOKIE_NAME}=`))
|
||||
|
||||
if (!match) return null
|
||||
|
||||
const value = match.slice(`${FIRST_REFERRER_COOKIE_NAME}=`.length)
|
||||
const parsed = JSON.parse(decodeURIComponent(value)) as unknown
|
||||
|
||||
if (!parsed || typeof parsed !== 'object') return null
|
||||
|
||||
const parsedRecord = parsed as Record<string, unknown>
|
||||
const referrer = parsedRecord.referrer
|
||||
const landingUrl = parsedRecord.landing_url
|
||||
|
||||
if (typeof referrer !== 'string' || typeof landingUrl !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
const utmsRaw = parsedRecord.utms
|
||||
const clickIdsRaw = parsedRecord.click_ids
|
||||
const tsRaw = parsedRecord.ts
|
||||
|
||||
const utms = toStringRecord(utmsRaw)
|
||||
const click_ids = toStringRecord(clickIdsRaw)
|
||||
|
||||
const ts = typeof tsRaw === 'number' && Number.isFinite(tsRaw) ? tsRaw : Date.now()
|
||||
|
||||
return {
|
||||
referrer,
|
||||
landing_url: landingUrl,
|
||||
utms,
|
||||
click_ids,
|
||||
ts,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,5 @@ export * from './helpers'
|
||||
export * from './hooks'
|
||||
export * from './MetaFavicons/pages-router'
|
||||
export * from './Providers'
|
||||
export * from './first-referrer-cookie'
|
||||
export * from './telemetry'
|
||||
export * from './telemetry-utils'
|
||||
+35
-103
@@ -12,8 +12,6 @@ import { hasConsented } from './consent-state'
|
||||
import { IS_PLATFORM, IS_PROD, LOCAL_STORAGE_KEYS } from './constants'
|
||||
import { useFeatureFlags } from './feature-flags'
|
||||
import { post } from './fetchWrappers'
|
||||
import type { FirstReferrerData } from './first-referrer-cookie'
|
||||
import { isExternalReferrer, parseFirstReferrerCookie } from './first-referrer-cookie'
|
||||
import { ensurePlatformSuffix, isBrowser } from './helpers'
|
||||
import { useParams, useTelemetryCookie } from './hooks'
|
||||
import { posthogClient, type ClientTelemetryEvent } from './posthog-client'
|
||||
@@ -98,25 +96,25 @@ function getFirstTouchAttributionProps(telemetryData: SharedTelemetryData) {
|
||||
}
|
||||
}
|
||||
|
||||
interface HandlePageTelemetryOptions {
|
||||
apiUrl: string
|
||||
pathname?: string
|
||||
featureFlags?: Record<string, unknown>
|
||||
slug?: string
|
||||
ref?: string
|
||||
telemetryDataOverride?: SharedTelemetryData
|
||||
firstReferrerData?: FirstReferrerData | null
|
||||
function isExternalReferrer(referrer: string) {
|
||||
try {
|
||||
const hostname = new URL(referrer).hostname
|
||||
return hostname !== 'supabase.com' && !hostname.endsWith('.supabase.com')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageTelemetry({
|
||||
apiUrl: API_URL,
|
||||
pathname,
|
||||
featureFlags,
|
||||
slug,
|
||||
ref,
|
||||
telemetryDataOverride,
|
||||
firstReferrerData,
|
||||
}: HandlePageTelemetryOptions) {
|
||||
function handlePageTelemetry(
|
||||
API_URL: string,
|
||||
pathname?: string,
|
||||
featureFlags?: {
|
||||
[key: string]: unknown
|
||||
},
|
||||
slug?: string,
|
||||
ref?: string,
|
||||
telemetryDataOverride?: SharedTelemetryData
|
||||
) {
|
||||
// Send to PostHog client-side (only in browser)
|
||||
if (typeof window !== 'undefined') {
|
||||
const livePageData = getSharedTelemetryData(pathname)
|
||||
@@ -135,51 +133,10 @@ function handlePageTelemetry({
|
||||
referrer: shouldUseCookieReferrer ? cookieReferrer! : liveReferrer,
|
||||
},
|
||||
}
|
||||
: { ...livePageData, ph: { ...livePageData.ph } }
|
||||
const firstTouchAttributionProps: Record<string, string> = {
|
||||
...(telemetryDataOverride ? getFirstTouchAttributionProps(telemetryDataOverride) : {}),
|
||||
}
|
||||
|
||||
// --- First-referrer edge cookie handoff ---
|
||||
// If the edge cookie has external context and the current referrer is internal,
|
||||
// override the referrer so PostHog gets the real acquisition source.
|
||||
const firstReferrerCookiePresent = Boolean(firstReferrerData)
|
||||
let firstReferrerCookieConsumed = false
|
||||
|
||||
if (
|
||||
firstReferrerData &&
|
||||
isExternalReferrer(firstReferrerData.referrer) &&
|
||||
!isExternalReferrer(pageData.ph.referrer)
|
||||
) {
|
||||
pageData.ph.referrer = firstReferrerData.referrer
|
||||
firstReferrerCookieConsumed = true
|
||||
|
||||
// Prefer attribution context captured at the external entry point.
|
||||
const { utms, click_ids, landing_url } = firstReferrerData
|
||||
|
||||
Object.entries(utms).forEach(([key, value]) => {
|
||||
const phKey = key.startsWith('utm_') ? `$${key}` : key
|
||||
firstTouchAttributionProps[phKey] = value
|
||||
})
|
||||
|
||||
Object.entries(click_ids).forEach(([key, value]) => {
|
||||
firstTouchAttributionProps[key] = value
|
||||
})
|
||||
|
||||
try {
|
||||
const url = new URL(landing_url)
|
||||
firstTouchAttributionProps.first_touch_url = url.href
|
||||
firstTouchAttributionProps.first_touch_pathname = url.pathname
|
||||
|
||||
if (url.search) {
|
||||
firstTouchAttributionProps.first_touch_search = url.search
|
||||
} else {
|
||||
delete firstTouchAttributionProps.first_touch_search
|
||||
}
|
||||
} catch {
|
||||
// Skip if landing URL is malformed
|
||||
}
|
||||
}
|
||||
: livePageData
|
||||
const firstTouchAttributionProps = telemetryDataOverride
|
||||
? getFirstTouchAttributionProps(telemetryDataOverride)
|
||||
: {}
|
||||
|
||||
const $referrer = pageData.ph.referrer
|
||||
const $referring_domain = (() => {
|
||||
@@ -213,13 +170,6 @@ function handlePageTelemetry({
|
||||
...Object.fromEntries(
|
||||
Object.entries(featureFlags || {}).map(([k, v]) => [`$feature/${k}`, v])
|
||||
),
|
||||
// Measurement properties for handoff observability
|
||||
// Only included on the initial pageview (when firstReferrerData is explicitly
|
||||
// passed as null or a value — subsequent pageviews leave it as undefined)
|
||||
...(firstReferrerData !== undefined && {
|
||||
first_referrer_cookie_present: firstReferrerCookiePresent,
|
||||
first_referrer_cookie_consumed: firstReferrerCookieConsumed,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -284,13 +234,13 @@ export const PageTelemetry = ({
|
||||
const sendPageTelemetry = useCallback(() => {
|
||||
if (!(enabled && hasAcceptedConsent)) return Promise.resolve()
|
||||
|
||||
return handlePageTelemetry({
|
||||
apiUrl: API_URL,
|
||||
pathname: pathnameRef.current,
|
||||
featureFlags: featureFlagsRef.current,
|
||||
return handlePageTelemetry(
|
||||
API_URL,
|
||||
pathnameRef.current,
|
||||
featureFlagsRef.current,
|
||||
slug,
|
||||
ref,
|
||||
}).catch((e) => {
|
||||
ref
|
||||
).catch((e) => {
|
||||
console.error('Problem sending telemetry page:', e)
|
||||
})
|
||||
}, [API_URL, enabled, hasAcceptedConsent, slug, ref])
|
||||
@@ -333,9 +283,6 @@ export const PageTelemetry = ({
|
||||
hasAcceptedConsent &&
|
||||
!hasSentInitialPageTelemetryRef.current
|
||||
) {
|
||||
// Read the edge-set first-referrer cookie (cross-app handoff)
|
||||
const firstReferrerData = parseFirstReferrerCookie(document.cookie)
|
||||
|
||||
const cookies = document.cookie.split(';')
|
||||
const telemetryCookieValue = cookies
|
||||
.map((cookie) => cookie.trim())
|
||||
@@ -347,39 +294,24 @@ export const PageTelemetry = ({
|
||||
const telemetryData = JSON.parse(
|
||||
decodeURIComponent(telemetryCookieValue)
|
||||
) as SharedTelemetryData
|
||||
handlePageTelemetry({
|
||||
apiUrl: API_URL,
|
||||
pathname: pathnameRef.current,
|
||||
featureFlags: featureFlagsRef.current,
|
||||
handlePageTelemetry(
|
||||
API_URL,
|
||||
pathnameRef.current,
|
||||
featureFlagsRef.current,
|
||||
slug,
|
||||
ref,
|
||||
telemetryDataOverride: telemetryData,
|
||||
firstReferrerData,
|
||||
})
|
||||
telemetryData
|
||||
)
|
||||
} catch (error) {
|
||||
if (!IS_PROD) {
|
||||
console.warn('Invalid telemetry cookie data:', error)
|
||||
}
|
||||
handlePageTelemetry({
|
||||
apiUrl: API_URL,
|
||||
pathname: pathnameRef.current,
|
||||
featureFlags: featureFlagsRef.current,
|
||||
slug,
|
||||
ref,
|
||||
firstReferrerData,
|
||||
})
|
||||
handlePageTelemetry(API_URL, pathnameRef.current, featureFlagsRef.current, slug, ref)
|
||||
} finally {
|
||||
clearTelemetryDataCookie()
|
||||
}
|
||||
} else {
|
||||
handlePageTelemetry({
|
||||
apiUrl: API_URL,
|
||||
pathname: pathnameRef.current,
|
||||
featureFlags: featureFlagsRef.current,
|
||||
slug,
|
||||
ref,
|
||||
firstReferrerData,
|
||||
})
|
||||
handlePageTelemetry(API_URL, pathnameRef.current, featureFlagsRef.current, slug, ref)
|
||||
}
|
||||
|
||||
hasSentInitialPageTelemetryRef.current = true
|
||||
|
||||
Reference in new issue
Block a user