12 Commits

Author SHA1 Message Date
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
20 changed files with 808 additions and 798 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.1.0",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -64,9 +64,9 @@
"dependencies": { "dependencies": {
"@types/facebook-nodejs-business-sdk": "^23.0.0", "@types/facebook-nodejs-business-sdk": "^23.0.0",
"@types/umami": "^2.10.1", "@types/umami": "^2.10.1",
"facebook-nodejs-business-sdk": "^24.0.1",
"http-status-codes": "^2.3.0", "http-status-codes": "^2.3.0",
"loglevel": "^1.9.2" "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/"

258
pnpm-lock.yaml generated
View File

@@ -14,15 +14,15 @@ importers:
'@types/umami': '@types/umami':
specifier: ^2.10.1 specifier: ^2.10.1
version: 2.10.1 version: 2.10.1
facebook-nodejs-business-sdk:
specifier: ^24.0.1
version: 24.0.1
http-status-codes: http-status-codes:
specifier: ^2.3.0 specifier: ^2.3.0
version: 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
@@ -577,12 +577,6 @@ packages:
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
axios@1.13.2:
resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==}
axobject-query@4.1.0: axobject-query@4.1.0:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -596,10 +590,6 @@ packages:
brace-expansion@2.0.2: brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
callsites@3.1.0: callsites@3.1.0:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -627,10 +617,6 @@ packages:
color-name@1.1.4: color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
concat-map@0.0.1: concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
@@ -647,9 +633,6 @@ packages:
engines: {node: '>=4'} engines: {node: '>=4'}
hasBin: true hasBin: true
currency-codes@1.5.1:
resolution: {integrity: sha512-hqy8vtlIYKzO6pe2TE0V4/riZALIc7nhtE9cvxk5FDRCvfGplgzUvpTmZlMsyO+NeK5U41j+sQXJOo8l8v9kdg==}
debug@4.4.3: debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'} engines: {node: '>=6.0'}
@@ -669,37 +652,9 @@ packages:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
devalue@5.6.1: devalue@5.6.1:
resolution: {integrity: sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==} resolution: {integrity: sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
email-validator@2.0.4:
resolution: {integrity: sha512-gYCwo7kh5S3IDyZPLZf6hSS0MnZT8QmJFqYvbqlDZSbwdZlY6QZWxJ4i/6UhITOJ4XzyI647Bm2MXKCLqnJ4nQ==}
engines: {node: '>4.0'}
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
es-errors@1.3.0:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
es-set-tostringtag@2.1.0:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
esbuild@0.27.1: esbuild@0.27.1:
resolution: {integrity: sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==} resolution: {integrity: sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -773,9 +728,6 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
facebook-nodejs-business-sdk@24.0.1:
resolution: {integrity: sha512-31uR42FO+V3ikQfVZyrnoc34pVYzxo4PE+BnKt1ziX9nGhSwsS7ogGDsMJQk7A3SdoWPIGgR2G4UbAY2s9zNVQ==}
fast-deep-equal@3.1.3: fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -802,9 +754,6 @@ packages:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'} engines: {node: '>=10'}
first-match@0.0.1:
resolution: {integrity: sha512-VvKbnaxrC0polTFDC+teKPTdl2mn6B/KUW+WB3C9RzKDeNwbzfLdnUz3FxC+tnjvus6bI0jWrWicQyVIPdS37A==}
flat-cache@4.0.1: flat-cache@4.0.1:
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
engines: {node: '>=16'} engines: {node: '>=16'}
@@ -812,35 +761,11 @@ packages:
flatted@3.3.3: flatted@3.3.3:
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
follow-redirects@1.15.11:
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
fsevents@2.3.3: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin] os: [darwin]
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
glob-parent@6.0.2: glob-parent@6.0.2:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
@@ -853,26 +778,10 @@ packages:
resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
has-flag@4.0.0: has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
has-tostringtag@1.0.2:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
http-status-codes@2.3.0: http-status-codes@2.3.0:
resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==} resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==}
@@ -906,12 +815,6 @@ packages:
isexe@2.0.0: isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
iso-3166-1@2.1.1:
resolution: {integrity: sha512-RZxXf8cw5Y8LyHZIwIRvKw8sWTIHh2/txBT+ehO0QroesVfnz3JNFFX4i/OC/Yuv2bDIVYrHna5PMvjtpefq5w==}
js-sha256@0.9.0:
resolution: {integrity: sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA==}
js-yaml@4.1.1: js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true hasBin: true
@@ -960,18 +863,6 @@ packages:
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
minimatch@3.1.2: minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
@@ -979,9 +870,6 @@ packages:
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
engines: {node: '>=16 || 14 >=14.17'} engines: {node: '>=16 || 14 >=14.17'}
mixwith@0.1.1:
resolution: {integrity: sha512-DQsf/liljH/9e+94jR+xfK8vlKceeKdOM9H9UEXLwGuvEEpO6debNtJ9yt1ZKzPKPrwqGxzMdu0BR1fnQb6i4A==}
mri@1.2.0: mri@1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -1001,9 +889,6 @@ packages:
natural-compare@1.4.0: natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
nub@0.0.0:
resolution: {integrity: sha512-dK0Ss9C34R/vV0FfYJXuqDAqHlaW9fvWVufq9MmGF2umCuDbd5GRfRD9fpi/LiM0l4ZXf8IBB+RYmZExqCrf0w==}
optionator@0.9.4: optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -1085,9 +970,6 @@ packages:
engines: {node: '>=14'} engines: {node: '>=14'}
hasBin: true hasBin: true
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
publint@0.3.16: publint@0.3.16:
resolution: {integrity: sha512-MFqyfRLAExPVZdTQFwkAQELzA8idyXzROVOytg6nEJ/GEypXBUmMGrVaID8cTuzRS1U5L8yTOdOJtMXgFUJAeA==} resolution: {integrity: sha512-MFqyfRLAExPVZdTQFwkAQELzA8idyXzROVOytg6nEJ/GEypXBUmMGrVaID8cTuzRS1U5L8yTOdOJtMXgFUJAeA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1219,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}
@@ -1702,16 +1592,6 @@ snapshots:
aria-query@5.3.2: {} aria-query@5.3.2: {}
asynckit@0.4.0: {}
axios@1.13.2:
dependencies:
follow-redirects: 1.15.11
form-data: 4.0.5
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
axobject-query@4.1.0: {} axobject-query@4.1.0: {}
balanced-match@1.0.2: {} balanced-match@1.0.2: {}
@@ -1725,11 +1605,6 @@ snapshots:
dependencies: dependencies:
balanced-match: 1.0.2 balanced-match: 1.0.2
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
callsites@3.1.0: {} callsites@3.1.0: {}
chalk@4.1.2: chalk@4.1.2:
@@ -1753,10 +1628,6 @@ snapshots:
color-name@1.1.4: {} color-name@1.1.4: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
concat-map@0.0.1: {} concat-map@0.0.1: {}
cookie@0.6.0: {} cookie@0.6.0: {}
@@ -1769,11 +1640,6 @@ snapshots:
cssesc@3.0.0: {} cssesc@3.0.0: {}
currency-codes@1.5.1:
dependencies:
first-match: 0.0.1
nub: 0.0.0
debug@4.4.3: debug@4.4.3:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
@@ -1784,33 +1650,8 @@ snapshots:
deepmerge@4.3.1: {} deepmerge@4.3.1: {}
delayed-stream@1.0.0: {}
devalue@5.6.1: {} devalue@5.6.1: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
es-errors: 1.3.0
gopd: 1.2.0
email-validator@2.0.4: {}
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
es-set-tostringtag@2.1.0:
dependencies:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.2
esbuild@0.27.1: esbuild@0.27.1:
optionalDependencies: optionalDependencies:
'@esbuild/aix-ppc64': 0.27.1 '@esbuild/aix-ppc64': 0.27.1
@@ -1936,17 +1777,6 @@ snapshots:
esutils@2.0.3: {} esutils@2.0.3: {}
facebook-nodejs-business-sdk@24.0.1:
dependencies:
axios: 1.13.2
currency-codes: 1.5.1
email-validator: 2.0.4
iso-3166-1: 2.1.1
js-sha256: 0.9.0
mixwith: 0.1.1
transitivePeerDependencies:
- debug
fast-deep-equal@3.1.3: {} fast-deep-equal@3.1.3: {}
fast-json-stable-stringify@2.1.0: {} fast-json-stable-stringify@2.1.0: {}
@@ -1966,8 +1796,6 @@ snapshots:
locate-path: 6.0.0 locate-path: 6.0.0
path-exists: 4.0.0 path-exists: 4.0.0
first-match@0.0.1: {}
flat-cache@4.0.1: flat-cache@4.0.1:
dependencies: dependencies:
flatted: 3.3.3 flatted: 3.3.3
@@ -1975,39 +1803,9 @@ snapshots:
flatted@3.3.3: {} flatted@3.3.3: {}
follow-redirects@1.15.11: {}
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.2
mime-types: 2.1.35
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
function-bind@1.1.2: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.1.1
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.2
math-intrinsics: 1.1.0
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
es-object-atoms: 1.1.1
glob-parent@6.0.2: glob-parent@6.0.2:
dependencies: dependencies:
is-glob: 4.0.3 is-glob: 4.0.3
@@ -2016,20 +1814,8 @@ snapshots:
globals@16.5.0: {} globals@16.5.0: {}
gopd@1.2.0: {}
has-flag@4.0.0: {} has-flag@4.0.0: {}
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.1.0
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
http-status-codes@2.3.0: {} http-status-codes@2.3.0: {}
ignore@5.3.2: {} ignore@5.3.2: {}
@@ -2055,10 +1841,6 @@ snapshots:
isexe@2.0.0: {} isexe@2.0.0: {}
iso-3166-1@2.1.1: {}
js-sha256@0.9.0: {}
js-yaml@4.1.1: js-yaml@4.1.1:
dependencies: dependencies:
argparse: 2.0.1 argparse: 2.0.1
@@ -2098,14 +1880,6 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
math-intrinsics@1.1.0: {}
mime-db@1.52.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
minimatch@3.1.2: minimatch@3.1.2:
dependencies: dependencies:
brace-expansion: 1.1.12 brace-expansion: 1.1.12
@@ -2114,8 +1888,6 @@ snapshots:
dependencies: dependencies:
brace-expansion: 2.0.2 brace-expansion: 2.0.2
mixwith@0.1.1: {}
mri@1.2.0: {} mri@1.2.0: {}
mrmime@2.0.1: {} mrmime@2.0.1: {}
@@ -2126,8 +1898,6 @@ snapshots:
natural-compare@1.4.0: {} natural-compare@1.4.0: {}
nub@0.0.0: {}
optionator@0.9.4: optionator@0.9.4:
dependencies: dependencies:
deep-is: 0.1.4 deep-is: 0.1.4
@@ -2194,8 +1964,6 @@ snapshots:
prettier@3.7.4: {} prettier@3.7.4: {}
proxy-from-env@1.1.0: {}
publint@0.3.16: publint@0.3.16:
dependencies: dependencies:
'@publint/pack': 0.1.2 '@publint/pack': 0.1.2
@@ -2353,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

