33 Commits
v0.0.2 ... main

Author SHA1 Message Date
Elijah Duffy
354a2ac739 0.1.1 2025-12-24 12:02:01 -08:00
Elijah Duffy
ac3aabc1ed improve logs & capi event source URL handling 2025-12-24 12:01:50 -08:00
Elijah Duffy
2daf2eabfb pixel: include event source url when sending to CAPI 2025-12-24 10:55:53 -08:00
Elijah Duffy
e3c880c17b 0.1.0 2025-12-22 16:57:46 -08:00
Elijah Duffy
01fd81f911 tracking: add onceLoaded callback 2025-12-22 16:57:20 -08:00
Elijah Duffy
af1b66649b fix capi response types, improve log clarity 2025-12-22 16:57:06 -08:00
Elijah Duffy
93e5c35d86 fix util/ip exports 2025-12-21 21:17:28 -08:00
Elijah Duffy
90a2063ad3 rename lib/conversion to lib/capi; fix exports 2025-12-21 21:12:24 -08:00
Elijah Duffy
b6e0830209 refactor conversion API to avoid facebook-nodejs-business-sdk
uses direct meta graph endpoints instead.
2025-12-21 21:10:40 -08:00
Elijah Duffy
a9a9f5ed30 0.0.6 2025-12-18 22:08:31 -08:00
Elijah Duffy
0e1a449cb6 conversion: more robust fbp, fbc with header-based attempt 2025-12-18 22:06:23 -08:00
Elijah Duffy
764da5db2e conversion: make event ID optional 2025-12-18 22:04:32 -08:00
Elijah Duffy
6692338b83 pixel: allow disabling conversion API forwarding 2025-12-18 21:27:44 -08:00
Elijah Duffy
3561012fb9 pixel: fix $state usage, fix load ordering 2025-12-18 21:22:07 -08:00
Elijah Duffy
b26f6160f8 set log level to warn in production 2025-12-18 21:21:46 -08:00
Elijah Duffy
1bb202ffa5 pixel: rename pixel-control to svelte file 2025-12-18 19:24:28 -08:00
Elijah Duffy
0cd3f10da6 pixel: add ensure fbc fallback 2025-12-18 19:22:54 -08:00
Elijah Duffy
f2d389ee64 improve logging consistency 2025-12-18 19:03:27 -08:00
Elijah Duffy
f5ec7b3812 conversion: explicit event params, removes any in PixelControl 2025-12-18 18:44:33 -08:00
Elijah Duffy
fc07bb057c pixel: refactor to separate files 2025-12-18 17:20:05 -08:00
Elijah Duffy
9400e81aaa pixel: integrate conversion API 2025-12-18 17:12:57 -08:00
Elijah Duffy
824fd262ed conversion: fix exports and strengthen IP extraction 2025-12-18 17:12:47 -08:00
Elijah Duffy
82cce84a4e capi: proper dev mode handling requiring test event code 2025-12-18 13:07:39 -08:00
Elijah Duffy
2e12d281ef conversion api wrapper 2025-12-18 12:56:49 -08:00
Elijah Duffy
99c1f003c6 0.0.5 2025-12-18 10:33:25 -08:00
Elijah Duffy
97497db8e4 pixel: don't send events in dev mode without a test code 2025-12-18 10:33:17 -08:00
Elijah Duffy
674663b027 0.0.4 2025-12-18 10:23:17 -08:00
Elijah Duffy
e7b12f50b1 pixel: improve fetching existing PixelControls 2025-12-18 10:23:12 -08:00
Elijah Duffy
927b02d30e 0.0.3 2025-12-17 22:19:25 -08:00
Elijah Duffy
45a6bda53e bump required svelte version 2025-12-17 22:19:11 -08:00
Elijah Duffy
9a72280737 meta pixel: default to disablePushState = true
Breaks SvelteKit SPA which doesn't allow use of history API, requiring
its own wrapper to be used instead.
2025-12-17 22:19:05 -08:00
Elijah Duffy
095462c80d tracking manager: add localStorage persistence 2025-12-17 22:18:29 -08:00
Elijah Duffy
bb92e25485 meta pixel: more robust loading & graceful failure with adblockers 2025-12-16 21:04:32 -08:00
18 changed files with 1438 additions and 264 deletions

View File

