diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index e1c17045..5c3fc3a3 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -9,7 +9,16 @@ import type { RybbitAnalyticsInput } from './runtime/registry/rybbit-analytics' import type { SegmentInput } from './runtime/registry/segment' import type { TikTokPixelInput } from './runtime/registry/tiktok-pixel' import type { ProxyPrivacyInput } from './runtime/server/utils/privacy' -import type { ProxyAutoInject, ProxyCapability, ProxyConfig, RegistryScript, RegistryScriptKey, RegistryScriptServerHandler, ResolvedProxyAutoInject, ScriptCapabilities } from './runtime/types' +import type { + ProxyAutoInject, + ProxyCapability, + ProxyConfig, + RegistryScript, + RegistryScriptKey, + RegistryScriptServerHandler, + ResolvedProxyAutoInject, + ScriptCapabilities, +} from './runtime/types' import { joinURL, withBase, withQuery } from 'ufo' import { LOGOS } from './registry-logos' import { @@ -72,18 +81,55 @@ export interface PrivacyDescription { } // Privacy presets -export const PRIVACY_NONE: ProxyPrivacyInput = { ip: false, userAgent: false, language: false, screen: false, timezone: false, hardware: false } -export const PRIVACY_FULL: ProxyPrivacyInput = { ip: true, userAgent: true, language: true, screen: true, timezone: true, hardware: true } -export const PRIVACY_HEATMAP: ProxyPrivacyInput = { ip: true, userAgent: false, language: true, screen: false, timezone: false, hardware: true } -export const PRIVACY_IP_ONLY: ProxyPrivacyInput = { ip: true, userAgent: false, language: false, screen: false, timezone: false, hardware: false } +export const PRIVACY_NONE: ProxyPrivacyInput = { + ip: false, + userAgent: false, + language: false, + screen: false, + timezone: false, + hardware: false, +} +export const PRIVACY_FULL: ProxyPrivacyInput = { + ip: true, + userAgent: true, + language: true, + screen: true, + timezone: true, + hardware: true, +} +export const PRIVACY_HEATMAP: ProxyPrivacyInput = { + ip: true, + userAgent: false, + language: true, + screen: false, + timezone: false, + hardware: true, +} +export const PRIVACY_IP_ONLY: ProxyPrivacyInput = { + ip: true, + userAgent: false, + language: false, + screen: false, + timezone: false, + hardware: false, +} // -- Privacy descriptions -- export const PRIVACY_DESCRIPTIONS: Record = { 'ip-only': { label: 'IP Only', description: 'Anonymises IP addresses; other data passes through.' }, - 'full': { label: 'Full', description: 'All identifying data is anonymised: IP, user agent, language, screen, timezone, and hardware.' }, - 'heatmap': { label: 'Heatmap', description: 'IP, language, and hardware fingerprints anonymised; screen data preserved for heatmap accuracy.' }, - 'none': { label: 'None', description: 'No data is anonymised. Sensitive auth headers (cookies, authorization) are still stripped.' }, + 'full': { + label: 'Full', + description: 'All identifying data is anonymised: IP, user agent, language, screen, timezone, and hardware.', + }, + 'heatmap': { + label: 'Heatmap', + description: 'IP, language, and hardware fingerprints anonymised; screen data preserved for heatmap accuracy.', + }, + 'none': { + label: 'None', + description: 'No data is anonymised. Sensitive auth headers (cookies, authorization) are still stripped.', + }, } /** Resolve a privacy preset to a human-readable label + description. */ @@ -94,44 +140,179 @@ export function getPrivacyDescription(privacy: ProxyPrivacyInput | null | undefi return PRIVACY_DESCRIPTIONS.full! if (privacy.ip && privacy.userAgent && privacy.language && privacy.screen && privacy.timezone && privacy.hardware) return PRIVACY_DESCRIPTIONS.full! - if (privacy.ip && privacy.language && privacy.hardware && !privacy.screen && !privacy.timezone && !privacy.userAgent) + if ( + privacy.ip + && privacy.language + && privacy.hardware + && !privacy.screen + && !privacy.timezone + && !privacy.userAgent + ) { return PRIVACY_DESCRIPTIONS.heatmap! - if (privacy.ip && !privacy.userAgent && !privacy.language && !privacy.screen && !privacy.timezone && !privacy.hardware) + } + if ( + privacy.ip + && !privacy.userAgent + && !privacy.language + && !privacy.screen + && !privacy.timezone + && !privacy.hardware + ) { return PRIVACY_DESCRIPTIONS['ip-only']! + } return PRIVACY_DESCRIPTIONS.none! } // -- Registry metadata (sync, importable by docs site) -- -function m(key: string, label: string, category: string, composableName: string | false, capabilities: ScriptCapabilities, privacy: ProxyPrivacyInput | null = null): RegistryScriptMeta { +function m( + key: string, + label: string, + category: string, + composableName: string | false, + capabilities: ScriptCapabilities, + privacy: ProxyPrivacyInput | null = null, +): RegistryScriptMeta { return { key, label, category, composableName, capabilities, privacy } } /** Static registry metadata for all scripts. Importable without async resolution. */ export const registryMeta: RegistryScriptMeta[] = [ // analytics - m('googleAnalytics', 'Google Analytics', 'analytics', 'useScriptGoogleAnalytics', { bundle: true, proxy: true, partytown: true }, PRIVACY_HEATMAP), - m('plausibleAnalytics', 'Plausible Analytics', 'analytics', 'useScriptPlausibleAnalytics', { bundle: true, proxy: true, partytown: true }, PRIVACY_IP_ONLY), - m('cloudflareWebAnalytics', 'Cloudflare Web Analytics', 'analytics', 'useScriptCloudflareWebAnalytics', { bundle: true, proxy: true, partytown: true }, PRIVACY_IP_ONLY), + m( + 'googleAnalytics', + 'Google Analytics', + 'analytics', + 'useScriptGoogleAnalytics', + { bundle: true, proxy: true, partytown: true }, + PRIVACY_HEATMAP, + ), + m( + 'plausibleAnalytics', + 'Plausible Analytics', + 'analytics', + 'useScriptPlausibleAnalytics', + { bundle: true, proxy: true, partytown: true }, + PRIVACY_IP_ONLY, + ), + m( + 'cloudflareWebAnalytics', + 'Cloudflare Web Analytics', + 'analytics', + 'useScriptCloudflareWebAnalytics', + { bundle: true, proxy: true, partytown: true }, + PRIVACY_IP_ONLY, + ), m('posthog', 'PostHog', 'analytics', 'useScriptPostHog', { proxy: true }, PRIVACY_IP_ONLY), - m('fathomAnalytics', 'Fathom Analytics', 'analytics', 'useScriptFathomAnalytics', { bundle: true, proxy: true, partytown: true }, PRIVACY_IP_ONLY), - m('matomoAnalytics', 'Matomo Analytics', 'analytics', 'useScriptMatomoAnalytics', { proxy: true, partytown: true }, PRIVACY_IP_ONLY), - m('rybbitAnalytics', 'Rybbit Analytics', 'analytics', 'useScriptRybbitAnalytics', { bundle: true, proxy: true }, PRIVACY_IP_ONLY), - m('databuddyAnalytics', 'Databuddy Analytics', 'analytics', 'useScriptDatabuddyAnalytics', { bundle: true, proxy: true }, PRIVACY_IP_ONLY), - m('umamiAnalytics', 'Umami Analytics', 'analytics', 'useScriptUmamiAnalytics', { bundle: true, proxy: true, partytown: true }, PRIVACY_IP_ONLY), + m( + 'fathomAnalytics', + 'Fathom Analytics', + 'analytics', + 'useScriptFathomAnalytics', + { bundle: true, proxy: true, partytown: true }, + PRIVACY_IP_ONLY, + ), + m( + 'matomoAnalytics', + 'Matomo Analytics', + 'analytics', + 'useScriptMatomoAnalytics', + { proxy: true, partytown: true }, + PRIVACY_IP_ONLY, + ), + m( + 'rybbitAnalytics', + 'Rybbit Analytics', + 'analytics', + 'useScriptRybbitAnalytics', + { bundle: true, proxy: true }, + PRIVACY_IP_ONLY, + ), + m( + 'databuddyAnalytics', + 'Databuddy Analytics', + 'analytics', + 'useScriptDatabuddyAnalytics', + { bundle: true, proxy: true }, + PRIVACY_IP_ONLY, + ), + m( + 'umamiAnalytics', + 'Umami Analytics', + 'analytics', + 'useScriptUmamiAnalytics', + { bundle: true, proxy: true, partytown: true }, + PRIVACY_IP_ONLY, + ), m('segment', 'Segment', 'analytics', 'useScriptSegment', { bundle: true, partytown: true }, null), m('hotjar', 'Hotjar', 'analytics', 'useScriptHotjar', { bundle: true, proxy: true }, PRIVACY_HEATMAP), - m('clarity', 'Clarity', 'analytics', 'useScriptClarity', { bundle: true, proxy: true, partytown: true }, PRIVACY_HEATMAP), - m('vercelAnalytics', 'Vercel Analytics', 'analytics', 'useScriptVercelAnalytics', { bundle: true, proxy: true }, PRIVACY_IP_ONLY), - m('mixpanelAnalytics', 'Mixpanel', 'analytics', 'useScriptMixpanelAnalytics', { bundle: true, partytown: true }, null), + m( + 'clarity', + 'Clarity', + 'analytics', + 'useScriptClarity', + { bundle: true, proxy: true, partytown: true }, + PRIVACY_HEATMAP, + ), + m( + 'vercelAnalytics', + 'Vercel Analytics', + 'analytics', + 'useScriptVercelAnalytics', + { bundle: true, proxy: true }, + PRIVACY_IP_ONLY, + ), + m( + 'mixpanelAnalytics', + 'Mixpanel', + 'analytics', + 'useScriptMixpanelAnalytics', + { bundle: true, partytown: true }, + null, + ), // ad m('bingUet', 'Bing UET', 'ad', 'useScriptBingUet', { bundle: true, partytown: true }, null), - m('metaPixel', 'Meta Pixel', 'ad', 'useScriptMetaPixel', { bundle: true, proxy: true, partytown: true }, PRIVACY_FULL), + m( + 'metaPixel', + 'Meta Pixel', + 'ad', + 'useScriptMetaPixel', + { bundle: true, proxy: true, partytown: true }, + PRIVACY_FULL, + ), m('xPixel', 'X Pixel', 'ad', 'useScriptXPixel', { bundle: true, proxy: true, partytown: true }, PRIVACY_FULL), - m('tiktokPixel', 'TikTok Pixel', 'ad', 'useScriptTikTokPixel', { bundle: true, proxy: true, partytown: true }, PRIVACY_FULL), - m('snapchatPixel', 'Snapchat Pixel', 'ad', 'useScriptSnapchatPixel', { bundle: true, proxy: true, partytown: true }, PRIVACY_FULL), - m('redditPixel', 'Reddit Pixel', 'ad', 'useScriptRedditPixel', { bundle: true, proxy: true, partytown: true }, PRIVACY_FULL), - m('googleAdsense', 'Google Adsense', 'ad', 'useScriptGoogleAdsense', { bundle: true, proxy: true }, PRIVACY_HEATMAP), + m( + 'tiktokPixel', + 'TikTok Pixel', + 'ad', + 'useScriptTikTokPixel', + { bundle: true, proxy: true, partytown: true }, + PRIVACY_FULL, + ), + m( + 'snapchatPixel', + 'Snapchat Pixel', + 'ad', + 'useScriptSnapchatPixel', + { bundle: true, proxy: true, partytown: true }, + PRIVACY_FULL, + ), + m( + 'redditPixel', + 'Reddit Pixel', + 'ad', + 'useScriptRedditPixel', + { bundle: true, proxy: true, partytown: true }, + PRIVACY_FULL, + ), + m( + 'googleAdsense', + 'Google Adsense', + 'ad', + 'useScriptGoogleAdsense', + { bundle: true, proxy: true }, + PRIVACY_HEATMAP, + ), m('carbonAds', 'Carbon Ads', 'ad', false, { proxy: true }, PRIVACY_IP_ONLY), // tag-manager m('googleTagManager', 'Google Tag Manager', 'tag-manager', 'useScriptGoogleTagManager', { bundle: true }, null), @@ -140,7 +321,14 @@ export const registryMeta: RegistryScriptMeta[] = [ m('lemonSqueezy', 'Lemon Squeezy', 'payments', 'useScriptLemonSqueezy', { proxy: true }, PRIVACY_IP_ONLY), m('paypal', 'PayPal', 'payments', 'useScriptPayPal', {}, null), // video - m('youtubePlayer', 'YouTube Player', 'video', 'useScriptYouTubePlayer', { bundle: true, proxy: true }, PRIVACY_IP_ONLY), + m( + 'youtubePlayer', + 'YouTube Player', + 'video', + 'useScriptYouTubePlayer', + { bundle: true, proxy: true }, + PRIVACY_IP_ONLY, + ), m('vimeoPlayer', 'Vimeo Player', 'video', 'useScriptVimeoPlayer', { bundle: true, proxy: true }, PRIVACY_IP_ONLY), // content m('googleMaps', 'Google Maps', 'content', 'useScriptGoogleMaps', {}, null), @@ -173,7 +361,10 @@ export const REGISTRY_CATEGORIES = [ // -- Helpers -- /** Get the resolved proxy definition, following aliases. */ -export function getProxyDef(script: RegistryScript, scriptByKey?: Map): ProxyCapability | undefined { +export function getProxyDef( + script: RegistryScript, + scriptByKey?: Map, +): ProxyCapability | undefined { if (!script.proxy) return undefined if (typeof script.proxy === 'string') @@ -273,8 +464,12 @@ export async function registry(resolve?: (path: string) => Promise): Pro resolve: (options?: PlausibleAnalyticsInput) => { if (options?.scriptId) return `https://plausible.io/js/pa-${options.scriptId}.js` - const extensions = Array.isArray(options?.extension) ? options.extension.join('.') : [options?.extension] - return options?.extension ? `https://plausible.io/js/script.${extensions}.js` : 'https://plausible.io/js/script.js' + const extensions = Array.isArray(options?.extension) + ? options.extension.join('.') + : [options?.extension] + return options?.extension + ? `https://plausible.io/js/script.${extensions}.js` + : 'https://plausible.io/js/script.js' }, }, proxy: { @@ -370,7 +565,14 @@ export async function registry(resolve?: (path: string) => Promise): Pro domains: ['app.rybbit.io'], privacy: PRIVACY_IP_ONLY, autoInject: { field: 'analyticsHost', target: 'app.rybbit.io/api' }, - sdkPatches: [{ type: 'replace-src-split', separator: '/script.js', fromDomain: 'app.rybbit.io', appendPath: 'api' }], + sdkPatches: [ + { + type: 'replace-src-split', + separator: '/script.js', + fromDomain: 'app.rybbit.io', + appendPath: 'api', + }, + ], }, }), def('databuddyAnalytics', { @@ -394,7 +596,11 @@ export async function registry(resolve?: (path: string) => Promise): Pro envDefaults: { writeKey: '' }, bundle: { resolve: (options?: SegmentInput) => { - return joinURL('https://cdn.segment.com/analytics.js/v1', options?.writeKey || '', 'analytics.min.js') + return joinURL( + 'https://cdn.segment.com/analytics.js/v1', + options?.writeKey || '', + 'analytics.min.js', + ) }, }, partytown: { forwards: ['analytics', 'analytics.track', 'analytics.page', 'analytics.identify'] }, @@ -411,7 +617,17 @@ export async function registry(resolve?: (path: string) => Promise): Pro return 'https://cdn.mxpnl.com/libs/mixpanel-2-latest.min.js' }, }, - partytown: { forwards: ['mixpanel', 'mixpanel.init', 'mixpanel.track', 'mixpanel.identify', 'mixpanel.people.set', 'mixpanel.reset', 'mixpanel.register'] }, + partytown: { + forwards: [ + 'mixpanel', + 'mixpanel.init', + 'mixpanel.track', + 'mixpanel.identify', + 'mixpanel.people.set', + 'mixpanel.reset', + 'mixpanel.register', + ], + }, }), // ad def('bingUet', { @@ -459,7 +675,10 @@ export async function registry(resolve?: (path: string) => Promise): Pro resolve(options?: TikTokPixelInput) { if (!options?.id) return false - return withQuery('https://analytics.tiktok.com/i18n/pixel/events.js', { sdkid: options.id, lib: 'ttq' }) + return withQuery('https://analytics.tiktok.com/i18n/pixel/events.js', { + sdkid: options.id, + lib: 'ttq', + }) }, }, proxy: { @@ -503,7 +722,9 @@ export async function registry(resolve?: (path: string) => Promise): Pro resolve: (options?: GoogleAdsenseInput) => { if (!options?.client) return false - return withQuery('https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js', { client: options?.client }) + return withQuery('https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js', { + client: options?.client, + }) }, }, proxy: 'googleAnalytics', @@ -531,7 +752,15 @@ export async function registry(resolve?: (path: string) => Promise): Pro }, }, proxy: { - domains: ['widget.intercom.io', 'api-iam.intercom.io', 'api-iam.eu.intercom.io', 'api-iam.au.intercom.io', 'js.intercomcdn.com', 'downloads.intercomcdn.com', 'video-messages.intercomcdn.com'], + domains: [ + 'widget.intercom.io', + 'api-iam.intercom.io', + 'api-iam.eu.intercom.io', + 'api-iam.au.intercom.io', + 'js.intercomcdn.com', + 'downloads.intercomcdn.com', + 'video-messages.intercomcdn.com', + ], privacy: PRIVACY_IP_ONLY, }, }), @@ -544,11 +773,26 @@ export async function registry(resolve?: (path: string) => Promise): Pro resolve(options?: HotjarInput) { if (!options?.id) return false - return withQuery(`https://static.hotjar.com/c/hotjar-${options?.id || ''}.js`, { sv: options?.sv || '6' }) + return withQuery(`https://static.hotjar.com/c/hotjar-${options?.id || ''}.js`, { + sv: options?.sv || '6', + }) }, }, proxy: { - domains: ['static.hotjar.com', 'script.hotjar.com', 'vars.hotjar.com', 'in.hotjar.com', 'vc.hotjar.com', 'vc.hotjar.io', 'metrics.hotjar.io', 'insights.hotjar.com', 'ask.hotjar.io', 'events.hotjar.io', 'identify.hotjar.com', 'surveystats.hotjar.io'], + domains: [ + 'static.hotjar.com', + 'script.hotjar.com', + 'vars.hotjar.com', + 'in.hotjar.com', + 'vc.hotjar.com', + 'vc.hotjar.io', + 'metrics.hotjar.io', + 'insights.hotjar.com', + 'ask.hotjar.io', + 'events.hotjar.io', + 'identify.hotjar.com', + 'surveystats.hotjar.io', + ], privacy: PRIVACY_HEATMAP, }, }), @@ -669,7 +913,10 @@ export async function registry(resolve?: (path: string) => Promise): Pro category: 'cdn', bundle: { resolve(options?: NpmInput) { - return withBase(options?.file || '', `https://unpkg.com/${options?.packageName || ''}@${options?.version || 'latest'}`) + return withBase( + options?.file || '', + `https://unpkg.com/${options?.packageName || ''}@${options?.version || 'latest'}`, + ) }, }, }), @@ -724,7 +971,16 @@ export async function registry(resolve?: (path: string) => Promise): Pro }, }, proxy: { - domains: ['www.google-analytics.com', 'analytics.google.com', 'stats.g.doubleclick.net', 'pagead2.googlesyndication.com', 'www.googleadservices.com', 'googleads.g.doubleclick.net'], + domains: [ + 'www.google-analytics.com', + 'analytics.google.com', + 'stats.g.doubleclick.net', + 'pagead2.googlesyndication.com', + 'www.googleadservices.com', + 'googleads.g.doubleclick.net', + 'www.google.com', + 'www.googletagmanager.com', + ], privacy: PRIVACY_HEATMAP, }, partytown: { forwards: ['dataLayer.push', 'gtag'] }, @@ -754,9 +1010,7 @@ export async function registry(resolve?: (path: string) => Promise): Pro domains: ['secure.gravatar.com', 'gravatar.com'], privacy: PRIVACY_IP_ONLY, }, - serverHandlers: [ - { route: '/_scripts/proxy/gravatar', handler: './runtime/server/gravatar-proxy' }, - ], + serverHandlers: [{ route: '/_scripts/proxy/gravatar', handler: './runtime/server/gravatar-proxy' }], }), ]) } @@ -786,10 +1040,7 @@ export function generatePartytownResolveUrl(proxyPrefix: string): string { * 4. Clamp to ceiling (user can't enable unsupported capabilities) * 5. Warn in dev if user tries to exceed ceiling */ -export function resolveCapabilities( - script: RegistryScript, - scriptOptions?: Record, -): ScriptCapabilities { +export function resolveCapabilities(script: RegistryScript, scriptOptions?: Record): ScriptCapabilities { const ceiling = getCapabilities(script) // Default: all capabilities enabled except partytown (requires opt-in) const defaults: ScriptCapabilities = { ...ceiling, partytown: false } @@ -855,8 +1106,15 @@ export function buildProxyConfigsFromRegistry( continue configs[script.registryKey] = { - domains: proxyDef.domains.map(d => typeof d === 'string' ? d : d.domain), - privacy: proxyDef.privacy || { ip: false, userAgent: false, language: false, screen: false, timezone: false, hardware: false }, + domains: proxyDef.domains.map(d => (typeof d === 'string' ? d : d.domain)), + privacy: proxyDef.privacy || { + ip: false, + userAgent: false, + language: false, + screen: false, + timezone: false, + hardware: false, + }, autoInject: proxyDef.autoInject ? resolveAutoInject(proxyDef.autoInject) : undefined, sdkPatches: proxyDef.sdkPatches, }