@@ -64,7 +64,7 @@
} }
}); });
if (dev) log.info('[Umami] [dev]: 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]);
}
}

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

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

@@ -0,0 +1,74 @@
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 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

@@ -1,92 +0,0 @@
import { getFbpFbc } from '../metapixel/fbc.ts';
import type { TrackingManager } from '$lib/tracking.svelte';
import type {
ConversionErrorResponseBody,
ConversionEventParams,
ConversionRequestBody,
ConversionResponseBody,
ConversionUserData
} from '$lib/types/conversion.js';
import type { StandardEventName } from '$lib/types/fbq.js';
/**
* Client for sending conversion events to a server endpoint.
*/
export class ConversionClient {
private _href: string;
private _trackingManager: TrackingManager;
/**
* Creates a new ConversionClient.
*
* @param serverHref - The server endpoint URL.
* @param trackingManager - The tracking manager instance.
*/
constructor(serverHref: string, trackingManager: TrackingManager) {
this._href = serverHref;
this._trackingManager = trackingManager;
}
/**
* Sends a conversion event to the server.
*
* @param eventName - The name of the standard event to send.
* @param options - Additional options for the event.
* @returns A promise that resolves to the event response or error.
*/
async trackEvent(
eventName: StandardEventName,
options: {
eventID: string;
user?: Omit<ConversionUserData, 'ip' | 'fbp' | 'fbc' | 'ua'>;
customData?: ConversionEventParams;
}
): Promise<ConversionResponseBody | ConversionErrorResponseBody> {
// Extract user data
const { fbp, fbc } = getFbpFbc();
const user: ConversionUserData = {
...options.user,
fbp,
fbc
};
// Get event source URL & extract UTM params if present
const eventSourceURL = window.location.href;
const url = new URL(eventSourceURL);
const utms: Record<string, string> = {};
url.searchParams.forEach((value, key) => {
if (key.startsWith('utm_')) {
utms[key] = value;
}
});
// Build request body
const requestBody: ConversionRequestBody = {
consent: this._trackingManager.consent === true,
eventName,
eventID: options.eventID,
user,
eventSourceURL,
utms,
customData: options.customData
};
// Send request to server
const response = await fetch(this._href, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody)
});
if (response.ok) {
const data = (await response.json()) as ConversionResponseBody;
return data;
} else {
const errorData = (await response.json()) as ConversionErrorResponseBody;
return errorData;
}
}
}