@@ -4,7 +4,7 @@
"type": "git",
"url": "https://gitea.auvem.com/svelte-toolkit/spectator.git"
},
"version": "0.0.2",
"version": "0.1.1",
"license": "MIT",
"scripts": {
"dev": "vite dev",
@@ -36,7 +36,7 @@
},
"peerDependencies": {
"@sveltejs/kit": "^2.0.0",
"svelte": "^5.0.0"
"svelte": "^5.40.0"
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
@@ -62,8 +62,11 @@
"svelte"
],
"dependencies": {
"@types/facebook-nodejs-business-sdk": "^23.0.0",
"@types/umami": "^2.10.1",
"loglevel": "^1.9.2"
"http-status-codes": "^2.3.0",
"loglevel": "^1.9.2",
"valibot": "^1.2.0"
},
"publishConfig": {
"registry": "https://gitea.auvem.com/api/packages/svelte-toolkit/npm/"

31
pnpm-lock.yaml generated
View File

@@ -8,12 +8,21 @@ importers:
.:
dependencies:
'@types/facebook-nodejs-business-sdk':
specifier: ^23.0.0
version: 23.0.0
'@types/umami':
specifier: ^2.10.1
version: 2.10.1
http-status-codes:
specifier: ^2.3.0
version: 2.3.0
loglevel:
specifier: ^1.9.2
version: 1.9.2
valibot:
specifier: ^1.2.0
version: 1.2.0(typescript@5.9.3)
devDependencies:
'@eslint/compat':
specifier: ^1.4.0
@@ -473,6 +482,9 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/facebook-nodejs-business-sdk@23.0.0':
resolution: {integrity: sha512-k9saLiTd4kvBCdBHEpTyDMYlqSsiDw0yHs2ePe5M1PTIldtdtNOGx73Gtu8dQqbTAlwBWbiG3VfI+9s45Cf88w==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@@ -770,6 +782,9 @@ packages:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
http-status-codes@2.3.0:
resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@@ -1086,6 +1101,14 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
valibot@1.2.0:
resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==}
peerDependencies:
typescript: '>=5'
peerDependenciesMeta:
typescript:
optional: true
vite@7.3.0:
resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1447,6 +1470,8 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/facebook-nodejs-business-sdk@23.0.0': {}
'@types/json-schema@7.0.15': {}
'@types/node@24.10.4':
@@ -1791,6 +1816,8 @@ snapshots:
has-flag@4.0.0: {}
http-status-codes@2.3.0: {}
ignore@5.3.2: {}
ignore@7.0.5: {}
@@ -2094,6 +2121,10 @@ snapshots:
util-deprecate@1.0.2: {}
valibot@1.2.0(typescript@5.9.3):
optionalDependencies:
typescript: 5.9.3
vite@7.3.0(@types/node@24.10.4):
dependencies:
esbuild: 0.27.1

View File

@@ -1,221 +0,0 @@
<script lang="ts" module>
export class PixelControl {
private _pixelID: string;
private _testEventCode?: string = undefined;
private _trackingManager: MaybeGetter<TrackingManager | undefined>;
private static _baseLoaded: boolean = false;
static get baseLoaded(): boolean {
return this._baseLoaded;
}
/**
* Ensures that the Meta Pixel base has been loaded before
* allowing further operations.
* @throws Error if the Meta Pixel API is not loaded.
*/
static loadGuard(): void {
if (!this._baseLoaded || !window._fbq) {
throw new Error('Meta Pixel API has not been loaded. Call PixelControl.load() first.');
}
}
private constructor(
trackingManager: MaybeGetter<TrackingManager | undefined>,
pixelID: string,
testEventCode?: string
) {
this._trackingManager = trackingManager;
this._pixelID = pixelID;
this._testEventCode = testEventCode;
}
/** Loads the Meta Pixel base script. */
static load() {
if (this._baseLoaded && window._fbq) return;
if (!window._fbq) {
PixelControl.revokeConsent(); // Initialize without consent
loadMetaPixel(); // Load the Meta Pixel script
}
this._baseLoaded = true;
log.debug('Meta Pixel base script loaded.');
}
/** Tells the Meta pixel that the user has given consent for tracking. */
static grantConsent() {
this.loadGuard();
window.fbq?.('consent', 'grant');
log.debug('Meta Pixel consent granted.');
}
/** Tells the Meta pixel that the user has revoked consent for tracking. */
static revokeConsent() {
this.loadGuard();
window.fbq?.('consent', 'revoke');
log.debug('Meta Pixel consent revoked.');
}
/**
* Returns a PixelControl instance for the given Meta Pixel ID. If
* the base Meta Pixel script has not been loaded yet, it will be
* loaded automatically. Optionally sets a test event code for the Pixel.
* Does NOT initialize the Pixel; call `fireInit()` on the returned instance
* before tracking events.
* @param trackingManager Tracking manager to handle user consent for tracking
* @param pixelID Meta Pixel ID
* @param options Optional settings
* @returns PixelControl instance
*/
static for(
trackingManager: MaybeGetter<TrackingManager | undefined>,
pixelID: string,
options?: {
/**
* if provided, events fired will always have this code attached
* to prevent them from polluting real analytics data.
*/
testEventCode?: string;
}
): PixelControl {
PixelControl.load();
return new PixelControl(trackingManager, pixelID, options?.testEventCode);
}
/**
* Initializes this pixel with the Meta Pixel API including any advanced
* matching data and options.
* @param advancedMatching Advanced matching data
* @param initOptions Initialization options
* @returns this PixelControl instance
*/
fireInit(advancedMatching?: AdvancedMatching, initOptions?: InitOptions): PixelControl {
PixelControl.loadGuard();
window.fbq('init', this._pixelID, advancedMatching, initOptions);
log.debug(`Meta Pixel [${this._pixelID}] initialized.`);
return this;
}
/**
* Checks if the Meta Pixel has consent to track user data
* and if the Pixel has been loaded.
* @returns true if tracking is allowed, false otherwise.
* @throws Error if the Meta Pixel is not loaded.
*/
consentGuard(): boolean {
PixelControl.loadGuard();
const trackingManager = resolveGetter(this._trackingManager);
return trackingManager?.haveUserConsent() ?? false;
}
/**
* Sends a PageView event
* @throws Error if the Meta Pixel is not initialized.
*/
pageView() {
if (!this.consentGuard()) return;
window.fbq('track', 'PageView', undefined, { test_event_code: this._testEventCode });
log.debug(
`Meta Pixel [${this._pixelID}] PageView event sent (test code: ${this._testEventCode}).`
);
}
/**
* Tracks a standard event for this pixel (uses `trackSingle` under the hood)
* @throws Error if the Meta Pixel is not initialized.
*/
track<K extends StandardEventName>(event: K, params?: EventParamsByName[K], eventID?: string) {
if (!this.consentGuard()) return;
window.fbq('trackSingle', this._pixelID, event, params, {
eventID,
test_event_code: this._testEventCode
});
log.debug(
`Meta Pixel [${this._pixelID}] ${event} event sent (test code: ${this._testEventCode}).`
);
}
/**
* Tracks a custom event for this pixel (uses `trackSingleCustom` under the hood)
* @throws Error if the Meta Pixel is not initialized.
*/
trackCustom(event: string, params?: CommonParams & CustomParams, eventID?: string) {
if (!this.consentGuard()) return;
window.fbq('trackSingleCustom', this._pixelID, event, params, {
eventID,
test_event_code: this._testEventCode
});
log.debug(
`Meta Pixel [${this._pixelID}] ${event} custom event sent (test code: ${this._testEventCode}).`
);
}
}
</script>
<script lang="ts">
import { onMount } from 'svelte';
import type { TrackingManager } from './tracking.svelte.ts';
import type {
EventParamsByName,
StandardEventName,
CommonParams,
CustomParams,
AdvancedMatching,
InitOptions
} from './types/fbq.js';
import { loadMetaPixel } from './util/meta-pixel-loader.ts';
import { onNavigate } from '$app/navigation';
import { resolveGetter, type MaybeGetter } from './util/getter.ts';
import log from 'loglevel';
interface Props {
/** Meta Pixel ID */
pixelID: string;
/**
* If a test event code is available, events fired will always have this
* code attached to prevent them from polluting real analytics data.
*/
testEventCode?: string;
/**
* Controls whether page views are automatically tracked by this
* component (default: true).
*/
autoPageView?: boolean;
/**
* Tracking manager to handle user consent for tracking. If omitted
* tracking is disabled by default until consent is granted via
* PixelControl.grantConsent().
*/
trackingManager?: TrackingManager;
}
let { pixelID, testEventCode, autoPageView = true, trackingManager }: Props = $props();
let pixel = $state<PixelControl | null>(null);
onMount(() => {
if (!trackingManager) {
throw new Error('MetaPixel component requires a TrackingManager to manage consent.');
}
PixelControl.load();
pixel = PixelControl.for(trackingManager, pixelID, { testEventCode }).fireInit();
trackingManager.runWithConsent(() => {
if (autoPageView && pixel) {
pixel.pageView();
}
});
});
onNavigate(() => {
trackingManager?.runWithConsent(() => {
if (autoPageView && pixel) {
pixel.pageView();
}
});
});
</script>

View File

@@ -25,7 +25,9 @@
const consentGranted = $derived(trackingManager ? trackingManager.consent === true : true);
// Development overrides to prevent dirty analytics
const devConsoleTag = $derived(`[dev][consent: ${consentGranted ? 'granted' : 'revoked'}]`);
const devConsoleTag = $derived(
`[Umami] [dev][consent: ${consentGranted ? 'granted' : 'revoked'}]`
);
const devOverride = {
track: (...args: unknown[]): Promise<string> | undefined => {
log.debug(`${devConsoleTag}: Track called with:`, ...args);
@@ -62,7 +64,7 @@
}
});
if (dev) log.debug('[dev]: Umami tracking disabled');
if (dev) log.info('[Umami] [dev]: reporting disabled');
onMount(() => {
if (dev) {

101
src/lib/capi/client.ts Normal file
View File

@@ -0,0 +1,101 @@
import { getFbpFbc } from '../metapixel/fbc.ts';
import type { TrackingManager } from '$lib/tracking.svelte';
import {
capiErrorBodySchema,
capiResponseBodySchema,
type CAPIErrorBody,
type CAPIRequestBody,
type CAPIResponseBody
} from './handle.ts';
import type { CAPICustomerInfoParams, CAPIEvent } from './event.ts';
import * as v from 'valibot';
import { dev } from '$app/environment';
/**
* Client abstracts HTTP communication with a spectator server endpoint for
* sending conversion events to Meta Conversions API.
*/
export class CAPIClient {
private _href: string;
private _trackingManager: TrackingManager;
/**
* Creates a new CAPIClient.
*
* @param serverHref - The spectator server endpoint URL.
* @param trackingManager - The tracking manager instance, used for consent status.
*/
constructor(serverHref: string, trackingManager: TrackingManager) {
this._href = serverHref;
this._trackingManager = trackingManager;
}
/**
* Sends CAPIEvents to the server endpoint. If no consent is given, no events
* are sent and a dummy response is returned.
* @param events - The array of CAPIEvents to send.
* @returns A promise that resolves to the server response.
* @throws Will throw an error if input or response shape validation fails.
*/
async sendEvents(events: CAPIEvent[]): Promise<CAPIResponseBody | CAPIErrorBody> {
// Respond with an empty response if consent is not given
if (!this._trackingManager.haveUserConsent()) {
if (dev) {
console.warn(`[CAPIClient] Consent not given. Skipping sending ${events.length} event(s).`);
}
return {
fbtrace_id: '',
events_received: 0,
messages: []
};
}
// Attempt to build enriched user data
const { fbp, fbc } = getFbpFbc();
const enrichedUserData: Partial<CAPICustomerInfoParams> = { fbp, fbc };
// Build request body
const body: CAPIRequestBody = {
events: events.map((e) => {
e.enrichUserData(enrichedUserData);
return e.toObject();
})
};
try {
v.parse(v.object({ events: v.array(v.any()) }), body); // Validate body shape
} catch (err) {
throw new Error(`[CAPIClient] Invalid request body shape: ${(err as Error).message}`);
}
const response = await fetch(this._href, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
const json = await response.json();
try {
if (response.ok) {
const parsed = v.parse(capiResponseBodySchema, json);
return parsed as CAPIResponseBody;
} else {
const parsed = v.parse(capiErrorBodySchema, json);
return parsed as CAPIErrorBody;
}
} catch (err) {
throw new Error(`[CAPIClient] Invalid response shape: ${(err as Error).message}`);
}
}
/**
* Shorthand for sending a single CAPIEvent to the server endpoint.
* @param event - The CAPIEvent to send.
* @returns A promise that resolves to the server response.
* @throws Will throw an error if input or response shape validation fails.
*/
async trackEvent(event: CAPIEvent): Promise<CAPIResponseBody | CAPIErrorBody> {
return this.sendEvents([event]);
}
}

93
src/lib/capi/connector.ts Normal file
View File

@@ -0,0 +1,93 @@
import { dev } from '$app/environment';
import log from 'loglevel';
import type { CAPIEvent } from './event.ts';
import * as v from 'valibot';
import { capiResponseBodySchema, type CAPIResponseBody } from './handle.ts';
const GRAPH_VERSION = 'v24.0';
/**
* Connector class for Meta Conversion API (CAPI). Abstraction over direct HTTP
* requests to Meta's CAPI endpoint.
*
* See https://developers.facebook.com/docs/marketing-api/conversions-api/get-started
* for more information.
*/
export class CAPIConnector {
private _accessToken: string;
private _pixelID: string;
private _testEventCode?: string;
/**
* Creates a new MCAPIControl instance.
*
* @param accessToken - Your Meta Pixel Conversion API access token.
* @param pixelID - Your Meta Pixel ID.
* @param testEventCode - Optional test event code used for all events if provided.
*/
constructor(accessToken: string, pixelID: string, testEventCode?: string) {
this._accessToken = accessToken;
this._pixelID = pixelID;
this._testEventCode = testEventCode;
}
/**
* Sends conversion events to the Meta Conversion API.
*
* @param events - Array of CAPIEvent instances to send.
* @returns The response from the Meta CAPI.
* @throws Will throw an error if the request fails or the API returns an error.
*/
async sendEvents(events: CAPIEvent[]): Promise<CAPIResponseBody> {
if (dev && !this._testEventCode) {
log.warn(
`[CAPIConnector] Sending ${events.length} event(s) in dev mode without a test event code. ` +
'Consider providing a test event code to avoid affecting real data.'
);
}
const url = `https://graph.facebook.com/${GRAPH_VERSION}/${this._pixelID}/events`;
const body = {
data: events.map((e) => e.toObject()),
test_event_code: this._testEventCode
};
log.debug(
`[CAPIConnector] [${this._pixelID}] Sending ${events.length} event(s) to Meta CAPI at ${url} with body: ${JSON.stringify(body, null, 2)}`
);
const resp = await fetch(`${url}?access_token=${this._accessToken}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
const json = await resp.json();
if (!resp.ok) {
throw new Error(`Meta CAPI error ${resp.status}: ${JSON.stringify(json, null, 2)}`);
}
try {
const parsed = v.parse(capiResponseBodySchema, json);
log.info(
`[CAPIConnector] [${this._pixelID}] Successfully sent ${events.length} event(s) to Meta CAPI.`
);
return parsed as CAPIResponseBody;
} catch (err) {
throw new Error(`[CAPIConnector] Invalid response shape: ${(err as Error).message}`);
}
}
/**
* Shorthand for sending a single event to the Meta Conversion API.
*
* @param event - The CAPIEvent instance to send.
* @returns The response from the Meta CAPI.
* @throws Will throw an error if the request fails or the API returns an error.
*/
async trackEvent(event: CAPIEvent): Promise<CAPIResponseBody> {
return this.sendEvents([event]);
}
}

382
src/lib/capi/event.ts Normal file
View File

@@ -0,0 +1,382 @@
import type { StandardEventName } from '../types/fbq.js';
import * as v from 'valibot';
import crypto from 'node:crypto';
import log from 'loglevel';
const sha256 = (value: string): string => {
return crypto.createHash('sha256').update(value).digest('hex');
};
const normPhone = (s?: string | null) => {
// E.164-ish: keep digits, include leading + if present
if (!s) return undefined;
const plus = s.trim().startsWith('+');
const digits = s.replace(/[^\d]/g, '');
return plus ? `+${digits}` : digits;
};
const trimPunctuation = (s: string) => {
return s.replace(/^[\p{P}\p{S}]+|[\p{P}\p{S}]+$/gu, '');
};
/**
* Supported user data fields for conversion events.
*
* See https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/customer-information-parameters
* for an exhaustive list of Facebook's supported user data parameters. Mappings
* to Meta's expected keys are handled under the hood by CAPIEvent.
*/
export type CAPICustomerInfoParams = {
/** Email address (maps to `em`). */
email?: string;
/** Phone number (maps to `ph`). */
phone?: string;
/** First name (maps to `fn`). */
firstName?: string;
/** Last name (maps to `ln`). */
lastName?: string;
/** Client IP address (maps to `client_ip_address`). */
clientIP?: string;
/** Client user agent (maps to `client_user_agent`). */
clientUserAgent: string;
/** Facebook browser pixel ID (_fbp cookie). */
fbp?: string;
/** Facebook click ID (_fbc cookie). */
fbc?: string;
/** External ID (maps to `external_id`). */
externalID?: string;
};
/** valibot schema for meta's customer information parameters */
const metaCustomerInfoParamsSchema = v.object({
em: v.optional(v.string()),
ph: v.optional(v.string()),
fn: v.optional(v.string()),
ln: v.optional(v.string()),
client_ip_address: v.optional(v.string()),
client_user_agent: v.string(),
fbp: v.optional(v.string()),
fbc: v.optional(v.string()),
external_id: v.optional(v.string())
});
/** Meta's customer information parameters with annoyingly abbreviated keys. */
type meta_customerInfoParams = v.InferOutput<typeof metaCustomerInfoParamsSchema>;
/** Maps our customer information parameters to Meta's abbreviated keys. */
const _meta_customInfoParams_Map: Record<
keyof CAPICustomerInfoParams,
keyof meta_customerInfoParams
> = {
email: 'em',
phone: 'ph',
firstName: 'fn',
lastName: 'ln',
clientIP: 'client_ip_address',
clientUserAgent: 'client_user_agent',
fbp: 'fbp',
fbc: 'fbc',
externalID: 'external_id'
};
/**
* Maps CAPICustomerInfoParams to meta_customerInfoParams by transforming keys.
* Transforms values as needed (e.g. hashing email/phone).
*
* WARNING: This function is unsafe and does not perform shape validation; ensure
* input shape is valid before consuming.
*
* @param data - The CAPICustomerInfoParams to map.
* @returns The mapped meta_customerInfoParams.
*/
const mapCustomerInfoToMeta = (data: Partial<CAPICustomerInfoParams>) => {
const dst = {} as meta_customerInfoParams; // unsafe
for (const key in data) {
const shortKey = _meta_customInfoParams_Map[key as keyof CAPICustomerInfoParams];
if (shortKey && data[key as keyof CAPICustomerInfoParams]) {
dst[shortKey] = data[key as keyof CAPICustomerInfoParams] as string;
}
}
// Transform values as needed
if (dst.em) {
dst.em = sha256(dst.em.trim().toLowerCase());
}
if (dst.ph) {
const normed = normPhone(dst.ph);
dst.ph = normed ? sha256(normed) : normed;
}
if (dst.fn) {
dst.fn = sha256(trimPunctuation(dst.fn.trim().toLowerCase()));
}
if (dst.ln) {
dst.ln = sha256(trimPunctuation(dst.ln.trim().toLowerCase()));
}
return dst;
};
/** valibot schema for standard parameters / custom data */
const standardParamsSchema = v.object({
value: v.optional(v.number()),
net_revenue: v.optional(v.number()),
currency: v.optional(v.string()),
content_name: v.optional(v.string()),
content_category: v.optional(v.string()),
content_ids: v.optional(v.array(v.string())),
contents: v.optional(
v.array(
v.object({
id: v.optional(v.string()),
quantity: v.optional(v.number()),
item_price: v.optional(v.number()),
title: v.optional(v.string()),
description: v.optional(v.string()),
category: v.optional(v.string()),
brand: v.optional(v.string()),
delivery_category: v.optional(v.string())
})
)
),
content_type: v.optional(v.string()),
order_id: v.optional(v.string()),
predicted_ltv: v.optional(v.number()),
num_items: v.optional(v.number()),
search_string: v.optional(v.string()),
status: v.optional(v.string()),
item_number: v.optional(v.string()),
delivery_category: v.optional(v.string()),
custom_properties: v.optional(v.record(v.string(), v.unknown()))
});
/**
* Supported standard data fields typically used as custom data parameters
* for CAPI/Pixel events.
*
* See https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/custom-data
* for an exhaustive list of Facebook's supported standard event parameters.
*/
export type CAPIStandardParams = v.InferOutput<typeof standardParamsSchema>;
/** Supported action sources for conversion events. */
export enum ActionSource {
Email = 'email',
Website = 'website',
App = 'app',
PhoneCall = 'phone_call',
Chat = 'chat',
PhysicalStore = 'physical_store',
SystemGenerated = 'system_generated',
BusinessMessaging = 'business_messaging',
Other = 'other'
}
/** valibot schema for server event parameters, sent directly as request */
export const MetaServerEventParamsSchema = v.object({
event_name: v.string(),
event_time: v.number(),
user_data: metaCustomerInfoParamsSchema,
custom_data: v.optional(standardParamsSchema),
event_source_url: v.optional(v.string()),
opt_out: v.optional(v.boolean()),
event_id: v.optional(v.string()),
action_source: v.enum(ActionSource)
});
/**
* Internal type representing validated server event parameters, suitable to
* POST directly to the Meta Conversions API.
*
* See https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/server-event
* for an exhaustive list of Facebook's supported server event parameters.
*/
export type MetaServerEventParams = v.InferOutput<typeof MetaServerEventParamsSchema>;
/**
* Options for creating a CAPIEvent. All parameters are validated and
* transformed to Meta's expected keys by CAPIEvent.
*/
export type CAPIEventOptions = {
eventName: StandardEventName | string;
/** Will be set to the current date and time if not provided */
eventTime?: Date;
userData: CAPICustomerInfoParams;
customData?: CAPIStandardParams;
/**
* Required by Meta if actionSource is set to 'website'. If not provided and
* actionSource is 'website', a warning will be emitted and the field may be
* filled by the server receiving the event, however, accuracy is not guaranteed.
*/
eventSourceURL?: string;
optOut?: boolean;
eventID?: string;
actionSource: ActionSource;
};
/**
* Represents a Meta Conversions API event with properly mapped parameters
* and input validation.
*/
export class CAPIEvent {
private _params?: MetaServerEventParams;
/** Returns Meta-compliant server event object. */
get params() {
return this._params;
}
/** Returns human-readable event name, optionally including event ID. */
get readableName() {
if (!this._params) return 'Unknown Event';
return `${this._params.event_name}${this._params.event_id ? ` (${this._params.event_id})` : ''}`;
}
private constructor() {}
/**
* Creates a new CAPIEvent instance with the given options and generates a
* Meta-compliant event object.
*
* @param opts - The options for creating the CAPIEvent.
* @return The CAPIEvent instance.
* @throws Will throw an error if option shape validation fails.
*/
static fromOpts(opts: CAPIEventOptions) {
const event = new CAPIEvent();
// Transform our customer info params to Meta's expected keys
const meta_customerInfo = mapCustomerInfoToMeta(opts.userData);
// Build event params & validate
event._params = {
event_name: opts.eventName,
event_time: Math.floor((opts.eventTime ?? new Date()).getTime() / 1000),
user_data: meta_customerInfo,
custom_data: opts.customData,
event_source_url: opts.eventSourceURL,
opt_out: opts.optOut,
event_id: opts.eventID,
action_source: opts.actionSource
};
event._params = v.parse(MetaServerEventParamsSchema, event._params);
event.paramIntegrationCheck();
return event;
}
/**
* Performs integration checks on the event parameters and logs warnings
* if potential issues are detected.
*/
private paramIntegrationCheck() {
if (
this._params &&
this._params.action_source === ActionSource.Website &&
!this._params.event_source_url
) {
console.warn(
`[CAPIEvent] Warning: ${this.readableName} ` +
`with actionSource 'website' is missing eventSourceURL. Provide eventSourceURL to improve ` +
`data quality and avoid blocks by Meta.`
);
}
}
/**
* Unmashals a JSON string or object into a CAPIEvent instance and parses
* its parameters to match Meta's expected shape.
*
* @param src - The JSON string to unmarshal.
* @returns The CAPIEvent instance.
* @throws Will throw an error if the JSON is invalid or fails validation.
*/
private static fromSrc(src: string | object): CAPIEvent {
const obj = typeof src === 'string' ? JSON.parse(src) : src;
const parsed = v.parse(MetaServerEventParamsSchema, obj);
const event = new CAPIEvent();
event._params = parsed;
event.paramIntegrationCheck();
return event;
}
/**
* Unmarshals a JSON string into a CAPIEvent instance and parses its
* parameters to match Meta's expected shape.
*
* @param str - The JSON string to unmarshal.
* @returns The CAPIEvent instance.
* @throws Will throw an error if the JSON is invalid or fails validation.
*/
static fromJSON(str: string): CAPIEvent {
return CAPIEvent.fromSrc(str);
}
/**
* Unmarshals a plain object into a CAPIEvent instance and parses its
* parameters to match Meta's expected shape.
*
* @param obj - The object to unmarshal.
* @returns The CAPIEvent instance.
* @throws Will throw an error if the object fails validation.
*/
static fromObject(obj: object): CAPIEvent {
return CAPIEvent.fromSrc(obj);
}
/**
* Marshals the CAPIEvent to a JSON string suitable for sending to the Meta CAPI.
* @returns The JSON string representation of the CAPIEvent.
*/
toJSON(): string {
return JSON.stringify(this._params);
}
/**
* Marshals the CAPIEvent to a plain object suitable for sending to the Meta CAPI.
* @returns The object representation of the CAPIEvent.
*/
toObject(): MetaServerEventParams {
return this._params as MetaServerEventParams;
}
/**
* Enriches the CAPIEvent with additional user data. Will not override
* existing user data fields if already set.
* @param additionalData - Additional user data to merge.
* @throws Will throw an error if the enriched data fails validation.
*/
enrichUserData(additionalData: Partial<CAPICustomerInfoParams>) {
if (!this._params) return;
const additionalMetaData = mapCustomerInfoToMeta(additionalData);
// Merge additional data, only overwriting if no previous value exists
this._params.user_data = {
...additionalMetaData,
...this._params.user_data
};
// Re-validate after enrichment
this._params = v.parse(MetaServerEventParamsSchema, this._params);
}
/**
* Enriches the CAPIEvent with an event source URL. Will not override
* existing event source URL if already set AND will only apply if the
* action source is 'website'. A message will be logged to warn the user
* that an implicit event source URL is being set.
* @param url - The event source URL to set.
*/
unsafeEventSourceURL(url?: string) {
if (!this._params) return;
if (!url) {
log.warn(
`[CAPIEvent] ${this.readableName} could not implicitly set eventSourceURL, event will be submitted without it.`
);
return;
}
if (!this._params.event_source_url && this._params.action_source === ActionSource.Website) {
this._params.event_source_url = url;
log.warn(`[CAPIEvent] ${this.readableName} implicitly setting eventSourceURL to ${url}`);
}
}
}

76
src/lib/capi/handle.ts Normal file
View File

@@ -0,0 +1,76 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { StatusCodes } from 'http-status-codes';
import { getFbpFbcFromCookies } from '../metapixel/fbc.ts';
import type { CAPIConnector } from './connector.ts';
import { getRequestIP } from '../util/ip.ts';
import * as v from 'valibot';
import { CAPIEvent, MetaServerEventParamsSchema, type CAPICustomerInfoParams } from './event.ts';
export const capiRequestBodySchema = v.object({
events: v.array(MetaServerEventParamsSchema)
});
/** Request body for conversion events */
export type CAPIRequestBody = v.InferOutput<typeof capiRequestBodySchema>;
export const capiErrorBodySchema = v.object({
error: v.string()
});
/** Returned by the conversion request handler in case of an error */
export type CAPIErrorBody = v.InferOutput<typeof capiErrorBodySchema>;
export const capiResponseBodySchema = v.object({
fbtrace_id: v.string(),
events_received: v.number(),
messages: v.array(v.string())
});
/** Returned by the conversion request handler in case of a successful response */
export type CAPIResponseBody = v.InferOutput<typeof capiResponseBodySchema>;
/**
* Creates a SvelteKit request handler for processing conversion events.
*
* @param connector - The CAPIConnector instance to send events through.
* @returns A SvelteKit RequestHandler function.
*/
export const createCAPIHandler: (connector: CAPIConnector) => RequestHandler = (connector) => {
const handle: RequestHandler = async ({ request, getClientAddress, cookies }) => {
try {
const jsonBody = await request.json();
const parsed = v.parse(capiRequestBodySchema, jsonBody);
// Build enriched user data with IP, user agent, and fbp/fbc from cookies
const ip = getRequestIP(request, getClientAddress);
const ua = request.headers.get('user-agent') ?? undefined;
const { fbp, fbc } = getFbpFbcFromCookies(cookies);
const eventSourceURL = request.headers.get('referer') ?? undefined;
const enrichedUserData: Partial<CAPICustomerInfoParams> = {
clientIP: ip,
clientUserAgent: ua,
fbp,
fbc
};
// Enrich each event's user data & event source URL
const events: CAPIEvent[] = parsed.events.map((eventParams) => {
const event = CAPIEvent.fromObject(eventParams);
event.enrichUserData(enrichedUserData);
event.unsafeEventSourceURL(eventSourceURL);
return event;
});
// Send the event via the control
const response = await connector.sendEvents(events);
return json(response, { status: StatusCodes.OK });
} catch (e) {
const response: CAPIErrorBody = { error: e instanceof Error ? e.message : String(e) };
return json(response, {
status: StatusCodes.INTERNAL_SERVER_ERROR
});
}
};
return handle;
};

4
src/lib/capi/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export * from './event.ts';
export * from './connector.ts';
export * from './handle.ts';
export * from './client.ts';

View File

@@ -4,11 +4,16 @@ import { dev } from '$app/environment';
import log from 'loglevel';
export type * as fbq from './types/fbq.d.ts';
export { default as MetaPixel, PixelControl } from './MetaPixel.svelte';
export * from './metapixel/index.ts';
export * from './tracking.svelte.ts';
export { default as Umami } from './Umami.svelte';
export * from './capi/index.ts';
export * from './util/ip.ts';
// set log level to debug if we're in dev mode
if (dev) {
log.setLevel('debug');
log.debug('[spectator] Log level set to debug');
} else {
log.setLevel('warn');
}

View File

@@ -0,0 +1,74 @@
<!-- @component
MetaPixel integrates the Meta (Facebook) Pixel into your Svelte application,
allowing you to track page views and custom events while respecting user consent
for tracking. The component manages the lifecycle of the Meta Pixel script and
PixelControl interface.
-->
<script lang="ts">
import { onMount } from 'svelte';
import type { TrackingManager } from '../tracking.svelte.ts';
import { onNavigate } from '$app/navigation';
import { PixelControl, type PixelControlOptions } from './pixel-control.ts';
import { ensureFbc, type EnsureFbcOptions } from './fbc.ts';
interface Props {
/**
* Tracking manager to handle user consent for tracking. If omitted
* tracking is disabled by default until consent is granted via
* PixelControl.grantConsent().
*/
trackingManager?: TrackingManager;
/** Meta Pixel ID */
pixelID: string;
/** Meta Pixel Options */
pixelOptions?: PixelControlOptions;
/**
* Controls whether page views are automatically tracked by this
* component (default: true).
*/
autoPageView?: boolean;
}
let { pixelID, pixelOptions, autoPageView = true, trackingManager }: Props = $props();
let pixel = $state<PixelControl | null>(null);
const fbcOptions: EnsureFbcOptions = {
sameSite: 'Lax'
};
onMount(() => {
if (!trackingManager) {
throw new Error('MetaPixel component requires a TrackingManager to manage consent.');
}
pixel = PixelControl.initialize(trackingManager, pixelID, pixelOptions);
trackingManager.runWithConsent(() => {
if (autoPageView && pixel) {
pixel.pageView();
ensureFbc(fbcOptions);
}
});
});
onNavigate(() => {
trackingManager?.runWithConsent(() => {
if (autoPageView && pixel) {
pixel.pageView();
ensureFbc(fbcOptions);
}
});
});
export const getPixelControl = (): PixelControl => {
if (!pixel) {
throw new Error('MetaPixel component has not been initialized yet, wait for onMount.');
}
return pixel;
};
</script>

98
src/lib/metapixel/fbc.ts Normal file
View File

@@ -0,0 +1,98 @@
import type { Cookies } from '@sveltejs/kit';
import log from 'loglevel';
export type EnsureFbcOptions = {
days?: number; // cookie lifetime, default 180
domain?: string; // optional cookie domain
sameSite?: 'Lax' | 'Strict' | 'None'; // default Lax
secure?: boolean; // default inferred from location.protocol === 'https:'
pixelLoaded?: boolean; // if true, skip manual set and let Pixel handle it
};
function getParam(name: string): string | undefined {
const params = new URLSearchParams(location.search);
const v = params.get(name);
return v || undefined;
}
function getCookie(name: string): string | undefined {
return document.cookie
.split('; ')
.find((c) => c.startsWith(name + '='))
?.split('=')[1];
}
function setCookie(
name: string,
value: string,
{
days = 180,
domain,
sameSite = 'Lax',
secure
}: Pick<EnsureFbcOptions, 'days' | 'domain' | 'sameSite' | 'secure'> = {}
) {
const d = new Date();
d.setTime(d.getTime() + days * 864e5);
const parts = [
`${name}=${encodeURIComponent(value)}`,
`expires=${d.toUTCString()}`,
'path=/',
`SameSite=${sameSite}`
];
if (domain) parts.push(`domain=${domain}`);
const isSecure = secure ?? location.protocol === 'https:';
if (isSecure) parts.push('Secure');
document.cookie = parts.join('; ');
}
function isValidFbc(value: string | undefined): boolean {
if (!value) return false;
// Expect "fb.1.<unix>.<fbclid>"
if (!value.startsWith('fb.1.')) return false;
const parts = value.split('.');
return parts.length >= 4 && /^\d+$/.test(parts[2]) && parts[3].length > 0;
}
/**
* Ensure _fbc cookie exists when landing URL contains fbclid.
* Call after consent. If pixelLoaded is true, it skips manual setting.
*/
export function ensureFbc(options: EnsureFbcOptions = {}) {
try {
const { pixelLoaded = false, ...cookieOpts } = options;
if (pixelLoaded) throw new Error('Pixel loaded, skipping manual _fbc set'); // Let the Pixel set _fbc if its active
const fbclid = getParam('fbclid');
if (!fbclid) throw new Error('No fbclid param present');
const existing = getCookie('_fbc');
if (isValidFbc(existing)) throw new Error('_fbc cookie already present and valid');
const ts = Math.floor(Date.now() / 1000);
const fbc = `fb.1.${ts}.${fbclid}`;
setCookie('_fbc', fbc, cookieOpts);
log.debug('[ensureFbc] Set _fbc cookie:', fbc);
} catch (e) {
log.debug('[ensureFbc]', (e as Error).message);
}
}
/**
* Helper to read both _fbp and _fbc for your CAPI payload from browser cookies.
*/
export function getFbpFbc(): { fbp?: string; fbc?: string } {
const fbp = getCookie('_fbp');
const fbc = getCookie('_fbc');
return { fbp, fbc };
}
/**
* Helper to read both _fbp and _fbc for your CAPI payload from cookies object.
*/
export function getFbpFbcFromCookies(cookies: Cookies): { fbp?: string; fbc?: string } {
const fbp = cookies.get('_fbp') || undefined;
const fbc = cookies.get('_fbc') || undefined;
return { fbp, fbc };
}

View File

@@ -0,0 +1,4 @@
export { default as MetaPixel } from './MetaPixel.svelte';
export { PixelControl, type PixelControlOptions } from './pixel-control.ts';
export * from './fbc.ts';
export * from './pixel-control.ts';

View File

@@ -0,0 +1,351 @@
import type { TrackingManager } from '../tracking.svelte.ts';
import type {
EventParamsByName,
StandardEventName,
CommonParams,
CustomParams,
AdvancedMatching,
InitOptions
} from '../types/fbq.js';
import { loadMetaPixel } from '../util/meta-pixel-loader.ts';
import { resolveGetter, type MaybeGetter } from '../util/getter.ts';
import log from 'loglevel';
import { dev } from '$app/environment';
import { CAPIClient } from '../capi/client.ts';
import {
ActionSource,
CAPIEvent,
type CAPIEventOptions,
type CAPIStandardParams
} from '../capi/event.ts';
const pixelParamsToCustomData = (params: CommonParams & CustomParams): CAPIStandardParams => {
const customData: CAPIStandardParams = {};
if (params.value) customData.value = params.value;
if (params.currency) customData.currency = params.currency;
if (params.content_name) customData.content_name = params.content_name;
if (params.content_category) customData.content_category = params.content_category;
if (params.content_ids) customData.content_ids = params.content_ids;
if (params.contents) {
const acc: CAPIStandardParams['contents'] = [];
customData.contents = params.contents.reduce((acc, content) => {
acc.push({
id: content.id.toString(),
quantity: content.quantity
});
return acc;
}, acc);
}
if (params.num_items) customData.num_items = params.num_items;
if (params.search_string) customData.search_string = params.search_string;
if (params.predicted_ltv) customData.predicted_ltv = params.predicted_ltv;
return customData;
};
/**
* Options for configuring a PixelControl instance.
*/
export type PixelControlOptions = {
/**
* if provided, events fired will always have this code attached
* to prevent them from polluting real analytics data.
*/
testEventCode?: string;
/**
* if provided, all events fired will be passed to the server endpoint
* at this URL to be sent via the Conversion API. Any events sent
* without a event ID will be assigned a random one.
*/
conversionHref?: string;
/** Advanced matching data */
advancedMatching?: AdvancedMatching;
/** Initialization options */
initOptions?: InitOptions;
};
/**
* Manages multiple Meta Pixel instances and provides methods to
* interact with them, including consent management and event tracking.
*/
export class PixelControl {
private _pixelID: string;
private _testEventCode?: string = undefined;
private _trackingManager: MaybeGetter<TrackingManager | undefined>;
private _conversionClient?: CAPIClient = undefined;
private static _baseLoaded: boolean = false;
private static _registeredPixels: Record<string, PixelControl> = {};
/** Indicates whether the Meta Pixel base script has been loaded. */
static get baseLoaded(): boolean {
return this._baseLoaded;
}
/**
* Ensures that the Meta Pixel base has been loaded before
* allowing further operations.
* @throws Error if the Meta Pixel API is not loaded.
*/
static loadGuard(): void {
if (!this._baseLoaded || !window.fbq) {
throw new Error('Meta Pixel API has not been loaded. Call PixelControl.load() first.');
}
}
private constructor(
trackingManager: MaybeGetter<TrackingManager | undefined>,
pixelID: string,
options?: PixelControlOptions
) {
this._trackingManager = trackingManager;
this._pixelID = pixelID;
this._testEventCode = options?.testEventCode;
const resolvedTrackingManager = resolveGetter(trackingManager);
if (options?.conversionHref && resolvedTrackingManager) {
this._conversionClient = new CAPIClient(options.conversionHref, resolvedTrackingManager);
} else if (options?.conversionHref) {
log.warn(
`${this.logPrefix} CAPI Client ${options.conversionHref} available but not initialized, TrackingManager is required for user consent.`
);
}
}
/** Loads the Meta Pixel base script. */
static load() {
if (this._baseLoaded && !!window.fbq) return;
loadMetaPixel(); // Load the Meta Pixel script
this._baseLoaded = true;
log.debug('[PixelControl] Meta Pixel base script loaded.', this._baseLoaded);
}
/** Tells the Meta pixel that the user has given consent for tracking. */
static grantConsent() {
this.loadGuard();
window.fbq?.('consent', 'grant');
log.debug('[PixelControl] Pixel consent granted.');
}
/** Tells the Meta pixel that the user has revoked consent for tracking. */
static revokeConsent() {
this.loadGuard();
window.fbq?.('consent', 'revoke');
log.debug('[PixelControl] Pixel consent revoked.');
}
/**
* Registers a PixelControl instance for the given Meta Pixel ID. If
* the base Meta Pixel script has not been loaded yet, it will be
* loaded automatically. Optionally sets a test event code for the Pixel.
* Should only be called once for each Pixel ID, use PixelControl.get()
* to retrieve existing instances.
* @param trackingManager Tracking manager to handle user consent for tracking
* @param pixelID Meta Pixel ID
* @param options Optional settings
* @returns PixelControl instance
*/
static initialize(
trackingManager: MaybeGetter<TrackingManager | undefined>,
pixelID: string,
options?: PixelControlOptions
): PixelControl {
// Load the base script if not already loaded
PixelControl.load();
// Check for existing PixelControl instance
if (this._registeredPixels[pixelID]) {
log.warn(
`${this._registeredPixels[pixelID].logPrefix} Instance already exists. Returning existing instance.`
);
return this._registeredPixels[pixelID];
}
// Create and register the PixelControl instance
const pixel = new PixelControl(trackingManager, pixelID, options);
this._registeredPixels[pixelID] = pixel;
// Fire initialization
window.fbq('init', pixel._pixelID, options?.advancedMatching, options?.initOptions);
log.debug(`${pixel.logPrefix} initialized.`);
return pixel;
}
/**
* Returns an existing PixelControl instance for the given Meta Pixel ID.
* @param pixelID Meta Pixel ID
* @returns PixelControl instance
* @throws Error if no PixelControl instance is found for the given ID.
*/
static get(pixelID: string): PixelControl {
const pixel = this._registeredPixels[pixelID];
if (!pixel) {
throw new Error(`No PixelControl instance found for Meta Pixel ID: ${pixelID}`);
}
return pixel;
}
/**
* Checks if the Meta Pixel has consent to track user data
* and if the Pixel has been loaded.
* @returns true if tracking is allowed, false otherwise.
* @throws Error if the Meta Pixel is not loaded.
*/
consentGuard(): boolean {
PixelControl.loadGuard();
const trackingManager = resolveGetter(this._trackingManager);
return trackingManager?.haveUserConsent() ?? false;
}
/** Warns if we're in dev mode and no test code is set */
private devModeWarn() {
if (dev && !this._testEventCode) {
log.warn(
`${this.logPrefix} Sending events in dev mode without a test event code. ` +
'Consider providing a test event code to avoid affecting real data.'
);
}
}
/** Returns the log prefix including the pixel ID */
private get logPrefix(): string {
return `[PixelControl] [${this._pixelID}]`;
}
/**
* Shorthand utility to send a PageView event
* @param disableCAPI If true, disables sending this event to the Conversion API
* @throws Error if the Meta Pixel is not initialized.
*/
pageView(disableCAPI: boolean = false) {
this.track('PageView', undefined, undefined, disableCAPI);
}
/**
* Forwards an event to the Conversion API client if configured.
*
* @param event - The event name.
* @param params - The event parameters.
* @param eventID - Optional event ID for deduplication.
* @returns The event ID used, either provided or generated.
*/
private forwardToCAPI(
event: StandardEventName | string,
params?: CommonParams & CustomParams,
eventID?: string
): string | undefined {
if (!this._conversionClient) return eventID;
if (!eventID) {
eventID = crypto.randomUUID();
}
const opts: CAPIEventOptions = {
eventName: event,
eventID: eventID,
actionSource: ActionSource.Website,
eventSourceURL: window.location.href,
eventTime: new Date(),
userData: {
clientUserAgent: navigator.userAgent
},
customData: params ? pixelParamsToCustomData(params) : undefined
};
const ev = CAPIEvent.fromOpts(opts);
log.debug(
`${this.logPrefix} ${ev.readableName} forwarding to CAPI with body: ${JSON.stringify(ev.params, null, 2)}`
);
this._conversionClient
.trackEvent(ev)
.then((response) => {
log.debug(
`${this.logPrefix} ${ev.readableName} forwarded to CAPI, response: ${JSON.stringify(
response,
null,
2
)}`
);
})
.catch((error) => {
log.error(`${this.logPrefix} ${ev.readableName} failed to forward to CAPI. Error: `, error);
});
return eventID;
}
private getEventReadableName(
event: StandardEventName | string,
eventID?: string,
testCode?: string
): string {
return (
event + (eventID ? ` (${eventID})` : '') + (testCode ? ` [test_event_code: ${testCode}]` : '')
);
}
/**
* Tracks a standard event for this pixel (uses `trackSingle` under the hood)
* @param event Standard event name
* @param params Event parameters
* @param eventID Optional event ID for deduplication with Conversion API
* @param disableCAPI If true, disables sending this event to the Conversion API
* @throws Error if the Meta Pixel is not initialized.
*/
track<K extends StandardEventName>(
event: K,
params?: EventParamsByName[K],
eventID?: string,
disableCAPI: boolean = false
) {
if (!this.consentGuard()) return;
this.devModeWarn();
// Optionally, send to conversion API endpoint
if (!disableCAPI) {
eventID = this.forwardToCAPI(event, params, eventID);
}
// Send the event to Meta via the pixel
window.fbq('trackSingle', this._pixelID, event, params, {
eventID,
test_event_code: this._testEventCode
});
log.debug(
`${this.logPrefix} ${this.getEventReadableName(event, eventID, this._testEventCode)} sent.`
);
}
/**
* Tracks a custom event for this pixel (uses `trackSingleCustom` under the hood)
* @param event Custom event name
* @param params Event parameters
* @param eventID Optional event ID for deduplication with Conversion API
* @param disableCAPI If true, disables sending this event to the Conversion API
* @throws Error if the Meta Pixel is not initialized.
*/
trackCustom(
event: string,
params?: CommonParams & CustomParams,
eventID?: string,
disableCAPI: boolean = false
) {
if (!this.consentGuard()) return;
this.devModeWarn();
// Optionally, send to conversion API endpoint
if (!disableCAPI) {
eventID = this.forwardToCAPI(event, params, eventID);
}
// Send the event to Meta via the pixel
window.fbq('trackSingleCustom', this._pixelID, event, params, {
eventID,
test_event_code: this._testEventCode
});
log.debug(
`${this.logPrefix} ${this.getEventReadableName(event, eventID, this._testEventCode)} sent.`
);
}
}

View File

@@ -1,4 +1,6 @@
import { setContext, getContext, onDestroy } from 'svelte';
import { browser } from '$app/environment';
import log from 'loglevel';
import { onDestroy, onMount, createContext } from 'svelte';
/**
* Options for initializing the TrackingManager.
@@ -36,17 +38,77 @@ type InternalService<T> = Service<T> & {
* Manages user tracking preferences and services that require consent.
*/
export class TrackingManager {
/** tracking consent, persisted to localStorage by saveOpts */
private _consent: boolean | null = $state(null);
private _services: Record<string, InternalService<unknown>> = {};
private _changeCallbacks: Array<(consent: boolean | null) => void> = [];
private _loadCallbacks: Array<(consent: boolean | null) => void> = [];
private _loaded: boolean = false;
private _consentQueue: Array<() => void> = [];
/**
* Saves consent state to localStorage if browser storage is available.
* Automatically called after updating consent.
* @throws Error if storage is not available.
*/
saveOpts(): TrackingManager {
if (!browser || !window?.localStorage) {
throw new Error('Cannot access localStorage to save tracking state');
}
window.localStorage.setItem(
'trackingOpts',
JSON.stringify({ consent: this._consent } as TrackingManagerOpts)
);
return this;
}
/**
* Loads tracking options from localStorage if available. Suitable to call
* after initialization, e.g. in onMount after a TrackingManager is created.
* @throws Error if storage is not available.
*/
loadOpts(): TrackingManager {
if (!browser || !window?.localStorage) {
throw new Error('Cannot access localStorage to load tracking state');
}
const raw = window.localStorage.getItem('trackingOpts');
if (raw) {
const opts = JSON.parse(raw) as TrackingManagerOpts;
if (opts.consent !== undefined && opts.consent !== null) {
this.setConsent(opts.consent);
}
log.debug('[TrackingManager] Loaded tracking options from storage:', opts);
}
// Run load callbacks
this._loadCallbacks.forEach((cb) => {
cb(this._consent);
});
this._loaded = true;
return this;
}
/**
* Creates a TrackingManager instance.
* @param opts Optional initial options.
*/
constructor(opts?: TrackingManagerOpts) {
if (opts) {
if (opts.consent !== undefined) this._consent = opts.consent;
}
}
/**
* Creates a TrackingManager instance from localStorage data.
* @throws Error if storage is not available.
*/
static fromLocalStorage(): TrackingManager {
return new TrackingManager().loadOpts();
}
/** Indicates whether tracking is currently allowed. */
get consent() {
return this._consent;
@@ -78,7 +140,7 @@ export class TrackingManager {
/**
* Sets whether tracking is consented. If set to true, all queued callbacks
* will be executed.
* will be executed. Automatically persists to localStorage if available.
*/
setConsent(value: boolean) {
if (this._consent === value) return;
@@ -95,6 +157,17 @@ export class TrackingManager {
this._changeCallbacks.forEach((cb) => {
cb(this._consent);
});
this.saveOpts();
}
/**
* Registers a callback to be called only once the tracking state has been
* loaded from localStorage. Will be called immediately if already loaded
* and with each subsequent load.
*/
onceLoaded(callback: (consent: boolean | null) => void) {
this._loadCallbacks.push(callback);
if (this._loaded) callback(this.consent);
}
/**
@@ -113,16 +186,23 @@ export class TrackingManager {
/**
* Runs callback immediately if we have consent already or queues it for later.
* Removes callback from queue onDestroy.
* @param callback The function to run when consent is granted.
*/
runWithConsent(callback: () => void) {
if (this._consent) {
callback();
} else {
this._consentQueue.push(callback);
}
if (this._consent) callback();
else this._consentQueue.push(callback);
}
/**
* Runs callback onMount if we have consent already or queues it for later.
* Removes the callback from the queue onDestroy.
* @param callback The function to run when consent is granted.
*/
lifecycleWithConsent(callback: () => void) {
onMount(() => {
if (this._consent) callback();
else this._consentQueue.push(callback);
});
onDestroy(() => {
this._consentQueue = this._consentQueue.filter((cb) => cb !== callback);
});
@@ -192,19 +272,31 @@ export class TrackingManager {
}
}
const trackingManagerKey = Symbol();
const [getTrackingContext, setTrackingContext] = createContext<TrackingManager>();
/**
* Gets the TrackingManager from context, or creates one if it doesn't exist.
* @param initializer Optional initializer function to customize the TrackingManager.
* If called from the browser, attempts to load saved state from localStorage.
* @returns The TrackingManager instance.
*/
export const getTrackingManager = (): TrackingManager => {
const saved = getContext<TrackingManager>(trackingManagerKey);
if (saved) return saved;
try {
const saved = getTrackingContext();
if (saved) {
log.debug('[TrackingManager] Using existing instance from context');
return saved;
}
} catch {
// ignore missing context, we'll create a new one
}
log.debug('[TrackingManager] Creating new instance');
const manager = $state(new TrackingManager());
setTrackingContext(manager);
if (browser) {
manager.loadOpts();
}
console.debug('initializing a new TrackingManager');
const manager = new TrackingManager();
setContext(trackingManagerKey, manager);
return manager;
};

View File

@@ -39,7 +39,7 @@ export type CommonParams = Partial<{
/**
* Product IDs associated with the event, such as SKUs (e.g. ["ABC123", "XYZ456"]).
*/
content_ids: (string | number)[];
content_ids: string[];
/**
* Name of the page/product.
@@ -230,27 +230,27 @@ export type StandardEventName =
*/
export type AdvancedMatching = {
/** Primary contact email (or hashed email) */
email?: string;
em?: string;
/** Phone number (E.164 or local) */
phone?: string;
ph?: string;
/** First name */
first_name?: string;
fn?: string;
/** Last name */
last_name?: string;
ln?: string;
/** City */
city?: string;
ct?: string;
/** State/region */
state?: string;
st?: string;
/** Postal / ZIP code */
zip?: string;
zp?: string;
/** Country code */
country?: string;
/** External id to match users (optional) */
external_id?: string;
/** Gender */
gender?: string;
ge?: string;
/** Date of birth (ISO-like or YYYY-MM-DD) */
date_of_birth?: string;
db?: string;
// allow additional provider-specific keys
[key: string]: string | undefined;
};
@@ -340,6 +340,11 @@ export interface FBQ {
// consent and LDU
(cmd: 'consent', state: 'grant' | 'revoke'): void;
(cmd: 'dataProcessingOptions', options: string[], countryCode?: number, stateCode?: number): void;
/** Prevent automatic listening to history.pushState/popstate */
disablePushState?: boolean;
/** Allow duplicate page view events (legacy / undocumented behavior) */
allowDuplicatePageViews?: boolean;
}
declare global {

20
src/lib/util/ip.ts Normal file
View File

@@ -0,0 +1,20 @@
/**
* Extracts the client's IP address from the request headers or falls back to a provided function.
*
* @param request - The incoming Request object.
* @param getClientAddress - A function that returns the client's IP address as a fallback.
* @returns The client's IP address as a string.
*/
export const getRequestIP = (request: Request, getClientAddress: () => string) => {
return (
request.headers.get('x-forwarded-for') ||
request.headers.get('cf-connecting-ip') ||
request.headers.get('x-real-ip') ||
request.headers.get('x-client-ip') ||
request.headers.get('x-cluster-client-ip') ||
request.headers.get('x-original-forwarded-for') ||
request.headers.get('forwarded-for') ||
request.headers.get('forwarded') ||
getClientAddress()
);
};

View File

@@ -1,4 +1,5 @@
import { browser } from '$app/environment';
import log from 'loglevel';
const SCRIPT_SRC = 'https://connect.facebook.net/en_US/fbevents.js';
@@ -8,22 +9,60 @@ type QueuedFBQ = ((...args: unknown[]) => void) & {
loaded?: boolean;
version?: string;
push?: unknown;
disablePushState?: boolean;
allowDuplicatePageViews?: boolean;
};
/**
* Loads the Meta Pixel script and configures the `fbq` function to queue
* commands until the script is fully loaded. You may optionally await the
* returned Promise to ensure the script has loaded before proceeding.
*
* Options:
* - `disablePushState` (default: true) — when true, sets
* `window.fbq.disablePushState = true` before the pixel script loads so the
* pixel does not auto-listen to `history.pushState`/`popstate` (recommended
* for SPA frameworks like Svelte).
* - `allowDuplicatePageViews` (default: false) — when true, sets
* `window.fbq.allowDuplicatePageViews = true` on the stub.
*/
export const loadMetaPixel = (): Promise<void> => {
export const loadMetaPixel = (opts?: {
disablePushState?: boolean;
allowDuplicatePageViews?: boolean;
}): Promise<void> => {
// Make sure we're using the browser
if (!browser || !window) {
return Promise.reject(new Error('Window is undefined'));
return Promise.reject(new Error(`Not in browser, can't access window`));
}
// Default behavior: disable pushState handling since Svelte apps manage
// navigation themselves and Meta's auto-patching of history APIs can
// cause duplicate/incorrect pageview events. Consumers can pass
// `opts.disablePushState = false` to opt out.
const disablePushState = opts?.disablePushState ?? true;
const allowDuplicatePageViews = opts?.allowDuplicatePageViews ?? false;
// If fbq is already defined, resolve immediately
const existing = window.fbq as QueuedFBQ | undefined;
if (existing && existing.loaded) {
if (existing) {
// If the existing stub is present but hasn't set these flags yet, set
// them now so the loaded library (if it inspects them) sees intended
// behavior. Setting these is a no-op if initialization already
// completed.
if (disablePushState) existing.disablePushState = true;
if (allowDuplicatePageViews) existing.allowDuplicatePageViews = true;
const existingScript = getExistingScript();
if (existingScript) {
return new Promise((resolve, reject) => {
attachToScript(existingScript, resolve, reject);
});
}
log.debug(
'Meta Pixel fbq already present, skipping injection',
existing.version,
existing.queue
);
return Promise.resolve();
}
@@ -40,18 +79,18 @@ export const loadMetaPixel = (): Promise<void> => {
q.push = q;
q.loaded = true;
q.version = '2.0';
// set control flags on the stub before the meta script runs
if (disablePushState) q.disablePushState = true;
if (allowDuplicatePageViews) q.allowDuplicatePageViews = true;
window.fbq = q;
window._fbq = q;
return new Promise((resolve, reject) => {
// Avoid adding the same script twice
const existingScript = document.querySelector(
`script[src="${SCRIPT_SRC}"]`
) as HTMLScriptElement | null;
const existingScript = getExistingScript();
if (existingScript) {
existingScript.addEventListener('load', () => resolve());
existingScript.addEventListener('error', () =>
reject(new Error('Failed to load Meta Pixel script'))
);
attachToScript(existingScript, resolve, reject);
log.debug('Meta Pixel script already present, waiting for load');
return;
}
@@ -59,8 +98,23 @@ export const loadMetaPixel = (): Promise<void> => {
const script = document.createElement('script');
script.src = SCRIPT_SRC;
script.async = true;
script.addEventListener('load', () => resolve());
script.addEventListener('error', () => reject(new Error('Failed to load Meta Pixel script')));
attachToScript(script, resolve, reject);
document.head.appendChild(script);
log.debug('Meta Pixel script added to document');
});
};
const getExistingScript = (): HTMLScriptElement | null => {
return document.querySelector(
`script[src*="connect.facebook.net"][src*="fbevents.js"]`
) as HTMLScriptElement | null;
};
const attachToScript = (
el: HTMLScriptElement,
resolve: () => void,
reject: (err: Error) => void
) => {
el.addEventListener('load', () => resolve());
el.addEventListener('error', () => reject(new Error('Failed to load Meta Pixel script')));
};