26 Commits

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
17 changed files with 1277 additions and 280 deletions

View File

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

31
pnpm-lock.yaml generated
View File

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

View File

@@ -1,265 +0,0 @@
<!-- @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.
The PixelControl class also allows you to directly manage multiple Pixel
instances and handle event tracking with optional test event codes without
using the MetaPixel component.
-->
<script lang="ts" module>
export type PixelControlOptions = {
/**
* if provided, events fired will always have this code attached
* to prevent them from polluting real analytics data.
*/
testEventCode?: string;
/** Advanced matching data */
advancedMatching?: AdvancedMatching;
/** Initialization options */
initOptions?: InitOptions;
};
export class PixelControl {
private _pixelID: string;
private _testEventCode?: string = undefined;
private _trackingManager: MaybeGetter<TrackingManager | 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;
}
/** Loads the Meta Pixel base script. */
static async load() {
if (this._baseLoaded && !!window.fbq) return;
if (!window.fbq) {
try {
await loadMetaPixel(); // Load the Meta Pixel script
} catch (e) {
log.warn('Failed to load Meta Pixel script, all events will be queued.', e);
}
}
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.');
}
/**
* 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(
`PixelControl instance for Meta Pixel ID: ${pixelID} 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(`Meta Pixel [${pixel._pixelID}] 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;
}
/**
* 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 { createContext, 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 {
/**
* 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);
onMount(() => {
if (!trackingManager) {
throw new Error('MetaPixel component requires a TrackingManager to manage consent.');
}
PixelControl.load();
pixel = PixelControl.initialize(trackingManager, pixelID, pixelOptions);
trackingManager.runWithConsent(() => {
if (autoPageView && pixel) {
pixel.pageView();
}
});
});
onNavigate(() => {
trackingManager?.runWithConsent(() => {
if (autoPageView && pixel) {
pixel.pageView();
}
});
});
export const getPixelControl = (): PixelControl => {
if (!pixel) {
throw new Error('MetaPixel component has not been initialized yet, wait for onMount.');
}
return pixel;
};
</script>

View File

@@ -25,7 +25,9 @@
const consentGranted = $derived(trackingManager ? trackingManager.consent === true : true); const consentGranted = $derived(trackingManager ? trackingManager.consent === true : true);
// Development overrides to prevent dirty analytics // 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 = { const devOverride = {
track: (...args: unknown[]): Promise<string> | undefined => { track: (...args: unknown[]): Promise<string> | undefined => {
log.debug(`${devConsoleTag}: Track called with:`, ...args); 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(() => { onMount(() => {
if (dev) { 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'; import log from 'loglevel';
export type * as fbq from './types/fbq.d.ts'; 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 * from './tracking.svelte.ts';
export { default as Umami } from './Umami.svelte'; 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 // set log level to debug if we're in dev mode
if (dev) { if (dev) {
log.setLevel('debug'); 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

@@ -42,6 +42,8 @@ export class TrackingManager {
private _consent: boolean | null = $state(null); private _consent: boolean | null = $state(null);
private _services: Record<string, InternalService<unknown>> = {}; private _services: Record<string, InternalService<unknown>> = {};
private _changeCallbacks: Array<(consent: boolean | null) => void> = []; private _changeCallbacks: Array<(consent: boolean | null) => void> = [];
private _loadCallbacks: Array<(consent: boolean | null) => void> = [];
private _loaded: boolean = false;
private _consentQueue: Array<() => void> = []; private _consentQueue: Array<() => void> = [];
/** /**
@@ -80,6 +82,12 @@ export class TrackingManager {
log.debug('[TrackingManager] Loaded tracking options from storage:', opts); log.debug('[TrackingManager] Loaded tracking options from storage:', opts);
} }
// Run load callbacks
this._loadCallbacks.forEach((cb) => {
cb(this._consent);
});
this._loaded = true;
return this; return this;
} }
@@ -152,6 +160,16 @@ export class TrackingManager {
this.saveOpts(); 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);
}
/** /**
* Registers a callback to be notified when the tracking permission changes. * Registers a callback to be notified when the tracking permission changes.
*/ */

View File

@@ -39,7 +39,7 @@ export type CommonParams = Partial<{
/** /**
* Product IDs associated with the event, such as SKUs (e.g. ["ABC123", "XYZ456"]). * 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. * Name of the page/product.
@@ -230,27 +230,27 @@ export type StandardEventName =
*/ */
export type AdvancedMatching = { export type AdvancedMatching = {
/** Primary contact email (or hashed email) */ /** Primary contact email (or hashed email) */
email?: string; em?: string;
/** Phone number (E.164 or local) */ /** Phone number (E.164 or local) */
phone?: string; ph?: string;
/** First name */ /** First name */
first_name?: string; fn?: string;
/** Last name */ /** Last name */
last_name?: string; ln?: string;
/** City */ /** City */
city?: string; ct?: string;
/** State/region */ /** State/region */
state?: string; st?: string;
/** Postal / ZIP code */ /** Postal / ZIP code */
zip?: string; zp?: string;
/** Country code */ /** Country code */
country?: string; country?: string;
/** External id to match users (optional) */ /** External id to match users (optional) */
external_id?: string; external_id?: string;
/** Gender */ /** Gender */
gender?: string; ge?: string;
/** Date of birth (ISO-like or YYYY-MM-DD) */ /** Date of birth (ISO-like or YYYY-MM-DD) */
date_of_birth?: string; db?: string;
// allow additional provider-specific keys // allow additional provider-specific keys
[key: string]: string | undefined; [key: string]: string | undefined;
}; };

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()
);
};