View File

@@ -1,157 +0,0 @@
import {
Content,
CustomData,
EventRequest,
ServerEvent,
UserData
} from 'facebook-nodejs-business-sdk';
import type { StandardEventName } from '../types/fbq.js';
import type {
ConversionEventDetails,
ConversionEventParams,
ConversionResponseBody,
ConversionUserData
} from '$lib/types/conversion.js';
import { dev } from '$app/environment';
import log from 'loglevel';
/**
* Builds UserData for conversion events.
*
* @param data - The user data to include.
* @returns The constructed UserData object.
*/
const buildUserData = (data: ConversionUserData): UserData => {
const userData = new UserData();
if (data.email) userData.setEmail(data.email);
if (data.phone) userData.setPhone(data.phone);
if (data.firstName) userData.setFirstName(data.firstName);
if (data.lastName) userData.setLastName(data.lastName);
if (data.ip) userData.setClientIpAddress(data.ip);
if (data.fbp) userData.setFbp(data.fbp);
if (data.fbc) userData.setFbc(data.fbc);
if (data.ua) userData.setClientUserAgent(data.ua);
if (data.externalId) userData.setExternalId(data.externalId);
return userData;
};
/**
* Builds CustomData for conversion events.
*
* @param params - The custom data parameters.
* @returns The constructed CustomData object.
*/
const buildCustomData = (params: ConversionEventParams): CustomData => {
const c = new CustomData();
if (params.value) c.setValue(params.value);
if (params.net_revenue) c.setNetRevenue(params.net_revenue);
if (params.currency) c.setCurrency(params.currency);
if (params.content_name) c.setContentName(params.content_name);
if (params.content_category) c.setContentCategory(params.content_category);
if (params.content_ids) c.setContentIds(params.content_ids);
if (params.contents) {
const contents: Content[] = params.contents.map((content) => {
const c = new Content();
if (content.id) c.setId(content.id.toString());
if (content.quantity) c.setQuantity(content.quantity);
if (content.item_price) c.setItemPrice(content.item_price);
if (content.title) c.setTitle(content.title);
if (content.description) c.setDescription(content.description);
if (content.category) c.setCategory(content.category);
if (content.brand) c.setBrand(content.brand);
if (content.delivery_category) c.setDeliveryCategory(content.delivery_category);
return c;
});
c.setContents(contents);
}
if (params.content_type) c.setContentType(params.content_type);
if (params.order_id) c.setOrderId(params.order_id);
if (params.predicted_ltv) c.setPredictedLtv(params.predicted_ltv);
if (params.num_items) c.setNumItems(params.num_items);
if (params.search_string) c.setSearchString(params.search_string);
if (params.status) c.setStatus(params.status);
if (params.item_number) c.setItemNumber(params.item_number);
if (params.delivery_category) c.setDeliveryCategory(params.delivery_category);
if (params.custom_properties) c.setCustomProperties(params.custom_properties);
return c;
};
/**
* Control class for sending Conversion API events to Meta Pixel.
*/
export class ConversionControl {
private _accessToken: string;
private _pixelID: string;
private _testEventCode?: string;
/**
* Creates a new ConversionControl instance.
*
* @param accessToken - Your Meta Pixel Conversion API access token.
* @param pixelID - Your Meta Pixel ID.
* @param testEventCode - Optional test event code for testing events.
*/
constructor(accessToken: string, pixelID: string, testEventCode?: string) {
this._accessToken = accessToken;
this._pixelID = pixelID;
this._testEventCode = testEventCode;
}
/**
* Sends a conversion event to Meta Pixel. Requires a test event code for
* events to be sent to Meta when in development mode.
*
* @param eventName - The name of the standard event to send.
* @param options - Additional options for the event.
* @returns A promise that resolves to the event response.
*/
async trackEvent(
eventName: StandardEventName,
details: ConversionEventDetails,
params?: ConversionEventParams
): Promise<ConversionResponseBody> {
const event = new ServerEvent()
.setEventName(eventName)
.setEventTime(Math.floor(Date.now() / 1000))
.setEventId(details.eventID)
.setUserData(buildUserData(details.userData))
.setActionSource(details.actionSource);
if (details?.eventSourceURL) event.setEventSourceUrl(details.eventSourceURL);
if (params) {
const customData = buildCustomData(params);
event.setCustomData(customData);
}
// If we're in dev mode and missing a test event code, log and exit
if (dev && !this._testEventCode) {
log.debug(
`[ConversionControl] ${eventName} event not sent - missing test event code in dev mode.`
);
return Promise.resolve({
pixelID: this._pixelID,
fbtrace_id: 'dev-mode-no-test-event-code',
receivedEvents: 1,
processedEvents: 0,
messages: ['Event not sent - missing test event code in dev mode.']
});
}
const req = new EventRequest(this._accessToken, this._pixelID).setEvents([event]);
if (this._testEventCode) req.setTestEventCode(this._testEventCode);
const response = await req.execute();
const structuredResponse: ConversionResponseBody = {
pixelID: response.id,
fbtrace_id: response.fbtrace_id,
receivedEvents: response.events_received,
processedEvents: response.num_processed_entries,
messages: response.messages
};
log.debug(
`[ConversionControl] Sent ${eventName}, Event response: ${JSON.stringify(structuredResponse)}`
);
return structuredResponse;
}
}

