From b6e08302090e49f0020f43a72976adeaeea1a662 Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Sun, 21 Dec 2025 21:10:40 -0800 Subject: [PATCH] refactor conversion API to avoid facebook-nodejs-business-sdk uses direct meta graph endpoints instead. --- package.json | 4 +- pnpm-lock.yaml | 258 ++-------------------- src/lib/conversion/client.ts | 116 +++++----- src/lib/conversion/connector.ts | 97 +++++++++ src/lib/conversion/control.ts | 157 -------------- src/lib/conversion/event.ts | 329 +++++++++++++++++++++++++++++ src/lib/conversion/handle.ts | 76 +++++++ src/lib/conversion/index.ts | 5 +- src/lib/conversion/server.ts | 78 ------- src/lib/metapixel/pixel-control.ts | 137 ++++++------ src/lib/types/conversion.d.ts | 94 --------- src/lib/types/fbq.d.ts | 18 +- src/lib/util/ip.ts | 20 ++ 13 files changed, 684 insertions(+), 705 deletions(-) create mode 100644 src/lib/conversion/connector.ts delete mode 100644 src/lib/conversion/control.ts create mode 100644 src/lib/conversion/event.ts create mode 100644 src/lib/conversion/handle.ts delete mode 100644 src/lib/conversion/server.ts delete mode 100644 src/lib/types/conversion.d.ts create mode 100644 src/lib/util/ip.ts diff --git a/package.json b/package.json index a233b6a..8c3606b 100644 --- a/package.json +++ b/package.json @@ -64,9 +64,9 @@ "dependencies": { "@types/facebook-nodejs-business-sdk": "^23.0.0", "@types/umami": "^2.10.1", - "facebook-nodejs-business-sdk": "^24.0.1", "http-status-codes": "^2.3.0", - "loglevel": "^1.9.2" + "loglevel": "^1.9.2", + "valibot": "^1.2.0" }, "publishConfig": { "registry": "https://gitea.auvem.com/api/packages/svelte-toolkit/npm/" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a509694..ed12b02 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,15 +14,15 @@ importers: '@types/umami': specifier: ^2.10.1 version: 2.10.1 - facebook-nodejs-business-sdk: - specifier: ^24.0.1 - version: 24.0.1 http-status-codes: specifier: ^2.3.0 version: 2.3.0 loglevel: specifier: ^1.9.2 version: 1.9.2 + valibot: + specifier: ^1.2.0 + version: 1.2.0(typescript@5.9.3) devDependencies: '@eslint/compat': specifier: ^1.4.0 @@ -577,12 +577,6 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} 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: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -596,10 +590,6 @@ packages: brace-expansion@2.0.2: 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: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -627,10 +617,6 @@ packages: color-name@1.1.4: 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: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -647,9 +633,6 @@ packages: engines: {node: '>=4'} hasBin: true - currency-codes@1.5.1: - resolution: {integrity: sha512-hqy8vtlIYKzO6pe2TE0V4/riZALIc7nhtE9cvxk5FDRCvfGplgzUvpTmZlMsyO+NeK5U41j+sQXJOo8l8v9kdg==} - debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -669,37 +652,9 @@ packages: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - devalue@5.6.1: 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: resolution: {integrity: sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==} engines: {node: '>=18'} @@ -773,9 +728,6 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - facebook-nodejs-business-sdk@24.0.1: - resolution: {integrity: sha512-31uR42FO+V3ikQfVZyrnoc34pVYzxo4PE+BnKt1ziX9nGhSwsS7ogGDsMJQk7A3SdoWPIGgR2G4UbAY2s9zNVQ==} - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -802,9 +754,6 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} - first-match@0.0.1: - resolution: {integrity: sha512-VvKbnaxrC0polTFDC+teKPTdl2mn6B/KUW+WB3C9RzKDeNwbzfLdnUz3FxC+tnjvus6bI0jWrWicQyVIPdS37A==} - flat-cache@4.0.1: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} @@ -812,35 +761,11 @@ packages: flatted@3.3.3: 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: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} 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: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -853,26 +778,10 @@ packages: resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} engines: {node: '>=18'} - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} 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: resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==} @@ -906,12 +815,6 @@ packages: isexe@2.0.0: 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: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -960,18 +863,6 @@ packages: magic-string@0.30.21: 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: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -979,9 +870,6 @@ packages: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} - mixwith@0.1.1: - resolution: {integrity: sha512-DQsf/liljH/9e+94jR+xfK8vlKceeKdOM9H9UEXLwGuvEEpO6debNtJ9yt1ZKzPKPrwqGxzMdu0BR1fnQb6i4A==} - mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -1001,9 +889,6 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - nub@0.0.0: - resolution: {integrity: sha512-dK0Ss9C34R/vV0FfYJXuqDAqHlaW9fvWVufq9MmGF2umCuDbd5GRfRD9fpi/LiM0l4ZXf8IBB+RYmZExqCrf0w==} - optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1085,9 +970,6 @@ packages: engines: {node: '>=14'} hasBin: true - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - publint@0.3.16: resolution: {integrity: sha512-MFqyfRLAExPVZdTQFwkAQELzA8idyXzROVOytg6nEJ/GEypXBUmMGrVaID8cTuzRS1U5L8yTOdOJtMXgFUJAeA==} engines: {node: '>=18'} @@ -1219,6 +1101,14 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + valibot@1.2.0: + resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + vite@7.3.0: resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1702,16 +1592,6 @@ snapshots: 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: {} balanced-match@1.0.2: {} @@ -1725,11 +1605,6 @@ snapshots: dependencies: 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: {} chalk@4.1.2: @@ -1753,10 +1628,6 @@ snapshots: color-name@1.1.4: {} - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - concat-map@0.0.1: {} cookie@0.6.0: {} @@ -1769,11 +1640,6 @@ snapshots: cssesc@3.0.0: {} - currency-codes@1.5.1: - dependencies: - first-match: 0.0.1 - nub: 0.0.0 - debug@4.4.3: dependencies: ms: 2.1.3 @@ -1784,33 +1650,8 @@ snapshots: deepmerge@4.3.1: {} - delayed-stream@1.0.0: {} - 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: optionalDependencies: '@esbuild/aix-ppc64': 0.27.1 @@ -1936,17 +1777,6 @@ snapshots: 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-json-stable-stringify@2.1.0: {} @@ -1966,8 +1796,6 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 - first-match@0.0.1: {} - flat-cache@4.0.1: dependencies: flatted: 3.3.3 @@ -1975,39 +1803,9 @@ snapshots: 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: 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: dependencies: is-glob: 4.0.3 @@ -2016,20 +1814,8 @@ snapshots: globals@16.5.0: {} - gopd@1.2.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: {} ignore@5.3.2: {} @@ -2055,10 +1841,6 @@ snapshots: isexe@2.0.0: {} - iso-3166-1@2.1.1: {} - - js-sha256@0.9.0: {} - js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -2098,14 +1880,6 @@ snapshots: dependencies: '@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: dependencies: brace-expansion: 1.1.12 @@ -2114,8 +1888,6 @@ snapshots: dependencies: brace-expansion: 2.0.2 - mixwith@0.1.1: {} - mri@1.2.0: {} mrmime@2.0.1: {} @@ -2126,8 +1898,6 @@ snapshots: natural-compare@1.4.0: {} - nub@0.0.0: {} - optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -2194,8 +1964,6 @@ snapshots: prettier@3.7.4: {} - proxy-from-env@1.1.0: {} - publint@0.3.16: dependencies: '@publint/pack': 0.1.2 @@ -2353,6 +2121,10 @@ snapshots: util-deprecate@1.0.2: {} + valibot@1.2.0(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + vite@7.3.0(@types/node@24.10.4): dependencies: esbuild: 0.27.1 diff --git a/src/lib/conversion/client.ts b/src/lib/conversion/client.ts index cfbf5d8..f5c0846 100644 --- a/src/lib/conversion/client.ts +++ b/src/lib/conversion/client.ts @@ -1,26 +1,29 @@ 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'; +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 for sending conversion events to a server endpoint. + * Client abstracts HTTP communication with a spectator server endpoint for + * sending conversion events to Meta Conversions API. */ -export class ConversionClient { +export class CAPIClient { private _href: string; private _trackingManager: TrackingManager; /** - * Creates a new ConversionClient. + * Creates a new CAPIClient. * - * @param serverHref - The server endpoint URL. - * @param trackingManager - The tracking manager instance. + * @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; @@ -28,65 +31,64 @@ export class ConversionClient { } /** - * 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. + * 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 trackEvent( - eventName: StandardEventName, - options: { - eventID: string; - user?: Omit; - customData?: ConversionEventParams; - } - ): Promise { - // 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 = {}; - url.searchParams.forEach((value, key) => { - if (key.startsWith('utm_')) { - utms[key] = value; + async sendEvents(events: CAPIEvent[]): Promise { + // 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 = { fbp, fbc }; // Build request body - const requestBody: ConversionRequestBody = { - consent: this._trackingManager.consent === true, - eventName, - eventID: options.eventID, - user, - eventSourceURL, - utms, - customData: options.customData + 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 - // Send request to server const response = await fetch(this._href, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody) + body: JSON.stringify(body) }); + const json = await response.json(); if (response.ok) { - const data = (await response.json()) as ConversionResponseBody; - return data; + const parsed = v.parse(capiResponseBodySchema, json); + return parsed as CAPIResponseBody; } else { - const errorData = (await response.json()) as ConversionErrorResponseBody; - return errorData; + 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 { + return this.sendEvents([event]); + } } diff --git a/src/lib/conversion/connector.ts b/src/lib/conversion/connector.ts new file mode 100644 index 0000000..4ca7cc3 --- /dev/null +++ b/src/lib/conversion/connector.ts @@ -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 { + 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 { + return this.sendEvents([event]); + } +} diff --git a/src/lib/conversion/control.ts b/src/lib/conversion/control.ts deleted file mode 100644 index 8435c23..0000000 --- a/src/lib/conversion/control.ts +++ /dev/null @@ -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 { - const event = new ServerEvent() - .setEventName(eventName) - .setEventTime(Math.floor(Date.now() / 1000)) - .setUserData(buildUserData(details.userData)) - .setActionSource(details.actionSource); - - if (details.eventID) event.setEventId(details.eventID); - 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; - } -} diff --git a/src/lib/conversion/event.ts b/src/lib/conversion/event.ts new file mode 100644 index 0000000..962509d --- /dev/null +++ b/src/lib/conversion/event.ts @@ -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; + +/** 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) => { + 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; + +/** 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; + +/** + * 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) { + 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); + } +} diff --git a/src/lib/conversion/handle.ts b/src/lib/conversion/handle.ts new file mode 100644 index 0000000..9ad1ff4 --- /dev/null +++ b/src/lib/conversion/handle.ts @@ -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; + +export const capiErrorBodySchema = v.object({ + error: v.string() +}); + +/** Returned by the conversion request handler in case of an error */ +export type CAPIErrorBody = v.InferOutput; + +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; + +/** + * 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 = { + 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; +}; diff --git a/src/lib/conversion/index.ts b/src/lib/conversion/index.ts index 252dc22..d97d2a8 100644 --- a/src/lib/conversion/index.ts +++ b/src/lib/conversion/index.ts @@ -1,3 +1,4 @@ +export * from './event.ts'; +export * from './connector.ts'; +export * from './handle.ts'; export * from './client.ts'; -export * from './server.ts'; -export * from './control.ts'; diff --git a/src/lib/conversion/server.ts b/src/lib/conversion/server.ts deleted file mode 100644 index fa28434..0000000 --- a/src/lib/conversion/server.ts +++ /dev/null @@ -1,78 +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'; -import { getFbpFbcFromCookies } from '../metapixel/fbc.ts'; - -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() - ); -}; - -export const createConversionRequestHandler: (control: ConversionControl) => RequestHandler = ( - control -) => { - const handle: RequestHandler = async ({ request, getClientAddress, cookies }) => { - try { - const body = (await request.json()) as ConversionRequestBody; - - // Build user data with IP and user agent - const ip = getRequestIP(request, getClientAddress); - const ua = request.headers.get('user-agent'); - const { fbp, fbc } = getFbpFbcFromCookies(cookies); - - const userData: ConversionUserData = { - ...body.user, - ip, - ua: ua ?? body.user?.ua ?? undefined, - fbc: body.user?.fbc ?? fbc, - fbp: body.user?.fbp ?? fbp - }; - - // 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; -}; diff --git a/src/lib/metapixel/pixel-control.ts b/src/lib/metapixel/pixel-control.ts index fbe93e4..1f532cd 100644 --- a/src/lib/metapixel/pixel-control.ts +++ b/src/lib/metapixel/pixel-control.ts @@ -11,18 +11,18 @@ 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 { ConversionClient } from '../conversion/client.ts'; -import type { ConversionEventParams } from '$lib/types/conversion.js'; +import { CAPIClient } from '../conversion/client.ts'; +import { ActionSource, CAPIEvent, type CAPIStandardParams } from '../conversion/event.ts'; -const pixelParamsToCustomData = (params: CommonParams & CustomParams): ConversionEventParams => { - const customData: ConversionEventParams = {}; +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: ConversionEventParams['contents'] = []; + const acc: CAPIStandardParams['contents'] = []; customData.contents = params.contents.reduce((acc, content) => { acc.push({ id: content.id.toString(), @@ -66,7 +66,7 @@ export class PixelControl { private _pixelID: string; private _testEventCode?: string = undefined; private _trackingManager: MaybeGetter; - private _conversionClient?: ConversionClient = undefined; + private _conversionClient?: CAPIClient = undefined; private static _baseLoaded: boolean = false; private static _registeredPixels: Record = {}; @@ -98,10 +98,7 @@ export class PixelControl { const resolvedTrackingManager = resolveGetter(trackingManager); if (options?.conversionHref && resolvedTrackingManager) { - this._conversionClient = new ConversionClient( - 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.` @@ -196,48 +193,61 @@ export class PixelControl { } /** - * 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. */ 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 - if (this._conversionClient && !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('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 - if (!dev || this._testEventCode) { - window.fbq('track', 'PageView', undefined, { - test_event_code: this._testEventCode, - eventID + 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 + ); }); - log.debug( - `[PixelControl] [${this._pixelID}] PageView event sent${dev && ` (test code: ${this._testEventCode})`}.` - ); - } else { - log.info( - `[PixelControl] [${this._pixelID}] PageView event not sent in development mode without a test event code.` - ); - } } /** @@ -256,27 +266,12 @@ export class PixelControl { ) { if (!this.consentGuard()) return; - // Optionally, send to conversion API endpoint if configured - if (this._conversionClient && !disableCAPI) { - eventID = eventID ?? crypto.randomUUID(); - 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 - ); - }); + // Optionally, send to conversion API endpoint + if (!disableCAPI) { + eventID = this.forwardToCAPI(event, params, eventID); } - // 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, { eventID, @@ -294,10 +289,26 @@ export class PixelControl { /** * 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) { + 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, diff --git a/src/lib/types/conversion.d.ts b/src/lib/types/conversion.d.ts deleted file mode 100644 index 775b310..0000000 --- a/src/lib/types/conversion.d.ts +++ /dev/null @@ -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; -}; - -/** - * 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; - 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; -}; diff --git a/src/lib/types/fbq.d.ts b/src/lib/types/fbq.d.ts index b0a9c26..6b84050 100644 --- a/src/lib/types/fbq.d.ts +++ b/src/lib/types/fbq.d.ts @@ -230,27 +230,27 @@ export type StandardEventName = */ export type AdvancedMatching = { /** Primary contact email (or hashed email) */ - email?: string; + em?: string; /** Phone number (E.164 or local) */ - phone?: string; + ph?: string; /** First name */ - first_name?: string; + fn?: string; /** Last name */ - last_name?: string; + ln?: string; /** City */ - city?: string; + ct?: string; /** State/region */ - state?: string; + st?: string; /** Postal / ZIP code */ - zip?: string; + zp?: string; /** Country code */ country?: string; /** External id to match users (optional) */ external_id?: string; /** Gender */ - gender?: string; + ge?: string; /** Date of birth (ISO-like or YYYY-MM-DD) */ - date_of_birth?: string; + db?: string; // allow additional provider-specific keys [key: string]: string | undefined; }; diff --git a/src/lib/util/ip.ts b/src/lib/util/ip.ts new file mode 100644 index 0000000..734ef68 --- /dev/null +++ b/src/lib/util/ip.ts @@ -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() + ); +};