18 Commits

Author SHA1 Message Date
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
16 changed files with 1177 additions and 300 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.5", "version": "0.0.6",
"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,285 +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;
// Send the PageView event
if (!dev || this._testEventCode) {
window.fbq('track', 'PageView', undefined, { test_event_code: this._testEventCode });
log.debug(
`Meta Pixel [${this._pixelID}] PageView event sent${dev && ` (test code: ${this._testEventCode})`}.`
);
} else {
log.info(
`Meta Pixel [${this._pixelID}] PageView event not sent in development mode without a test event code.`
);
}
}
/**
* 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;
if (!dev || this._testEventCode) {
window.fbq('trackSingle', this._pixelID, event, params, {
eventID,
test_event_code: this._testEventCode
});
log.debug(
`Meta Pixel [${this._pixelID}] ${event} event sent${dev && ` (test code: ${this._testEventCode})`}.`
);
} else {
log.info(
`Meta Pixel [${this._pixelID}] ${event} event not sent in development mode without a test event code.`
);
}
}
/**
* 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;
if (!dev || this._testEventCode) {
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}).`
);
} else {
log.info(
`Meta Pixel [${this._pixelID}] ${event} custom event not sent in development mode without a test event code.`
);
}
}
}
</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';
import { dev } from '$app/environment';
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]: tracking disabled');
onMount(() => { onMount(() => {
if (dev) { if (dev) {

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

@@ -0,0 +1,94 @@
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.
* @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 {
pixelID: '',
fbTraceID: '',
receivedEvents: 0,
processedEvents: 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();
})
};
v.parse(v.object({ events: v.array(v.any()) }), body); // Validate body shape
const response = await fetch(this._href, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
const json = await response.json();
if (response.ok) {
const parsed = v.parse(capiResponseBodySchema, json);
return parsed as CAPIResponseBody;
} else {
const parsed = v.parse(capiErrorBodySchema, json);
return parsed as CAPIErrorBody;
}
}
/**
* 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]);
}
}

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

@@ -0,0 +1,97 @@
import { dev } from '$app/environment';
import log from 'loglevel';
import type { CAPIEvent } from './event.ts';
const GRAPH_VERSION = 'v24.0';
/**
* Response body from Meta Conversion API after sending events.
*/
export type CAPIRequestResponseBody = {
/** Dataset or Pixel ID to which the event successfully posted. */
pixelID: string;
/** fbtrace_id for debugging purposes. */
fbtrace_id: string;
/** Number of events received that were sent by the request. */
receivedEvents: number;
/** Number of events successfully posted by the request. */
processedEvents: number;
/** Messages returned by the server. */
messages: string[];
};
/**
* 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<CAPIRequestResponseBody> {
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?access_token=${this._accessToken}`;
const body = {
data: events.map((e) => e.toObject()),
test_event_code: this._testEventCode
};
const resp = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
const json = await resp.json();
console.log('CAPI response:', json);
if (!resp.ok) {
throw new Error(`Meta CAPI error ${resp.status}: ${JSON.stringify(json, null, 2)}`);
}
return {} as CAPIRequestResponseBody;
}
/**
* 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<CAPIRequestResponseBody> {
return this.sendEvents([event]);
}
}

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

@@ -0,0 +1,329 @@
import type { StandardEventName } from '../types/fbq.js';
import * as v from 'valibot';
import crypto from 'node:crypto';
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 if actionSource is set to 'website' */
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;
}
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);
return event;
}
/**
* 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;
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.
* @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);
}
}

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({
pixelID: v.string(),
fbTraceID: v.string(),
receivedEvents: v.number(),
processedEvents: 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 json = await request.json();
const parsed = v.parse(capiRequestBodySchema, json);
// 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 enrichedUserData: Partial<CAPICustomerInfoParams> = {
clientIP: ip,
clientUserAgent: ua,
fbp,
fbc
};
// Enrich each event's user data
const events: CAPIEvent[] = parsed.events.map((eventParams) => {
const event = CAPIEvent.fromObject(eventParams);
event.enrichUserData(enrichedUserData);
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,15 @@ 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');
} 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,326 @@
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 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(
`[PixelControl] Conversion Client ${options.conversionHref} for Meta Pixel [${this._pixelID}] 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(
`[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(`[PixelControl] [${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;
}
/**
* 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();
}
this._conversionClient
.trackEvent(
CAPIEvent.fromOpts({
eventName: event,
eventID: eventID,
actionSource: ActionSource.Website,
eventTime: new Date(),
userData: {
clientUserAgent: navigator.userAgent
},
customData: params ? pixelParamsToCustomData(params) : undefined
})
)
.then((response) => {
log.debug(
`[PixelControl] [${this._pixelID}] ${event} event sent to Conversion API with Event ID: ${eventID}, Response: ${JSON.stringify(
response,
null,
2
)}`
);
})
.catch((error) => {
log.error(
`[PixelControl] [${this._pixelID}] Failed to send ${event} event to Conversion API with Event ID: ${eventID}`,
error
);
});
}
/**
* 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;
// Optionally, send to conversion API endpoint
if (!disableCAPI) {
eventID = this.forwardToCAPI(event, params, eventID);
}
// Send the event to Meta via the pixel
if (!dev || this._testEventCode) {
window.fbq('trackSingle', this._pixelID, event, params, {
eventID,
test_event_code: this._testEventCode
});
log.debug(
`[PixelControl] [${this._pixelID}] ${event} event sent${dev && ` (test code: ${this._testEventCode})`}.`
);
} else {
log.info(
`[PixelControl] [${this._pixelID}] ${event} event not sent in development mode without a test event code.`
);
}
}
/**
* 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;
// Optionally, send to conversdion API endpoint
if (!disableCAPI) {
eventID = this.forwardToCAPI(event, params, eventID);
}
// Send the event to Meta via the pixel
if (!dev || this._testEventCode) {
window.fbq('trackSingleCustom', this._pixelID, event, params, {
eventID,
test_event_code: this._testEventCode
});
log.debug(
`[PixelControl] [${this._pixelID}] ${event} custom event sent (test code: ${this._testEventCode}).`
);
} else {
log.info(
`[PixelControl] [${this._pixelID}] ${event} custom event not sent in development mode without a test event code.`
);
}
}
}

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