View File

@@ -1,3 +0,0 @@
export * from './client.ts';
export * from './server.ts';
export * from './control.ts';

View File

@@ -1,74 +0,0 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { ConversionControl } from './control.ts';
import type {
ConversionErrorResponseBody,
ConversionEventParams,
ConversionRequestBody,
ConversionUserData
} from '$lib/types/conversion.js';
import { StatusCodes } from 'http-status-codes';
const getEventIP = (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()
);
};
export const createConversionRequestHandler: (control: ConversionControl) => RequestHandler = (
control
) => {
const handle: RequestHandler = async ({ request, getClientAddress }) => {
try {
const body = (await request.json()) as ConversionRequestBody;
// Build user data with IP and user agent
const ip = getEventIP(request, getClientAddress);
const ua = request.headers.get('user-agent');
const userData: ConversionUserData = {
...body.user,
ip,
ua: ua ?? body.user?.ua ?? undefined
};
// Build custom data with UTM params if applicable
let customData: ConversionEventParams = body.customData ?? {};
if (body.eventName === 'PageView' && body.utms) {
// For PageView events, automatically include UTM params if provided
customData = {
...customData,
...body.utms
};
}
// Send the event via the control
const response = await control.trackEvent(
body.eventName,
{
eventID: body.eventID,
eventSourceURL: body.eventSourceURL,
actionSource: 'website',
userData
},
customData
);
return json(response, { status: StatusCodes.OK });
} catch (e) {
return json(
{ error: e instanceof Error ? e.message : String(e) } as ConversionErrorResponseBody,
{ status: StatusCodes.INTERNAL_SERVER_ERROR }
);
}
};
return handle;
};

View File

@@ -3,14 +3,17 @@
import { dev } from '$app/environment'; import { dev } from '$app/environment';
import log from 'loglevel'; import log from 'loglevel';
export type * from './types/conversion.d.ts';
export type * as fbq from './types/fbq.d.ts'; export type * as fbq from './types/fbq.d.ts';
export * from './metapixel/index.ts'; 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 './conversion/index.ts'; 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

@@ -10,7 +10,7 @@ PixelControl interface.
import type { TrackingManager } from '../tracking.svelte.ts'; import type { TrackingManager } from '../tracking.svelte.ts';
import { onNavigate } from '$app/navigation'; import { onNavigate } from '$app/navigation';
import { PixelControl, type PixelControlOptions } from './pixel-control.svelte.ts'; import { PixelControl, type PixelControlOptions } from './pixel-control.ts';
import { ensureFbc, type EnsureFbcOptions } from './fbc.ts'; import { ensureFbc, type EnsureFbcOptions } from './fbc.ts';
interface Props { interface Props {
@@ -38,22 +38,20 @@ PixelControl interface.
let pixel = $state<PixelControl | null>(null); let pixel = $state<PixelControl | null>(null);
const ensureFbcOptions: EnsureFbcOptions = $derived({ const fbcOptions: EnsureFbcOptions = {
sameSite: 'Lax', sameSite: 'Lax'
pixelLoaded: PixelControl.baseLoaded && !PixelControl.baseFailed };
});
onMount(() => { onMount(() => {
if (!trackingManager) { if (!trackingManager) {
throw new Error('MetaPixel component requires a TrackingManager to manage consent.'); throw new Error('MetaPixel component requires a TrackingManager to manage consent.');
} }
PixelControl.load();
pixel = PixelControl.initialize(trackingManager, pixelID, pixelOptions); pixel = PixelControl.initialize(trackingManager, pixelID, pixelOptions);
trackingManager.runWithConsent(() => { trackingManager.runWithConsent(() => {
if (autoPageView && pixel) { if (autoPageView && pixel) {
pixel.pageView(); pixel.pageView();
ensureFbc(ensureFbcOptions); ensureFbc(fbcOptions);
} }
}); });
}); });
@@ -62,7 +60,7 @@ PixelControl interface.
trackingManager?.runWithConsent(() => { trackingManager?.runWithConsent(() => {
if (autoPageView && pixel) { if (autoPageView && pixel) {
pixel.pageView(); pixel.pageView();
ensureFbc(ensureFbcOptions); ensureFbc(fbcOptions);
} }
}); });
}); });

View File

@@ -1,3 +1,4 @@
import type { Cookies } from '@sveltejs/kit';
import log from 'loglevel'; import log from 'loglevel';
export type EnsureFbcOptions = { export type EnsureFbcOptions = {
@@ -79,10 +80,19 @@ export function ensureFbc(options: EnsureFbcOptions = {}) {
} }
/** /**
* Helper to read both _fbp and _fbc for your CAPI payload. * Helper to read both _fbp and _fbc for your CAPI payload from browser cookies.
*/ */
export function getFbpFbc(): { fbp?: string; fbc?: string } { export function getFbpFbc(): { fbp?: string; fbc?: string } {
const fbp = getCookie('_fbp'); const fbp = getCookie('_fbp');
const fbc = getCookie('_fbc'); const fbc = getCookie('_fbc');
return { fbp, 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

@@ -11,18 +11,18 @@ import { loadMetaPixel } from '../util/meta-pixel-loader.ts';
import { resolveGetter, type MaybeGetter } from '../util/getter.ts'; import { resolveGetter, type MaybeGetter } from '../util/getter.ts';
import log from 'loglevel'; import log from 'loglevel';
import { dev } from '$app/environment'; import { dev } from '$app/environment';
import { ConversionClient } from '../conversion/client.ts'; import { CAPIClient } from '../capi/client.ts';
import type { ConversionEventParams } from '$lib/types/conversion.js'; import { ActionSource, CAPIEvent, type CAPIStandardParams } from '../capi/event.ts';
const pixelParamsToCustomData = (params: CommonParams & CustomParams): ConversionEventParams => { const pixelParamsToCustomData = (params: CommonParams & CustomParams): CAPIStandardParams => {
const customData: ConversionEventParams = {}; const customData: CAPIStandardParams = {};
if (params.value) customData.value = params.value; if (params.value) customData.value = params.value;
if (params.currency) customData.currency = params.currency; if (params.currency) customData.currency = params.currency;
if (params.content_name) customData.content_name = params.content_name; if (params.content_name) customData.content_name = params.content_name;
if (params.content_category) customData.content_category = params.content_category; if (params.content_category) customData.content_category = params.content_category;
if (params.content_ids) customData.content_ids = params.content_ids; if (params.content_ids) customData.content_ids = params.content_ids;
if (params.contents) { if (params.contents) {
const acc: ConversionEventParams['contents'] = []; const acc: CAPIStandardParams['contents'] = [];
customData.contents = params.contents.reduce((acc, content) => { customData.contents = params.contents.reduce((acc, content) => {
acc.push({ acc.push({
id: content.id.toString(), id: content.id.toString(),
@@ -66,10 +66,9 @@ export class PixelControl {
private _pixelID: string; private _pixelID: string;
private _testEventCode?: string = undefined; private _testEventCode?: string = undefined;
private _trackingManager: MaybeGetter<TrackingManager | undefined>; private _trackingManager: MaybeGetter<TrackingManager | undefined>;
private _conversionClient?: ConversionClient = undefined; private _conversionClient?: CAPIClient = undefined;
private static _baseLoaded: boolean = $state(false); private static _baseLoaded: boolean = false;
private static _baseFailed: boolean = $state(false);
private static _registeredPixels: Record<string, PixelControl> = {}; private static _registeredPixels: Record<string, PixelControl> = {};
/** Indicates whether the Meta Pixel base script has been loaded. */ /** Indicates whether the Meta Pixel base script has been loaded. */
@@ -77,11 +76,6 @@ export class PixelControl {
return this._baseLoaded; return this._baseLoaded;
} }
/** Indicates whether the Meta Pixel base script has failed to load. */
static get baseFailed(): boolean {
return this._baseFailed;
}
/** /**
* Ensures that the Meta Pixel base has been loaded before * Ensures that the Meta Pixel base has been loaded before
* allowing further operations. * allowing further operations.
@@ -104,10 +98,7 @@ export class PixelControl {
const resolvedTrackingManager = resolveGetter(trackingManager); const resolvedTrackingManager = resolveGetter(trackingManager);
if (options?.conversionHref && resolvedTrackingManager) { if (options?.conversionHref && resolvedTrackingManager) {
this._conversionClient = new ConversionClient( this._conversionClient = new CAPIClient(options.conversionHref, resolvedTrackingManager);
options.conversionHref,
resolvedTrackingManager
);
} else if (options?.conversionHref) { } else if (options?.conversionHref) {
log.warn( log.warn(
`[PixelControl] Conversion Client ${options.conversionHref} for Meta Pixel [${this._pixelID}] not initialized, TrackingManager is required for user consent.` `[PixelControl] Conversion Client ${options.conversionHref} for Meta Pixel [${this._pixelID}] not initialized, TrackingManager is required for user consent.`
@@ -116,20 +107,11 @@ export class PixelControl {
} }
/** Loads the Meta Pixel base script. */ /** Loads the Meta Pixel base script. */
static async load() { static load() {
if (this._baseLoaded && !this.baseFailed && !!window.fbq) return; if (this._baseLoaded && !!window.fbq) return;
if (!window.fbq) { loadMetaPixel(); // Load the Meta Pixel script
try { this._baseLoaded = true;
await loadMetaPixel(); // Load the Meta Pixel script log.debug('[PixelControl] Meta Pixel base script loaded.', this._baseLoaded);
} catch (e) {
log.warn('[PixelControl] Failed to load Meta Pixel script, all events will be queued.', e);
this._baseFailed = true;
return;
} finally {
this._baseLoaded = true;
}
}
log.debug('[PixelControl] Meta Pixel base script loaded.');
} }
/** Tells the Meta pixel that the user has given consent for tracking. */ /** Tells the Meta pixel that the user has given consent for tracking. */
@@ -210,111 +192,137 @@ export class PixelControl {
return trackingManager?.haveUserConsent() ?? false; 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(
`[PixelControl] [${this._pixelID}] Sending events in dev mode without a test event code. ` +
'Consider providing a test event code to avoid affecting real data.'
);
}
}
/** /**
* Sends a PageView event * 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. * @throws Error if the Meta Pixel is not initialized.
*/ */
pageView() { pageView(disableCAPI: boolean = false) {
if (!this.consentGuard()) return; this.track('PageView', undefined, undefined, disableCAPI);
}
let eventID: string | undefined = undefined; /**
// Optionally, send to conversion API endpoint if configured * Forwards an event to the Conversion API client if configured.
if (this._conversionClient) { *
* @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(); eventID = crypto.randomUUID();
this._conversionClient
.trackEvent('PageView', { eventID })
.then((response) => {
log.debug(
`[PixelControl] [${this._pixelID}] PageView event sent to Conversion API with Event ID: ${eventID}, Response: ${JSON.stringify(
response
)}`
);
})
.catch((error) => {
log.error(
`[PixelControl] [${this._pixelID}] Failed to send PageView event to Conversion API with Event ID: ${eventID}`,
error
);
});
} }
// Send the PageView event to Meta this._conversionClient
if (!dev || this._testEventCode) { .trackEvent(
window.fbq('track', 'PageView', undefined, { CAPIEvent.fromOpts({
test_event_code: this._testEventCode, eventName: event,
eventID 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 forwarded to Conversion API with Event ID: ${eventID}, Response: ${JSON.stringify(
response,
null,
2
)}`
);
})
.catch((error) => {
log.error(
`[PixelControl] [${this._pixelID}] Failed to forward ${event} event to Conversion API with Event ID: ${eventID}`,
error
);
}); });
log.debug(
`[PixelControl] [${this._pixelID}] PageView event sent${dev && ` (test code: ${this._testEventCode})`}.` return eventID;
);
} else {
log.info(
`[PixelControl] [${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) * 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. * @throws Error if the Meta Pixel is not initialized.
*/ */
track<K extends StandardEventName>(event: K, params?: EventParamsByName[K], eventID?: string) { track<K extends StandardEventName>(
event: K,
params?: EventParamsByName[K],
eventID?: string,
disableCAPI: boolean = false
) {
if (!this.consentGuard()) return; if (!this.consentGuard()) return;
this.devModeWarn();
// Optionally, send to conversion API endpoint if configured // Optionally, send to conversion API endpoint
if (this._conversionClient) { if (!disableCAPI) {
eventID = eventID ?? crypto.randomUUID(); eventID = this.forwardToCAPI(event, params, eventID);
this._conversionClient
.trackEvent(event, { eventID: eventID, customData: pixelParamsToCustomData(params ?? {}) })
.then((response) => {
log.debug(
`[PixelControl] [${this._pixelID}] ${event} event sent to Conversion API with Event ID: ${eventID}, Response: ${JSON.stringify(
response
)}`
);
})
.catch((error) => {
log.error(
`[PixelControl] [${this._pixelID}] Failed to send ${event} event to Conversion API with Event ID: ${eventID}`,
error
);
});
} }
// Send the PageView event to Meta // Send the event to Meta via the pixel
if (!dev || this._testEventCode) { window.fbq('trackSingle', this._pixelID, event, params, {
window.fbq('trackSingle', this._pixelID, event, params, { eventID,
eventID, test_event_code: this._testEventCode
test_event_code: this._testEventCode });
}); log.debug(
log.debug( `[PixelControl] [${this._pixelID}] ${event} event sent with event ID ${eventID} ${dev && ` (test code: ${this._testEventCode})`}.`
`[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) * 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. * @throws Error if the Meta Pixel is not initialized.
*/ */
trackCustom(event: string, params?: CommonParams & CustomParams, eventID?: string) { trackCustom(
event: string,
params?: CommonParams & CustomParams,
eventID?: string,
disableCAPI: boolean = false
) {
if (!this.consentGuard()) return; if (!this.consentGuard()) return;
if (!dev || this._testEventCode) { this.devModeWarn();
window.fbq('trackSingleCustom', this._pixelID, event, params, {
eventID, // Optionally, send to conversion API endpoint
test_event_code: this._testEventCode if (!disableCAPI) {
}); eventID = this.forwardToCAPI(event, params, eventID);
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.`
);
} }
// Send the event to Meta via the pixel
window.fbq('trackSingleCustom', this._pixelID, event, params, {
eventID,
test_event_code: this._testEventCode
});
log.debug(
`[PixelControl] [${this._pixelID}] ${event} custom event sent with event ID ${eventID} ${dev && ` (test code: ${this._testEventCode})`}.`
);
} }
} }

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

@@ -1,94 +0,0 @@
import type { StandardEventName } from '../types/fbq.js';
/**
* Supported user data fields for conversion events.
*/
export type ConversionUserData = {
email?: string;
phone?: string;
firstName?: string;
lastName?: string;
ip?: string;
fbp?: string;
fbc?: string;
/** user agent */
ua?: string;
externalId?: string;
};
/**
* Supported custom data fields for conversion events.
*/
export type ConversionEventParams = {
value?: number;
net_revenue?: number;
currency?: string;
content_name?: string;
content_category?: string;
content_ids?: string[];
contents?: {
id?: string;
quantity?: number;
item_price?: number;
title?: string;
description?: string;
category?: string;
brand?: string;
delivery_category?: string;
}[];
content_type?: string;
order_id?: string;
predicted_ltv?: number;
num_items?: number;
search_string?: string;
status?: string;
item_number?: string;
delivery_category?: string;
custom_properties?: Record<string, unknown>;
};
/**
* Parameters for sending a conversion event to Meta Pixel.
*/
export type ConversionEventDetails = {
eventID: string;
actionSource: 'website' | 'app' | 'offline' | 'other';
userData: ConversionUserData;
eventSourceURL?: string;
};
/**
* Request body for conversion event tracking.
*/
export type ConversionRequestBody = {
consent: boolean;
eventName: StandardEventName;
eventID: string;
eventSourceURL?: string;
utms?: Partial<
Record<'utm_source' | 'utm_medium' | 'utm_campaign' | 'utm_content' | 'utm_term', string>
>;
user?: Omit<ConversionUserData, 'ip'>;
customData?: ConversionEventParams;
};
/**
* Response body for conversion event tracking.
*/
export type ConversionResponseBody = {
/** 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[];
};
/** Error response body for conversion event tracking. */
export type ConversionErrorResponseBody = {
error: string;
};

View File

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