@shware/analytics 9.0.0 → 9.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/hooks/use-web-analytics.cjs +5 -1
- package/dist/hooks/use-web-analytics.cjs.map +1 -1
- package/dist/hooks/use-web-analytics.d.cts.map +1 -1
- package/dist/hooks/use-web-analytics.d.mts.map +1 -1
- package/dist/hooks/use-web-analytics.mjs +6 -2
- package/dist/hooks/use-web-analytics.mjs.map +1 -1
- package/dist/index.cjs +1 -0
- package/dist/index.d.cts +2 -2
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +2 -2
- package/dist/server/google-data-manager.cjs +3 -3
- package/dist/server/google-data-manager.cjs.map +1 -1
- package/dist/server/google-data-manager.d.cts +15 -2
- package/dist/server/google-data-manager.d.cts.map +1 -1
- package/dist/server/google-data-manager.d.mts +15 -2
- package/dist/server/google-data-manager.d.mts.map +1 -1
- package/dist/server/google-data-manager.mjs +3 -3
- package/dist/server/google-data-manager.mjs.map +1 -1
- package/dist/server/index.d.cts +2 -2
- package/dist/server/index.d.mts +2 -2
- package/dist/server/linkedin-conversions-api.cjs +6 -1
- package/dist/server/linkedin-conversions-api.cjs.map +1 -1
- package/dist/server/linkedin-conversions-api.d.cts.map +1 -1
- package/dist/server/linkedin-conversions-api.d.mts.map +1 -1
- package/dist/server/linkedin-conversions-api.mjs +6 -1
- package/dist/server/linkedin-conversions-api.mjs.map +1 -1
- package/dist/server/meta-capi.cjs +7 -1
- package/dist/server/meta-capi.cjs.map +1 -1
- package/dist/server/meta-capi.d.cts.map +1 -1
- package/dist/server/meta-capi.d.mts.map +1 -1
- package/dist/server/meta-capi.mjs +7 -1
- package/dist/server/meta-capi.mjs.map +1 -1
- package/dist/server/meta-conversions-api.cjs +3 -1
- package/dist/server/meta-conversions-api.cjs.map +1 -1
- package/dist/server/meta-conversions-api.d.cts.map +1 -1
- package/dist/server/meta-conversions-api.d.mts.map +1 -1
- package/dist/server/meta-conversions-api.mjs +3 -1
- package/dist/server/meta-conversions-api.mjs.map +1 -1
- package/dist/server/microsoft-conversions-api.cjs +3 -1
- package/dist/server/microsoft-conversions-api.cjs.map +1 -1
- package/dist/server/microsoft-conversions-api.d.cts.map +1 -1
- package/dist/server/microsoft-conversions-api.d.mts.map +1 -1
- package/dist/server/microsoft-conversions-api.mjs +3 -1
- package/dist/server/microsoft-conversions-api.mjs.map +1 -1
- package/dist/server/openai-conversions-api.cjs +13 -12
- package/dist/server/openai-conversions-api.cjs.map +1 -1
- package/dist/server/openai-conversions-api.d.cts.map +1 -1
- package/dist/server/openai-conversions-api.d.mts.map +1 -1
- package/dist/server/openai-conversions-api.mjs +14 -13
- package/dist/server/openai-conversions-api.mjs.map +1 -1
- package/dist/server/reddit-conversions-api.cjs +6 -2
- package/dist/server/reddit-conversions-api.cjs.map +1 -1
- package/dist/server/reddit-conversions-api.d.cts +10 -1
- package/dist/server/reddit-conversions-api.d.cts.map +1 -1
- package/dist/server/reddit-conversions-api.d.mts +10 -1
- package/dist/server/reddit-conversions-api.d.mts.map +1 -1
- package/dist/server/reddit-conversions-api.mjs +6 -2
- package/dist/server/reddit-conversions-api.mjs.map +1 -1
- package/dist/setup/session.cjs +1 -1
- package/dist/setup/session.cjs.map +1 -1
- package/dist/setup/session.d.cts +6 -5
- package/dist/setup/session.d.cts.map +1 -1
- package/dist/setup/session.d.mts +6 -5
- package/dist/setup/session.d.mts.map +1 -1
- package/dist/setup/session.mjs +1 -1
- package/dist/setup/session.mjs.map +1 -1
- package/dist/third-parties/openai-pixel.cjs +29 -13
- package/dist/third-parties/openai-pixel.cjs.map +1 -1
- package/dist/third-parties/openai-pixel.d.cts +4 -3
- package/dist/third-parties/openai-pixel.d.cts.map +1 -1
- package/dist/third-parties/openai-pixel.d.mts +4 -3
- package/dist/third-parties/openai-pixel.d.mts.map +1 -1
- package/dist/third-parties/openai-pixel.mjs +30 -14
- package/dist/third-parties/openai-pixel.mjs.map +1 -1
- package/dist/track/index.cjs +156 -89
- package/dist/track/index.cjs.map +1 -1
- package/dist/track/index.d.cts +17 -0
- package/dist/track/index.d.cts.map +1 -1
- package/dist/track/index.d.mts +17 -0
- package/dist/track/index.d.mts.map +1 -1
- package/dist/track/index.mjs +156 -90
- package/dist/track/index.mjs.map +1 -1
- package/dist/track/oaiq.cjs +57 -9
- package/dist/track/oaiq.cjs.map +1 -1
- package/dist/track/oaiq.d.cts +33 -7
- package/dist/track/oaiq.d.cts.map +1 -1
- package/dist/track/oaiq.d.mts +33 -7
- package/dist/track/oaiq.d.mts.map +1 -1
- package/dist/track/oaiq.mjs +55 -10
- package/dist/track/oaiq.mjs.map +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"linkedin-conversions-api.cjs","names":["createHash","getFirst","linkedinFatId","IGNORED_EVENTS","fetch"],"sources":["../../src/server/linkedin-conversions-api.ts"],"sourcesContent":["/**\n * Conversions API Payload Builder: https://www.linkedin.com/developers/payload-builder\n * https://learn.microsoft.com/en-us/linkedin/marketing/conversions/conversions-overview?view=li-lms-2026-09\n */\nimport { createHash } from 'node:crypto';\nimport { fetch } from '@shware/utils';\nimport { IGNORED_EVENTS } from '../third-parties/ignored-events';\nimport type { TrackEvent, TrackTags, UserProvidedData } from '../track/types';\nimport { getFirst } from '../utils/field';\nimport { linkedinFatId } from './click-ids';\n\n/**\n * The identifier types LinkedIn matches on, as of version 202609. `ORACLE_MOAT_ID` used to be\n * here and is no longer in the schema; the IP and Android ones are.\n */\ntype UserIdType =\n | 'SHA256_EMAIL'\n | 'LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID'\n | 'ACXIOM_ID'\n | 'PLAINTEXT_IP_ADDRESS'\n | 'SHA256_IP_ADDRESS'\n | 'GOOGLE_AID';\n\nexport interface CreateLinkedinEventDTO {\n /**\n * For any conversion that you want to send through multiple methods, such as Insight Tag and\n * Conversions API, you must create a conversion rule for each data source (browser and server).\n * Then, you can implement a logic to pick up the eventId from the browser and send it with the\n * corresponding event from your server. If we receive an Insight Tag event and a Conversions API\n * event from the same account with the same eventId, we discard the Conversions API event and\n * count only the Insight Tag event in campaign reporting.\n */\n eventId?: string;\n\n /**\n * Replace <id> with the conversion ID extracted when creating the conversion rule\n * (e.g. urn:lla:llaPartnerConversion:<id>).\n */\n conversion: `urn:lla:llaPartnerConversion:${number}`;\n\n /** Epoch timestamp in milliseconds at which the conversion event happened. */\n conversionHappenedAt: number;\n conversionValue: { currencyCode: string; amount: string };\n user: {\n userIds: { idType: UserIdType; idValue: string }[];\n /**\n * Probabilistic matching fields. `hashedFirstName` / `hashedLastName` arrived in 202609 and\n * are what we send; the plaintext `firstName` / `lastName` they replace are still accepted by\n * the API but are not listed here, because sending a name in the clear is not something this\n * client should make easy to reach for.\n */\n userInfo?: {\n hashedFirstName?: string;\n hashedLastName?: string;\n companyName?: string;\n countryCode?: string;\n title?: string;\n };\n\n /**\n * The maximum supported size of the list is 1 at the moment. If the list contains multiple\n * values, only the first value will be used.\n */\n externalIds?: [string, ...string[]];\n\n /**\n * This is generated when users submit the Linkedin Lead-gen form\n * (e.g. urn:li:leadGenFormResponse:<id>).\n */\n lead?: `urn:li:leadGenFormResponse:${string}`;\n };\n}\n\nexport interface CreateMultipleLinkedinEventsDTO {\n elements: CreateLinkedinEventDTO[];\n}\n\n/**\n * The schema prescribes an exact normalization per field before hashing, and getting it wrong is\n * silent: LinkedIn accepts any 64-char hex digest and simply matches nobody. So these helpers are\n * the difference between attribution and nothing, not between 200 and 400.\n */\nconst sha256 = (value: string) => createHash('sha256').update(value).digest('hex');\n\n/**\n * An email is lower-cased and stripped of whitespace before hashing. Punctuation is left alone —\n * `@` and `.` are the address.\n */\nconst hashEmail = (email: string) => sha256(email.toLowerCase().replace(/\\s/g, ''));\n\n/**\n * A name is lower-cased and stripped of whitespace *and punctuation* before hashing, so that\n * `O'Brien`, `o brien` and `obrien` all land on the same digest. UTF-8 is what `update` already\n * encodes with.\n *\n * The schema's wording (\"lowercase with no spaces or punctuation\") leaves room to read the\n * apostrophe either way, so this is checked against its own worked example rather than guessed:\n * the `hashedFirstName` / `hashedLastName` digests it prints are sha256('mary') and\n * sha256('obrien') — Mary O'Brien with the apostrophe removed, not escaped or kept.\n */\nconst hashName = (name: string) => sha256(name.toLowerCase().replace(/[\\s\\p{P}]/gu, ''));\n\nexport type LinkedinConversionConfig = Record<Lowercase<string>, number>;\n\nexport async function sendEvents(\n accessToken: string,\n config: LinkedinConversionConfig,\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n events: TrackEvent<any>[],\n data: UserProvidedData = {}\n) {\n const eventNames = Object.keys(config);\n const address = getFirst(data.address);\n const userIds: { idType: UserIdType; idValue: string }[] = [];\n const externalIds: [string, ...string[]] | undefined = data.user_id ? [data.user_id] : undefined;\n const userInfo =\n address?.first_name && address.last_name\n ? {\n hashedFirstName: hashName(address.first_name),\n hashedLastName: hashName(address.last_name),\n countryCode: address.country,\n }\n : undefined;\n\n if (data.email) {\n const email = getFirst(data.email);\n if (email) userIds.push({ idType: 'SHA256_EMAIL', idValue: hashEmail(email) });\n }\n\n const fatIds = (tags: TrackTags): { idType: UserIdType; idValue: string }[] => {\n const id = linkedinFatId(tags);\n return id ? [{ idType: 'LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID', idValue: id }] : [];\n };\n\n const dto: CreateMultipleLinkedinEventsDTO = {\n elements: events\n .filter((event) => eventNames.includes(event.name) && !IGNORED_EVENTS.includes(event.name))\n .map((event): CreateLinkedinEventDTO => ({\n eventId: event.id,\n conversion: `urn:lla:llaPartnerConversion:${config[event.name]}`,\n conversionHappenedAt: new Date(event.created_at).getTime(),\n conversionValue: {\n currencyCode: event.properties?.currency?.toUpperCase() ?? 'USD',\n amount: event.properties?.value?.toString() ?? '0',\n },\n user: {\n userIds: [...fatIds(event.tags), ...userIds],\n userInfo,\n externalIds,\n },\n }))\n // An element carrying no identifier at all fails validation, and LinkedIn fails the whole\n // batch when one element fails — so a single anonymous event would discard every\n // identifiable conversion sent alongside it. Dropped here instead.\n .filter(\n ({ user }) =>\n user.userIds.length > 0 || !!user.userInfo || !!user.externalIds || !!user.lead\n ),\n };\n\n if (dto.elements.length === 0) return;\n try {\n const response = await fetch('https://api.linkedin.com/rest/conversionEvents', {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${accessToken}`,\n 'Content-Type': 'application/json',\n 'LinkedIn-Version': '202609',\n 'X-Restli-Protocol-Version': '2.0.0',\n 'X-RestLi-Method': 'BATCH_CREATE',\n },\n body: JSON.stringify(dto),\n });\n\n if (response.ok) return;\n const { status } = response;\n const message = await response.text();\n console.error(`Failed to send LinkedIn conversion, status: ${status}, body: ${message}`);\n } catch (error) {\n console.error('Failed to send LinkedIn conversion, network error:', error);\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAkFA,MAAM,UAAU,WAAA,GAAkBA,YAAAA,WAAAA,CAAW,QAAQ,CAAC,CAAC,OAAO,KAAK,CAAC,CAAC,OAAO,KAAK;;;;;AAMjF,MAAM,aAAa,UAAkB,OAAO,MAAM,YAAY,CAAC,CAAC,QAAQ,OAAO,EAAE,CAAC;;;;;;;;;;;AAYlF,MAAM,YAAY,SAAiB,OAAO,KAAK,YAAY,CAAC,CAAC,QAAQ,eAAe,EAAE,CAAC;
|
|
1
|
+
{"version":3,"file":"linkedin-conversions-api.cjs","names":["createHash","getFirst","linkedinFatId","IGNORED_EVENTS","fetch"],"sources":["../../src/server/linkedin-conversions-api.ts"],"sourcesContent":["/**\n * Conversions API Payload Builder: https://www.linkedin.com/developers/payload-builder\n * https://learn.microsoft.com/en-us/linkedin/marketing/conversions/conversions-overview?view=li-lms-2026-09\n */\nimport { createHash } from 'node:crypto';\nimport { fetch } from '@shware/utils';\nimport { IGNORED_EVENTS } from '../third-parties/ignored-events';\nimport type { TrackEvent, TrackTags, UserProvidedData } from '../track/types';\nimport { getFirst } from '../utils/field';\nimport { linkedinFatId } from './click-ids';\n\n/**\n * The identifier types LinkedIn matches on, as of version 202609. `ORACLE_MOAT_ID` used to be\n * here and is no longer in the schema; the IP and Android ones are.\n */\ntype UserIdType =\n | 'SHA256_EMAIL'\n | 'LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID'\n | 'ACXIOM_ID'\n | 'PLAINTEXT_IP_ADDRESS'\n | 'SHA256_IP_ADDRESS'\n | 'GOOGLE_AID';\n\nexport interface CreateLinkedinEventDTO {\n /**\n * For any conversion that you want to send through multiple methods, such as Insight Tag and\n * Conversions API, you must create a conversion rule for each data source (browser and server).\n * Then, you can implement a logic to pick up the eventId from the browser and send it with the\n * corresponding event from your server. If we receive an Insight Tag event and a Conversions API\n * event from the same account with the same eventId, we discard the Conversions API event and\n * count only the Insight Tag event in campaign reporting.\n */\n eventId?: string;\n\n /**\n * Replace <id> with the conversion ID extracted when creating the conversion rule\n * (e.g. urn:lla:llaPartnerConversion:<id>).\n */\n conversion: `urn:lla:llaPartnerConversion:${number}`;\n\n /** Epoch timestamp in milliseconds at which the conversion event happened. */\n conversionHappenedAt: number;\n conversionValue: { currencyCode: string; amount: string };\n user: {\n userIds: { idType: UserIdType; idValue: string }[];\n /**\n * Probabilistic matching fields. `hashedFirstName` / `hashedLastName` arrived in 202609 and\n * are what we send; the plaintext `firstName` / `lastName` they replace are still accepted by\n * the API but are not listed here, because sending a name in the clear is not something this\n * client should make easy to reach for.\n */\n userInfo?: {\n hashedFirstName?: string;\n hashedLastName?: string;\n companyName?: string;\n countryCode?: string;\n title?: string;\n };\n\n /**\n * The maximum supported size of the list is 1 at the moment. If the list contains multiple\n * values, only the first value will be used.\n */\n externalIds?: [string, ...string[]];\n\n /**\n * This is generated when users submit the Linkedin Lead-gen form\n * (e.g. urn:li:leadGenFormResponse:<id>).\n */\n lead?: `urn:li:leadGenFormResponse:${string}`;\n };\n}\n\nexport interface CreateMultipleLinkedinEventsDTO {\n elements: CreateLinkedinEventDTO[];\n}\n\n/**\n * The schema prescribes an exact normalization per field before hashing, and getting it wrong is\n * silent: LinkedIn accepts any 64-char hex digest and simply matches nobody. So these helpers are\n * the difference between attribution and nothing, not between 200 and 400.\n */\nconst sha256 = (value: string) => createHash('sha256').update(value).digest('hex');\n\n/**\n * An email is lower-cased and stripped of whitespace before hashing. Punctuation is left alone —\n * `@` and `.` are the address.\n */\nconst hashEmail = (email: string) => sha256(email.toLowerCase().replace(/\\s/g, ''));\n\n/**\n * A name is lower-cased and stripped of whitespace *and punctuation* before hashing, so that\n * `O'Brien`, `o brien` and `obrien` all land on the same digest. UTF-8 is what `update` already\n * encodes with.\n *\n * The schema's wording (\"lowercase with no spaces or punctuation\") leaves room to read the\n * apostrophe either way, so this is checked against its own worked example rather than guessed:\n * the `hashedFirstName` / `hashedLastName` digests it prints are sha256('mary') and\n * sha256('obrien') — Mary O'Brien with the apostrophe removed, not escaped or kept.\n */\nconst hashName = (name: string) => sha256(name.toLowerCase().replace(/[\\s\\p{P}]/gu, ''));\n\nexport type LinkedinConversionConfig = Record<Lowercase<string>, number>;\n\n/**\n * `conversionHappenedAt` must be \"within the past 90 days\", and one invalid record makes \"all\n * records fail\"; such an event is left out, with a minute's margin for the trip.\n */\nconst MAX_EVENT_AGE_MS = 90 * 24 * 60 * 60 * 1000 - 60 * 1000;\n\nexport async function sendEvents(\n accessToken: string,\n config: LinkedinConversionConfig,\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n events: TrackEvent<any>[],\n data: UserProvidedData = {}\n) {\n const eventNames = Object.keys(config);\n const address = getFirst(data.address);\n const userIds: { idType: UserIdType; idValue: string }[] = [];\n const externalIds: [string, ...string[]] | undefined = data.user_id ? [data.user_id] : undefined;\n const userInfo =\n address?.first_name && address.last_name\n ? {\n hashedFirstName: hashName(address.first_name),\n hashedLastName: hashName(address.last_name),\n countryCode: address.country,\n }\n : undefined;\n\n if (data.email) {\n const email = getFirst(data.email);\n if (email) userIds.push({ idType: 'SHA256_EMAIL', idValue: hashEmail(email) });\n }\n\n const fatIds = (tags: TrackTags): { idType: UserIdType; idValue: string }[] => {\n const id = linkedinFatId(tags);\n return id ? [{ idType: 'LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID', idValue: id }] : [];\n };\n\n const dto: CreateMultipleLinkedinEventsDTO = {\n elements: events\n .filter((event) => eventNames.includes(event.name) && !IGNORED_EVENTS.includes(event.name))\n .filter((event) => Date.now() - Date.parse(event.created_at) <= MAX_EVENT_AGE_MS)\n .map((event): CreateLinkedinEventDTO => ({\n eventId: event.id,\n conversion: `urn:lla:llaPartnerConversion:${config[event.name]}`,\n conversionHappenedAt: new Date(event.created_at).getTime(),\n conversionValue: {\n currencyCode: event.properties?.currency?.toUpperCase() ?? 'USD',\n amount: event.properties?.value?.toString() ?? '0',\n },\n user: {\n userIds: [...fatIds(event.tags), ...userIds],\n userInfo,\n externalIds,\n },\n }))\n // An element carrying no identifier at all fails validation, and LinkedIn fails the whole\n // batch when one element fails — so a single anonymous event would discard every\n // identifiable conversion sent alongside it. Dropped here instead.\n .filter(\n ({ user }) =>\n user.userIds.length > 0 || !!user.userInfo || !!user.externalIds || !!user.lead\n ),\n };\n\n if (dto.elements.length === 0) return;\n try {\n const response = await fetch('https://api.linkedin.com/rest/conversionEvents', {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${accessToken}`,\n 'Content-Type': 'application/json',\n 'LinkedIn-Version': '202609',\n 'X-Restli-Protocol-Version': '2.0.0',\n 'X-RestLi-Method': 'BATCH_CREATE',\n },\n body: JSON.stringify(dto),\n });\n\n if (response.ok) return;\n const { status } = response;\n const message = await response.text();\n console.error(`Failed to send LinkedIn conversion, status: ${status}, body: ${message}`);\n } catch (error) {\n console.error('Failed to send LinkedIn conversion, network error:', error);\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAkFA,MAAM,UAAU,WAAA,GAAkBA,YAAAA,WAAAA,CAAW,QAAQ,CAAC,CAAC,OAAO,KAAK,CAAC,CAAC,OAAO,KAAK;;;;;AAMjF,MAAM,aAAa,UAAkB,OAAO,MAAM,YAAY,CAAC,CAAC,QAAQ,OAAO,EAAE,CAAC;;;;;;;;;;;AAYlF,MAAM,YAAY,SAAiB,OAAO,KAAK,YAAY,CAAC,CAAC,QAAQ,eAAe,EAAE,CAAC;;;;;AAQvF,MAAM,mBAAmB;AAEzB,eAAsB,WACpB,aACA,QAEA,QACA,OAAyB,CAAC,GAC1B;CACA,MAAM,aAAa,OAAO,KAAK,MAAM;CACrC,MAAM,UAAUC,oBAAAA,SAAS,KAAK,OAAO;CACrC,MAAM,UAAqD,CAAC;CAC5D,MAAM,cAAiD,KAAK,UAAU,CAAC,KAAK,OAAO,IAAI,KAAA;CACvF,MAAM,WACJ,SAAS,cAAc,QAAQ,YAC3B;EACE,iBAAiB,SAAS,QAAQ,UAAU;EAC5C,gBAAgB,SAAS,QAAQ,SAAS;EAC1C,aAAa,QAAQ;CACvB,IACA,KAAA;CAEN,IAAI,KAAK,OAAO;EACd,MAAM,QAAQA,oBAAAA,SAAS,KAAK,KAAK;EACjC,IAAI,OAAO,QAAQ,KAAK;GAAE,QAAQ;GAAgB,SAAS,UAAU,KAAK;EAAE,CAAC;CAC/E;CAEA,MAAM,UAAU,SAA+D;EAC7E,MAAM,KAAKC,yBAAAA,cAAc,IAAI;EAC7B,OAAO,KAAK,CAAC;GAAE,QAAQ;GAA0C,SAAS;EAAG,CAAC,IAAI,CAAC;CACrF;CAEA,MAAM,MAAuC,EAC3C,UAAU,OACP,QAAQ,UAAU,WAAW,SAAS,MAAM,IAAI,KAAK,CAACC,qCAAAA,eAAe,SAAS,MAAM,IAAI,CAAC,CAAC,CAC1F,QAAQ,UAAU,KAAK,IAAI,IAAI,KAAK,MAAM,MAAM,UAAU,KAAK,gBAAgB,CAAC,CAChF,KAAK,WAAmC;EACvC,SAAS,MAAM;EACf,YAAY,gCAAgC,OAAO,MAAM;EACzD,sBAAsB,IAAI,KAAK,MAAM,UAAU,CAAC,CAAC,QAAQ;EACzD,iBAAiB;GACf,cAAc,MAAM,YAAY,UAAU,YAAY,KAAK;GAC3D,QAAQ,MAAM,YAAY,OAAO,SAAS,KAAK;EACjD;EACA,MAAM;GACJ,SAAS,CAAC,GAAG,OAAO,MAAM,IAAI,GAAG,GAAG,OAAO;GAC3C;GACA;EACF;CACF,EAAE,CAAC,CAIF,QACE,EAAE,WACD,KAAK,QAAQ,SAAS,KAAK,CAAC,CAAC,KAAK,YAAY,CAAC,CAAC,KAAK,eAAe,CAAC,CAAC,KAAK,IAC/E,EACJ;CAEA,IAAI,IAAI,SAAS,WAAW,GAAG;CAC/B,IAAI;EACF,MAAM,WAAW,OAAA,GAAMC,cAAAA,MAAAA,CAAM,kDAAkD;GAC7E,QAAQ;GACR,SAAS;IACP,eAAe,UAAU;IACzB,gBAAgB;IAChB,oBAAoB;IACpB,6BAA6B;IAC7B,mBAAmB;GACrB;GACA,MAAM,KAAK,UAAU,GAAG;EAC1B,CAAC;EAED,IAAI,SAAS,IAAI;EACjB,MAAM,EAAE,WAAW;EACnB,MAAM,UAAU,MAAM,SAAS,KAAK;EACpC,QAAQ,MAAM,+CAA+C,OAAO,UAAU,SAAS;CACzF,SAAS,OAAO;EACd,QAAQ,MAAM,sDAAsD,KAAK;CAC3E;AACF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"linkedin-conversions-api.d.cts","names":[],"sources":["../../src/server/linkedin-conversions-api.ts"],"mappings":";;;;;;KAeK;iBAQY;;;;;;;;;EASf;;;;;EAMA;;EAGA;EACA;IAAmB;IAAsB;;EACzC;IACE;MAAW,QAAQ;MAAY;;;;;;;;IAO/B;MACE;MACA;MACA;MACA;MACA;;;;;;IAOF;;;;;IAMA;;;iBAIa;EACf,UAAU;;YA4BA,2BAA2B,OAAO;
|
|
1
|
+
{"version":3,"file":"linkedin-conversions-api.d.cts","names":[],"sources":["../../src/server/linkedin-conversions-api.ts"],"mappings":";;;;;;KAeK;iBAQY;;;;;;;;;EASf;;;;;EAMA;;EAGA;EACA;IAAmB;IAAsB;;EACzC;IACE;MAAW,QAAQ;MAAY;;;;;;;;IAO/B;MACE;MACA;MACA;MACA;MACA;;;;;;IAOF;;;;;IAMA;;;iBAIa;EACf,UAAU;;YA4BA,2BAA2B,OAAO;wBAQxB,WACpB,qBACA,QAAQ,0BAER,QAAQ,mBACR,OAAM,mBAAqB"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"linkedin-conversions-api.d.mts","names":[],"sources":["../../src/server/linkedin-conversions-api.ts"],"mappings":";;;;;;KAeK;iBAQY;;;;;;;;;EASf;;;;;EAMA;;EAGA;EACA;IAAmB;IAAsB;;EACzC;IACE;MAAW,QAAQ;MAAY;;;;;;;;IAO/B;MACE;MACA;MACA;MACA;MACA;;;;;;IAOF;;;;;IAMA;;;iBAIa;EACf,UAAU;;YA4BA,2BAA2B,OAAO;
|
|
1
|
+
{"version":3,"file":"linkedin-conversions-api.d.mts","names":[],"sources":["../../src/server/linkedin-conversions-api.ts"],"mappings":";;;;;;KAeK;iBAQY;;;;;;;;;EASf;;;;;EAMA;;EAGA;EACA;IAAmB;IAAsB;;EACzC;IACE;MAAW,QAAQ;MAAY;;;;;;;;IAO/B;MACE;MACA;MACA;MACA;MACA;;;;;;IAOF;;;;;IAMA;;;iBAIa;EACf,UAAU;;YA4BA,2BAA2B,OAAO;wBAQxB,WACpB,qBACA,QAAQ,0BAER,QAAQ,mBACR,OAAM,mBAAqB"}
|
|
@@ -30,6 +30,11 @@ const hashEmail = (email) => sha256(email.toLowerCase().replace(/\s/g, ""));
|
|
|
30
30
|
* sha256('obrien') — Mary O'Brien with the apostrophe removed, not escaped or kept.
|
|
31
31
|
*/
|
|
32
32
|
const hashName = (name) => sha256(name.toLowerCase().replace(/[\s\p{P}]/gu, ""));
|
|
33
|
+
/**
|
|
34
|
+
* `conversionHappenedAt` must be "within the past 90 days", and one invalid record makes "all
|
|
35
|
+
* records fail"; such an event is left out, with a minute's margin for the trip.
|
|
36
|
+
*/
|
|
37
|
+
const MAX_EVENT_AGE_MS = 777594e4;
|
|
33
38
|
async function sendEvents(accessToken, config, events, data = {}) {
|
|
34
39
|
const eventNames = Object.keys(config);
|
|
35
40
|
const address = getFirst(data.address);
|
|
@@ -54,7 +59,7 @@ async function sendEvents(accessToken, config, events, data = {}) {
|
|
|
54
59
|
idValue: id
|
|
55
60
|
}] : [];
|
|
56
61
|
};
|
|
57
|
-
const dto = { elements: events.filter((event) => eventNames.includes(event.name) && !IGNORED_EVENTS.includes(event.name)).map((event) => ({
|
|
62
|
+
const dto = { elements: events.filter((event) => eventNames.includes(event.name) && !IGNORED_EVENTS.includes(event.name)).filter((event) => Date.now() - Date.parse(event.created_at) <= MAX_EVENT_AGE_MS).map((event) => ({
|
|
58
63
|
eventId: event.id,
|
|
59
64
|
conversion: `urn:lla:llaPartnerConversion:${config[event.name]}`,
|
|
60
65
|
conversionHappenedAt: new Date(event.created_at).getTime(),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"linkedin-conversions-api.mjs","names":[],"sources":["../../src/server/linkedin-conversions-api.ts"],"sourcesContent":["/**\n * Conversions API Payload Builder: https://www.linkedin.com/developers/payload-builder\n * https://learn.microsoft.com/en-us/linkedin/marketing/conversions/conversions-overview?view=li-lms-2026-09\n */\nimport { createHash } from 'node:crypto';\nimport { fetch } from '@shware/utils';\nimport { IGNORED_EVENTS } from '../third-parties/ignored-events';\nimport type { TrackEvent, TrackTags, UserProvidedData } from '../track/types';\nimport { getFirst } from '../utils/field';\nimport { linkedinFatId } from './click-ids';\n\n/**\n * The identifier types LinkedIn matches on, as of version 202609. `ORACLE_MOAT_ID` used to be\n * here and is no longer in the schema; the IP and Android ones are.\n */\ntype UserIdType =\n | 'SHA256_EMAIL'\n | 'LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID'\n | 'ACXIOM_ID'\n | 'PLAINTEXT_IP_ADDRESS'\n | 'SHA256_IP_ADDRESS'\n | 'GOOGLE_AID';\n\nexport interface CreateLinkedinEventDTO {\n /**\n * For any conversion that you want to send through multiple methods, such as Insight Tag and\n * Conversions API, you must create a conversion rule for each data source (browser and server).\n * Then, you can implement a logic to pick up the eventId from the browser and send it with the\n * corresponding event from your server. If we receive an Insight Tag event and a Conversions API\n * event from the same account with the same eventId, we discard the Conversions API event and\n * count only the Insight Tag event in campaign reporting.\n */\n eventId?: string;\n\n /**\n * Replace <id> with the conversion ID extracted when creating the conversion rule\n * (e.g. urn:lla:llaPartnerConversion:<id>).\n */\n conversion: `urn:lla:llaPartnerConversion:${number}`;\n\n /** Epoch timestamp in milliseconds at which the conversion event happened. */\n conversionHappenedAt: number;\n conversionValue: { currencyCode: string; amount: string };\n user: {\n userIds: { idType: UserIdType; idValue: string }[];\n /**\n * Probabilistic matching fields. `hashedFirstName` / `hashedLastName` arrived in 202609 and\n * are what we send; the plaintext `firstName` / `lastName` they replace are still accepted by\n * the API but are not listed here, because sending a name in the clear is not something this\n * client should make easy to reach for.\n */\n userInfo?: {\n hashedFirstName?: string;\n hashedLastName?: string;\n companyName?: string;\n countryCode?: string;\n title?: string;\n };\n\n /**\n * The maximum supported size of the list is 1 at the moment. If the list contains multiple\n * values, only the first value will be used.\n */\n externalIds?: [string, ...string[]];\n\n /**\n * This is generated when users submit the Linkedin Lead-gen form\n * (e.g. urn:li:leadGenFormResponse:<id>).\n */\n lead?: `urn:li:leadGenFormResponse:${string}`;\n };\n}\n\nexport interface CreateMultipleLinkedinEventsDTO {\n elements: CreateLinkedinEventDTO[];\n}\n\n/**\n * The schema prescribes an exact normalization per field before hashing, and getting it wrong is\n * silent: LinkedIn accepts any 64-char hex digest and simply matches nobody. So these helpers are\n * the difference between attribution and nothing, not between 200 and 400.\n */\nconst sha256 = (value: string) => createHash('sha256').update(value).digest('hex');\n\n/**\n * An email is lower-cased and stripped of whitespace before hashing. Punctuation is left alone —\n * `@` and `.` are the address.\n */\nconst hashEmail = (email: string) => sha256(email.toLowerCase().replace(/\\s/g, ''));\n\n/**\n * A name is lower-cased and stripped of whitespace *and punctuation* before hashing, so that\n * `O'Brien`, `o brien` and `obrien` all land on the same digest. UTF-8 is what `update` already\n * encodes with.\n *\n * The schema's wording (\"lowercase with no spaces or punctuation\") leaves room to read the\n * apostrophe either way, so this is checked against its own worked example rather than guessed:\n * the `hashedFirstName` / `hashedLastName` digests it prints are sha256('mary') and\n * sha256('obrien') — Mary O'Brien with the apostrophe removed, not escaped or kept.\n */\nconst hashName = (name: string) => sha256(name.toLowerCase().replace(/[\\s\\p{P}]/gu, ''));\n\nexport type LinkedinConversionConfig = Record<Lowercase<string>, number>;\n\nexport async function sendEvents(\n accessToken: string,\n config: LinkedinConversionConfig,\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n events: TrackEvent<any>[],\n data: UserProvidedData = {}\n) {\n const eventNames = Object.keys(config);\n const address = getFirst(data.address);\n const userIds: { idType: UserIdType; idValue: string }[] = [];\n const externalIds: [string, ...string[]] | undefined = data.user_id ? [data.user_id] : undefined;\n const userInfo =\n address?.first_name && address.last_name\n ? {\n hashedFirstName: hashName(address.first_name),\n hashedLastName: hashName(address.last_name),\n countryCode: address.country,\n }\n : undefined;\n\n if (data.email) {\n const email = getFirst(data.email);\n if (email) userIds.push({ idType: 'SHA256_EMAIL', idValue: hashEmail(email) });\n }\n\n const fatIds = (tags: TrackTags): { idType: UserIdType; idValue: string }[] => {\n const id = linkedinFatId(tags);\n return id ? [{ idType: 'LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID', idValue: id }] : [];\n };\n\n const dto: CreateMultipleLinkedinEventsDTO = {\n elements: events\n .filter((event) => eventNames.includes(event.name) && !IGNORED_EVENTS.includes(event.name))\n .map((event): CreateLinkedinEventDTO => ({\n eventId: event.id,\n conversion: `urn:lla:llaPartnerConversion:${config[event.name]}`,\n conversionHappenedAt: new Date(event.created_at).getTime(),\n conversionValue: {\n currencyCode: event.properties?.currency?.toUpperCase() ?? 'USD',\n amount: event.properties?.value?.toString() ?? '0',\n },\n user: {\n userIds: [...fatIds(event.tags), ...userIds],\n userInfo,\n externalIds,\n },\n }))\n // An element carrying no identifier at all fails validation, and LinkedIn fails the whole\n // batch when one element fails — so a single anonymous event would discard every\n // identifiable conversion sent alongside it. Dropped here instead.\n .filter(\n ({ user }) =>\n user.userIds.length > 0 || !!user.userInfo || !!user.externalIds || !!user.lead\n ),\n };\n\n if (dto.elements.length === 0) return;\n try {\n const response = await fetch('https://api.linkedin.com/rest/conversionEvents', {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${accessToken}`,\n 'Content-Type': 'application/json',\n 'LinkedIn-Version': '202609',\n 'X-Restli-Protocol-Version': '2.0.0',\n 'X-RestLi-Method': 'BATCH_CREATE',\n },\n body: JSON.stringify(dto),\n });\n\n if (response.ok) return;\n const { status } = response;\n const message = await response.text();\n console.error(`Failed to send LinkedIn conversion, status: ${status}, body: ${message}`);\n } catch (error) {\n console.error('Failed to send LinkedIn conversion, network error:', error);\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;AAkFA,MAAM,UAAU,UAAkB,WAAW,QAAQ,CAAC,CAAC,OAAO,KAAK,CAAC,CAAC,OAAO,KAAK;;;;;AAMjF,MAAM,aAAa,UAAkB,OAAO,MAAM,YAAY,CAAC,CAAC,QAAQ,OAAO,EAAE,CAAC;;;;;;;;;;;AAYlF,MAAM,YAAY,SAAiB,OAAO,KAAK,YAAY,CAAC,CAAC,QAAQ,eAAe,EAAE,CAAC;
|
|
1
|
+
{"version":3,"file":"linkedin-conversions-api.mjs","names":[],"sources":["../../src/server/linkedin-conversions-api.ts"],"sourcesContent":["/**\n * Conversions API Payload Builder: https://www.linkedin.com/developers/payload-builder\n * https://learn.microsoft.com/en-us/linkedin/marketing/conversions/conversions-overview?view=li-lms-2026-09\n */\nimport { createHash } from 'node:crypto';\nimport { fetch } from '@shware/utils';\nimport { IGNORED_EVENTS } from '../third-parties/ignored-events';\nimport type { TrackEvent, TrackTags, UserProvidedData } from '../track/types';\nimport { getFirst } from '../utils/field';\nimport { linkedinFatId } from './click-ids';\n\n/**\n * The identifier types LinkedIn matches on, as of version 202609. `ORACLE_MOAT_ID` used to be\n * here and is no longer in the schema; the IP and Android ones are.\n */\ntype UserIdType =\n | 'SHA256_EMAIL'\n | 'LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID'\n | 'ACXIOM_ID'\n | 'PLAINTEXT_IP_ADDRESS'\n | 'SHA256_IP_ADDRESS'\n | 'GOOGLE_AID';\n\nexport interface CreateLinkedinEventDTO {\n /**\n * For any conversion that you want to send through multiple methods, such as Insight Tag and\n * Conversions API, you must create a conversion rule for each data source (browser and server).\n * Then, you can implement a logic to pick up the eventId from the browser and send it with the\n * corresponding event from your server. If we receive an Insight Tag event and a Conversions API\n * event from the same account with the same eventId, we discard the Conversions API event and\n * count only the Insight Tag event in campaign reporting.\n */\n eventId?: string;\n\n /**\n * Replace <id> with the conversion ID extracted when creating the conversion rule\n * (e.g. urn:lla:llaPartnerConversion:<id>).\n */\n conversion: `urn:lla:llaPartnerConversion:${number}`;\n\n /** Epoch timestamp in milliseconds at which the conversion event happened. */\n conversionHappenedAt: number;\n conversionValue: { currencyCode: string; amount: string };\n user: {\n userIds: { idType: UserIdType; idValue: string }[];\n /**\n * Probabilistic matching fields. `hashedFirstName` / `hashedLastName` arrived in 202609 and\n * are what we send; the plaintext `firstName` / `lastName` they replace are still accepted by\n * the API but are not listed here, because sending a name in the clear is not something this\n * client should make easy to reach for.\n */\n userInfo?: {\n hashedFirstName?: string;\n hashedLastName?: string;\n companyName?: string;\n countryCode?: string;\n title?: string;\n };\n\n /**\n * The maximum supported size of the list is 1 at the moment. If the list contains multiple\n * values, only the first value will be used.\n */\n externalIds?: [string, ...string[]];\n\n /**\n * This is generated when users submit the Linkedin Lead-gen form\n * (e.g. urn:li:leadGenFormResponse:<id>).\n */\n lead?: `urn:li:leadGenFormResponse:${string}`;\n };\n}\n\nexport interface CreateMultipleLinkedinEventsDTO {\n elements: CreateLinkedinEventDTO[];\n}\n\n/**\n * The schema prescribes an exact normalization per field before hashing, and getting it wrong is\n * silent: LinkedIn accepts any 64-char hex digest and simply matches nobody. So these helpers are\n * the difference between attribution and nothing, not between 200 and 400.\n */\nconst sha256 = (value: string) => createHash('sha256').update(value).digest('hex');\n\n/**\n * An email is lower-cased and stripped of whitespace before hashing. Punctuation is left alone —\n * `@` and `.` are the address.\n */\nconst hashEmail = (email: string) => sha256(email.toLowerCase().replace(/\\s/g, ''));\n\n/**\n * A name is lower-cased and stripped of whitespace *and punctuation* before hashing, so that\n * `O'Brien`, `o brien` and `obrien` all land on the same digest. UTF-8 is what `update` already\n * encodes with.\n *\n * The schema's wording (\"lowercase with no spaces or punctuation\") leaves room to read the\n * apostrophe either way, so this is checked against its own worked example rather than guessed:\n * the `hashedFirstName` / `hashedLastName` digests it prints are sha256('mary') and\n * sha256('obrien') — Mary O'Brien with the apostrophe removed, not escaped or kept.\n */\nconst hashName = (name: string) => sha256(name.toLowerCase().replace(/[\\s\\p{P}]/gu, ''));\n\nexport type LinkedinConversionConfig = Record<Lowercase<string>, number>;\n\n/**\n * `conversionHappenedAt` must be \"within the past 90 days\", and one invalid record makes \"all\n * records fail\"; such an event is left out, with a minute's margin for the trip.\n */\nconst MAX_EVENT_AGE_MS = 90 * 24 * 60 * 60 * 1000 - 60 * 1000;\n\nexport async function sendEvents(\n accessToken: string,\n config: LinkedinConversionConfig,\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n events: TrackEvent<any>[],\n data: UserProvidedData = {}\n) {\n const eventNames = Object.keys(config);\n const address = getFirst(data.address);\n const userIds: { idType: UserIdType; idValue: string }[] = [];\n const externalIds: [string, ...string[]] | undefined = data.user_id ? [data.user_id] : undefined;\n const userInfo =\n address?.first_name && address.last_name\n ? {\n hashedFirstName: hashName(address.first_name),\n hashedLastName: hashName(address.last_name),\n countryCode: address.country,\n }\n : undefined;\n\n if (data.email) {\n const email = getFirst(data.email);\n if (email) userIds.push({ idType: 'SHA256_EMAIL', idValue: hashEmail(email) });\n }\n\n const fatIds = (tags: TrackTags): { idType: UserIdType; idValue: string }[] => {\n const id = linkedinFatId(tags);\n return id ? [{ idType: 'LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID', idValue: id }] : [];\n };\n\n const dto: CreateMultipleLinkedinEventsDTO = {\n elements: events\n .filter((event) => eventNames.includes(event.name) && !IGNORED_EVENTS.includes(event.name))\n .filter((event) => Date.now() - Date.parse(event.created_at) <= MAX_EVENT_AGE_MS)\n .map((event): CreateLinkedinEventDTO => ({\n eventId: event.id,\n conversion: `urn:lla:llaPartnerConversion:${config[event.name]}`,\n conversionHappenedAt: new Date(event.created_at).getTime(),\n conversionValue: {\n currencyCode: event.properties?.currency?.toUpperCase() ?? 'USD',\n amount: event.properties?.value?.toString() ?? '0',\n },\n user: {\n userIds: [...fatIds(event.tags), ...userIds],\n userInfo,\n externalIds,\n },\n }))\n // An element carrying no identifier at all fails validation, and LinkedIn fails the whole\n // batch when one element fails — so a single anonymous event would discard every\n // identifiable conversion sent alongside it. Dropped here instead.\n .filter(\n ({ user }) =>\n user.userIds.length > 0 || !!user.userInfo || !!user.externalIds || !!user.lead\n ),\n };\n\n if (dto.elements.length === 0) return;\n try {\n const response = await fetch('https://api.linkedin.com/rest/conversionEvents', {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${accessToken}`,\n 'Content-Type': 'application/json',\n 'LinkedIn-Version': '202609',\n 'X-Restli-Protocol-Version': '2.0.0',\n 'X-RestLi-Method': 'BATCH_CREATE',\n },\n body: JSON.stringify(dto),\n });\n\n if (response.ok) return;\n const { status } = response;\n const message = await response.text();\n console.error(`Failed to send LinkedIn conversion, status: ${status}, body: ${message}`);\n } catch (error) {\n console.error('Failed to send LinkedIn conversion, network error:', error);\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;AAkFA,MAAM,UAAU,UAAkB,WAAW,QAAQ,CAAC,CAAC,OAAO,KAAK,CAAC,CAAC,OAAO,KAAK;;;;;AAMjF,MAAM,aAAa,UAAkB,OAAO,MAAM,YAAY,CAAC,CAAC,QAAQ,OAAO,EAAE,CAAC;;;;;;;;;;;AAYlF,MAAM,YAAY,SAAiB,OAAO,KAAK,YAAY,CAAC,CAAC,QAAQ,eAAe,EAAE,CAAC;;;;;AAQvF,MAAM,mBAAmB;AAEzB,eAAsB,WACpB,aACA,QAEA,QACA,OAAyB,CAAC,GAC1B;CACA,MAAM,aAAa,OAAO,KAAK,MAAM;CACrC,MAAM,UAAU,SAAS,KAAK,OAAO;CACrC,MAAM,UAAqD,CAAC;CAC5D,MAAM,cAAiD,KAAK,UAAU,CAAC,KAAK,OAAO,IAAI,KAAA;CACvF,MAAM,WACJ,SAAS,cAAc,QAAQ,YAC3B;EACE,iBAAiB,SAAS,QAAQ,UAAU;EAC5C,gBAAgB,SAAS,QAAQ,SAAS;EAC1C,aAAa,QAAQ;CACvB,IACA,KAAA;CAEN,IAAI,KAAK,OAAO;EACd,MAAM,QAAQ,SAAS,KAAK,KAAK;EACjC,IAAI,OAAO,QAAQ,KAAK;GAAE,QAAQ;GAAgB,SAAS,UAAU,KAAK;EAAE,CAAC;CAC/E;CAEA,MAAM,UAAU,SAA+D;EAC7E,MAAM,KAAK,cAAc,IAAI;EAC7B,OAAO,KAAK,CAAC;GAAE,QAAQ;GAA0C,SAAS;EAAG,CAAC,IAAI,CAAC;CACrF;CAEA,MAAM,MAAuC,EAC3C,UAAU,OACP,QAAQ,UAAU,WAAW,SAAS,MAAM,IAAI,KAAK,CAAC,eAAe,SAAS,MAAM,IAAI,CAAC,CAAC,CAC1F,QAAQ,UAAU,KAAK,IAAI,IAAI,KAAK,MAAM,MAAM,UAAU,KAAK,gBAAgB,CAAC,CAChF,KAAK,WAAmC;EACvC,SAAS,MAAM;EACf,YAAY,gCAAgC,OAAO,MAAM;EACzD,sBAAsB,IAAI,KAAK,MAAM,UAAU,CAAC,CAAC,QAAQ;EACzD,iBAAiB;GACf,cAAc,MAAM,YAAY,UAAU,YAAY,KAAK;GAC3D,QAAQ,MAAM,YAAY,OAAO,SAAS,KAAK;EACjD;EACA,MAAM;GACJ,SAAS,CAAC,GAAG,OAAO,MAAM,IAAI,GAAG,GAAG,OAAO;GAC3C;GACA;EACF;CACF,EAAE,CAAC,CAIF,QACE,EAAE,WACD,KAAK,QAAQ,SAAS,KAAK,CAAC,CAAC,KAAK,YAAY,CAAC,CAAC,KAAK,eAAe,CAAC,CAAC,KAAK,IAC/E,EACJ;CAEA,IAAI,IAAI,SAAS,WAAW,GAAG;CAC/B,IAAI;EACF,MAAM,WAAW,MAAM,MAAM,kDAAkD;GAC7E,QAAQ;GACR,SAAS;IACP,eAAe,UAAU;IACzB,gBAAgB;IAChB,oBAAoB;IACpB,6BAA6B;IAC7B,mBAAmB;GACrB;GACA,MAAM,KAAK,UAAU,GAAG;EAC1B,CAAC;EAED,IAAI,SAAS,IAAI;EACjB,MAAM,EAAE,WAAW;EACnB,MAAM,UAAU,MAAM,SAAS,KAAK;EACpC,QAAQ,MAAM,+CAA+C,OAAO,UAAU,SAAS;CACzF,SAAS,OAAO;EACd,QAAQ,MAAM,sDAAsD,KAAK;CAC3E;AACF"}
|
|
@@ -223,12 +223,18 @@ function getCapiEvent(event, data, appPackageName) {
|
|
|
223
223
|
return capiEvent;
|
|
224
224
|
}
|
|
225
225
|
/**
|
|
226
|
+
* An `event_time` over 7 days old fails the whole request — "we return an error for the entire
|
|
227
|
+
* request and process no events" — so such an event is left out; a minute's margin for the trip.
|
|
228
|
+
* https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/server-event
|
|
229
|
+
*/
|
|
230
|
+
const MAX_EVENT_AGE_MS = 60474e4;
|
|
231
|
+
/**
|
|
226
232
|
* The lightweight counterpart to the legacy SDK sender (`@shware/analytics/server/legacy`):
|
|
227
233
|
* same filtering, same payload, same never-throws contract, minus the 31MB SDK. The token
|
|
228
234
|
* travels in the JSON body, never in the URL, and is never logged.
|
|
229
235
|
*/
|
|
230
236
|
async function sendEvents(accessToken, pixelId, events, data = {}, options = {}) {
|
|
231
|
-
const capiEvents = events.filter((event) => !require_third_parties_ignored_events.IGNORED_EVENTS.includes(event.name)).map((event) => getCapiEvent(event, data, options.appPackageName));
|
|
237
|
+
const capiEvents = events.filter((event) => !require_third_parties_ignored_events.IGNORED_EVENTS.includes(event.name)).filter((event) => Date.now() - Date.parse(event.created_at) <= MAX_EVENT_AGE_MS).map((event) => getCapiEvent(event, data, options.appPackageName));
|
|
232
238
|
if (capiEvents.length === 0) return void 0;
|
|
233
239
|
const version = options.apiVersion ?? API_VERSION;
|
|
234
240
|
try {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"meta-capi.cjs","names":["ParamBuilder","createHash","PII_DATA_TYPE","metaFbc","metaFbp","mapFBEvent","resolveActionSource","pageLocation","IGNORED_EVENTS","fetch"],"sources":["../../src/server/meta-capi.ts"],"sourcesContent":["/**\n * Meta Conversions API sender built on `capi-param-builder-nodejs` and plain `fetch`, replacing\n * the 31MB `facebook-nodejs-business-sdk` with ~0.5MB of parameter building. The SDK-based\n * sender survives untouched at `@shware/analytics/server/legacy`; this module is its drop-in\n * successor.\n *\n * The wire payload is byte-identical to what the business SDK's `ServerEvent.normalize()`\n * produces — enforced by a differential test that runs both builders over the same events.\n * Where the two libraries normalize differently (the business SDK keeps punctuation in names,\n * spaces in multi-word last names, accents in cities; the param builder strips them per the\n * current documented rules), this module follows the business SDK so that switching senders\n * cannot change a single hash Meta receives. The param builder is used where the two provably\n * agree — email, phone, gender, country — and its adoption-tracking appendix (`<hash>.<token>`)\n * is stripped for the same reason.\n *\n * Two deliberate behavior differences from the business SDK, both on invalid input only:\n * an invalid field (malformed email, non-ISO country, letters in a phone) is dropped from\n * user_data instead of throwing inside `execute()` and costing the whole batch; an invalid\n * currency is uppercased and forwarded instead of rejected. Valid input is unaffected.\n *\n * https://developers.facebook.com/docs/marketing-api/conversions-api/using-the-api\n * https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/customer-information-parameters\n */\nimport { createHash } from 'node:crypto';\nimport { fetch } from '@shware/utils';\nimport { PII_DATA_TYPE, ParamBuilder } from 'capi-param-builder-nodejs';\nimport { IGNORED_EVENTS } from '../third-parties/ignored-events';\nimport { mapFBEvent } from '../track/fbq';\nimport type { TrackEvent, TrackTags, UserProvidedData } from '../track/types';\nimport { resolveActionSource } from './action-source';\nimport { metaFbc, metaFbp } from './click-ids';\nimport { pageLocation } from './page-location';\n\n/** Matches the Graph API version the pinned business SDK speaks (`FacebookAdsApi.VERSION`). */\nconst API_VERSION = 'v24.0';\n\nconst USER_ASSIGNED_COUNTRIES: string[] = ['xk'];\nfunction normalizeCountry(input: string | undefined): string | undefined {\n const country = input?.split(/[-_]/).at(0)?.toLowerCase();\n if (!country) return undefined;\n return USER_ASSIGNED_COUNTRIES.includes(country) ? undefined : country;\n}\n\n/**\n * Lazy for the same reason as the session and the token bucket (see setup/session.ts):\n * Cloudflare Workers refuse some work in module scope, and nothing here is per-request state.\n */\nlet paramBuilder: ParamBuilder | undefined;\n\n/**\n * Normalize and hash one PII value through the param builder, stripped to the bare SHA-256 the\n * business SDK sends: the builder appends a `.<language-token>` adoption marker that Meta's\n * endpoint tolerates but the business SDK never produced. Pre-hashed input passes through, as\n * it does in the business SDK.\n */\nfunction pii(value: string | undefined, dataType: string): string | undefined {\n if (!value) return undefined;\n const built = (paramBuilder ??= new ParamBuilder()).getNormalizedAndHashedPII(value, dataType);\n return built?.split('.').at(0) ?? undefined;\n}\n\nconst SHA256_OR_MD5 = /^[a-f0-9]{64}$|^[a-f0-9]{32}$/;\n\n/**\n * The business SDK's `normalizeAndHash` for the fields where the param builder normalizes\n * differently (names, city, state, zip): trim + lowercase, pass a pre-hashed value through,\n * apply the field rule, SHA-256. Field rules are copied from `ServerSideUtils` verbatim so the\n * differential test can hold the outputs equal.\n */\nfunction hashed(\n input: string | undefined,\n rule: (lowered: string) => string | undefined\n): string | undefined {\n if (!input) return undefined;\n const lowered = input.trim().toLowerCase();\n if (!lowered) return undefined;\n if (SHA256_OR_MD5.test(lowered)) return lowered;\n const normalized = rule(lowered);\n if (normalized === undefined) return undefined;\n return createHash('sha256').update(normalized).digest('hex');\n}\n\nconst nameRule = (v: string) => v;\nconst f5Rule = (v: string) => (v.length <= 5 ? v : v.slice(0, 5));\nconst cityStateRule = (v: string) => v.replace(/[0-9\\s().-]/g, '');\nconst zipRule = (v: string) => {\n const zip = v.replace(/\\s/g, '').split('-', 1)[0];\n return zip.length < 2 ? undefined : zip;\n};\n\n/**\n * Every multi-value field is deduplicated after hashing, order preserved — the SDK runs each\n * through `dedupArray` (a Set), `external_id` included.\n */\nconst dedupe = (hashes: (string | undefined)[]) =>\n Array.from(new Set(hashes.filter((hash): hash is string => hash !== undefined)));\n\nconst list = (hashes: (string | undefined)[]) => {\n const deduped = dedupe(hashes);\n return deduped.length > 0 ? deduped : undefined;\n};\n\ninterface CapiUserData {\n em?: string[];\n ph?: string[];\n ge?: string[];\n fn?: string[];\n ln?: string[];\n ct?: string[];\n st?: string[];\n zp?: string[];\n country?: string[];\n external_id?: string[];\n client_ip_address?: string;\n client_user_agent?: string;\n fbc?: string;\n fbp?: string;\n fb_login_id?: string;\n page_id?: string;\n f5first?: string;\n f5last?: string;\n dobd?: string;\n dobm?: string;\n doby?: string;\n madid?: string;\n}\n\nfunction getUserData(tags: TrackTags, data: UserProvidedData, eventTimeMs: number): CapiUserData {\n const userData: CapiUserData = {};\n\n if (data.email) {\n const emails = Array.isArray(data.email) ? data.email : [data.email];\n userData.em = list(emails.map((email) => pii(email, PII_DATA_TYPE.EMAIL)));\n }\n if (data.phone_number) {\n const phones = Array.isArray(data.phone_number) ? data.phone_number : [data.phone_number];\n userData.ph = list(phones.map((phone) => pii(phone, PII_DATA_TYPE.PHONE)));\n }\n if (data.gender === 'female' || data.gender === 'male') {\n userData.ge = list([pii(data.gender === 'female' ? 'f' : 'm', PII_DATA_TYPE.GENDER)]);\n }\n if (data.address) {\n if (Array.isArray(data.address)) {\n // The SDK sets these lists unconditionally in the multi-address branch, so an all-empty\n // column still goes out as `[]` — kept for byte-equality with the current sender.\n userData.fn = dedupe(data.address.map((a) => hashed(a.first_name, nameRule)));\n userData.ln = dedupe(data.address.map((a) => hashed(a.last_name, nameRule)));\n userData.ct = dedupe(data.address.map((a) => hashed(a.city, cityStateRule)));\n userData.st = dedupe(data.address.map((a) => hashed(a.region, cityStateRule)));\n userData.zp = dedupe(data.address.map((a) => hashed(a.postal_code, zipRule)));\n userData.country = dedupe(\n data.address.map((a) => pii(normalizeCountry(a.country), PII_DATA_TYPE.COUNTRY))\n );\n } else {\n userData.fn = list([hashed(data.address.first_name, nameRule)]);\n userData.ln = list([hashed(data.address.last_name, nameRule)]);\n userData.ct = list([hashed(data.address.city, cityStateRule)]);\n userData.st = list([hashed(data.address.region, cityStateRule)]);\n userData.zp = list([hashed(data.address.postal_code, zipRule)]);\n userData.country = list([pii(normalizeCountry(data.address.country), PII_DATA_TYPE.COUNTRY)]);\n // The 5-character prefixes, exactly as the current sender builds them: sliced first, then\n // normalized. Only the single-address branch sets them, as before.\n userData.f5first = hashed(data.address.first_name?.slice(0, 5), f5Rule);\n userData.f5last = hashed(data.address.last_name?.slice(0, 5), f5Rule);\n }\n }\n if (data.birthday) {\n const { year, month, day } = data.birthday;\n if (/^\\d{4}$/.test(year.toString())) {\n userData.doby = createHash('sha256').update(year.toString()).digest('hex');\n }\n if (month >= 1 && month <= 12) {\n userData.dobm = createHash('sha256').update(month.toString().padStart(2, '0')).digest('hex');\n }\n if (day >= 1 && day <= 31) {\n userData.dobd = createHash('sha256').update(day.toString().padStart(2, '0')).digest('hex');\n }\n }\n if (data.user_id && data.user_id.length !== 0) {\n // Unhashed, as the business SDK sends it. Meta documents hashing as recommended, not\n // required; changing it here would break continuity with every event sent so far.\n userData.external_id = [data.user_id];\n }\n if (data.ip_address) {\n userData.client_ip_address = data.ip_address;\n }\n if (data.user_agent) {\n userData.client_user_agent = data.user_agent;\n }\n if (data.fb_login_id) {\n userData.fb_login_id = data.fb_login_id;\n }\n if (data.fb_page_id) {\n userData.page_id = data.fb_page_id;\n }\n\n const fbc = metaFbc(tags, eventTimeMs);\n if (fbc) {\n userData.fbc = fbc;\n }\n const fbp = metaFbp(tags);\n if (fbp) {\n userData.fbp = fbp;\n }\n if (tags.advertising_id) {\n userData.madid = tags.advertising_id;\n }\n if (tags.ip_address && typeof tags.ip_address === 'string') {\n userData.client_ip_address = tags.ip_address;\n }\n\n return userData;\n}\n\nconst DELIVERY_CATEGORIES = ['in_store', 'curbside', 'home_delivery'];\n\n// oxlint-disable-next-line @typescript-eslint/no-explicit-any\nfunction getCustomData({ name, properties }: TrackEvent<any>): Record<string, unknown> {\n const [_, _name, fbEventProperties] = mapFBEvent(name, properties);\n const {\n value,\n currency,\n content_name,\n content_category,\n content_ids,\n contents,\n content_type,\n predicted_ltv,\n num_items,\n search_string,\n status,\n delivery_category,\n ...custom_properties\n } = fbEventProperties;\n\n const customData: Record<string, unknown> = {};\n // oxlint-disable-next-line typescript/no-unnecessary-condition -- custom event properties may carry `value: null` at runtime despite the declared type\n if (value !== undefined && value !== null && Number.isFinite(Number.parseFloat(String(value)))) {\n customData.value = Number.parseFloat(String(value));\n }\n if (currency) {\n // The business SDK also validates against the ISO 4217 list and throws; here an unknown\n // code is forwarded uppercased rather than costing the batch.\n customData.currency = currency\n .trim()\n .toUpperCase()\n .replace(/[^A-Z]/g, '');\n }\n if (content_name) customData.content_name = content_name;\n if (content_category) customData.content_category = content_category;\n if (content_ids) customData.content_ids = content_ids;\n if (contents) {\n customData.contents = contents.map((c) => {\n const content: Record<string, unknown> = {};\n if (c.id) content.id = c.id;\n if (c.quantity) content.quantity = c.quantity;\n if (c.item_price) content.item_price = c.item_price;\n if (c.title) content.title = c.title;\n if (c.description) content.description = c.description;\n if (c.brand) content.brand = c.brand;\n if (c.category) content.category = c.category;\n const delivery = c.delivery_category?.trim().toLowerCase();\n if (delivery && DELIVERY_CATEGORIES.includes(delivery)) content.delivery_category = delivery;\n return content;\n });\n }\n if (content_type) customData.content_type = content_type;\n if (predicted_ltv) customData.predicted_ltv = predicted_ltv;\n if (num_items) customData.num_items = num_items;\n if (search_string) customData.search_string = search_string;\n if (status) customData.status = status.toString();\n const delivery = delivery_category?.trim().toLowerCase();\n if (delivery && DELIVERY_CATEGORIES.includes(delivery)) customData.delivery_category = delivery;\n\n return { ...customData, ...custom_properties };\n}\n\n/**\n * `extinfo` is keyed by field index. The business SDK builds the same object and leaves unset\n * indexes out when it serializes; the documented 16-element array is what the Graph endpoint\n * decodes this into. Indexes: 0 version, 1 package, 2 short version, 3 long version, 4 OS\n * version, 5 device model, 6 locale, 9 screen width, 10 height, 11 density.\n */\nfunction getAppData(tags: TrackTags, appPackageName: string): Record<string, unknown> {\n const extinfo: Record<number, string | number> = {};\n if (tags.os_name === 'iOS' || tags.os_name === 'iPadOS') {\n extinfo[0] = 'i2';\n } else if (tags.os_name === 'Android') {\n extinfo[0] = 'a2';\n }\n extinfo[1] = appPackageName;\n const shortVersion = tags.release?.split('.').at(0);\n if (shortVersion) extinfo[2] = shortVersion;\n if (tags.release) extinfo[3] = tags.release;\n if (tags.os_version) extinfo[4] = tags.os_version;\n if (tags.device_model_id) extinfo[5] = tags.device_model_id;\n if (tags.language) extinfo[6] = tags.language;\n if (tags.screen_width) extinfo[9] = tags.screen_width;\n if (tags.screen_height) extinfo[10] = tags.screen_height;\n if (tags.device_pixel_ratio) extinfo[11] = tags.device_pixel_ratio.toString();\n\n const appData: Record<string, unknown> = { extinfo };\n if (tags.advertising_id) appData.advertiser_tracking_enabled = true;\n if (tags.install_referrer) appData.install_referrer = tags.install_referrer;\n return appData;\n}\n\n/** One entry of the `data` array POSTed to `/{pixel_id}/events`. */\nexport interface CapiEvent {\n event_name: string;\n event_time: number;\n event_id: string;\n event_source_url?: string;\n action_source: 'app' | 'website' | 'other';\n user_data: CapiUserData;\n custom_data: Record<string, unknown>;\n app_data?: Record<string, unknown>;\n}\n\n/**\n * `action_source` is derived from `event.platform` alone: an OS platform is an app, `web` is a\n * website, and everything else — including a backend-built offline conversion, which should\n * declare `platform: 'unknown'` — lands in Meta's `other`, exactly where the business-SDK\n * sender put an explicit `'offline'` too.\n */\nexport function getCapiEvent(\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n event: TrackEvent<any>,\n data: UserProvidedData,\n appPackageName?: string\n): CapiEvent {\n const eventTimeMs = new Date(event.created_at).getTime();\n const [_, eventName] = mapFBEvent(event.name, event.properties);\n const source = resolveActionSource(event.platform);\n\n const capiEvent: CapiEvent = {\n event_name: eventName,\n event_time: Math.round(eventTimeMs / 1000),\n event_id: event.tags.idempotency_key ?? event.id,\n // `action_source` is required; an offline conversion and an undeterminable platform both\n // land in Meta's catch-all, exactly as in the business-SDK sender.\n action_source: source === 'app' ? 'app' : source === 'web' ? 'website' : 'other',\n user_data: getUserData(event.tags, data, eventTimeMs),\n custom_data: getCustomData(event),\n };\n if (source === 'app' && appPackageName) {\n capiEvent.app_data = getAppData(event.tags, appPackageName);\n }\n const eventSourceUrl = pageLocation(event.tags);\n if (eventSourceUrl) {\n capiEvent.event_source_url = eventSourceUrl;\n }\n return capiEvent;\n}\n\nexport interface MetaConversionsOptions {\n /** Attaches `app_data`/extinfo to events whose platform resolves to an app. */\n appPackageName?: string;\n /** Routes the batch to Events Manager's Test Events tab instead of production. */\n testEventCode?: string;\n /** Graph API version, e.g. 'v24.0'. */\n apiVersion?: string;\n}\n\nexport interface MetaConversionsResponse {\n events_received?: number;\n messages?: string[];\n fbtrace_id?: string;\n}\n\n/**\n * The lightweight counterpart to the legacy SDK sender (`@shware/analytics/server/legacy`):\n * same filtering, same payload, same never-throws contract, minus the 31MB SDK. The token\n * travels in the JSON body, never in the URL, and is never logged.\n */\nexport async function sendEvents(\n accessToken: string,\n pixelId: string,\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n events: TrackEvent<any>[],\n data: UserProvidedData = {},\n options: MetaConversionsOptions = {}\n): Promise<MetaConversionsResponse | undefined> {\n const capiEvents = events\n .filter((event) => !IGNORED_EVENTS.includes(event.name))\n .map((event) => getCapiEvent(event, data, options.appPackageName));\n if (capiEvents.length === 0) return undefined;\n\n const version = options.apiVersion ?? API_VERSION;\n try {\n const response = await fetch(`https://graph.facebook.com/${version}/${pixelId}/events`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Accept: 'application/json' },\n body: JSON.stringify({\n data: capiEvents,\n test_event_code: options.testEventCode,\n access_token: accessToken,\n }),\n });\n if (response.ok) return (await response.json()) as MetaConversionsResponse;\n const { status } = response;\n const message = await response.text();\n console.error(`Failed to send Meta conversion, status: ${status}, body: ${message}`);\n return undefined;\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n console.error(`Failed to send Meta conversion, network error: ${message}`);\n return undefined;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"meta-capi.cjs","names":["ParamBuilder","createHash","PII_DATA_TYPE","metaFbc","metaFbp","mapFBEvent","resolveActionSource","pageLocation","IGNORED_EVENTS","fetch"],"sources":["../../src/server/meta-capi.ts"],"sourcesContent":["/**\n * Meta Conversions API sender built on `capi-param-builder-nodejs` and plain `fetch`, replacing\n * the 31MB `facebook-nodejs-business-sdk` with ~0.5MB of parameter building. The SDK-based\n * sender survives untouched at `@shware/analytics/server/legacy`; this module is its drop-in\n * successor.\n *\n * The wire payload is byte-identical to what the business SDK's `ServerEvent.normalize()`\n * produces — enforced by a differential test that runs both builders over the same events.\n * Where the two libraries normalize differently (the business SDK keeps punctuation in names,\n * spaces in multi-word last names, accents in cities; the param builder strips them per the\n * current documented rules), this module follows the business SDK so that switching senders\n * cannot change a single hash Meta receives. The param builder is used where the two provably\n * agree — email, phone, gender, country — and its adoption-tracking appendix (`<hash>.<token>`)\n * is stripped for the same reason.\n *\n * Two deliberate behavior differences from the business SDK, both on invalid input only:\n * an invalid field (malformed email, non-ISO country, letters in a phone) is dropped from\n * user_data instead of throwing inside `execute()` and costing the whole batch; an invalid\n * currency is uppercased and forwarded instead of rejected. Valid input is unaffected.\n *\n * https://developers.facebook.com/docs/marketing-api/conversions-api/using-the-api\n * https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/customer-information-parameters\n */\nimport { createHash } from 'node:crypto';\nimport { fetch } from '@shware/utils';\nimport { PII_DATA_TYPE, ParamBuilder } from 'capi-param-builder-nodejs';\nimport { IGNORED_EVENTS } from '../third-parties/ignored-events';\nimport { mapFBEvent } from '../track/fbq';\nimport type { TrackEvent, TrackTags, UserProvidedData } from '../track/types';\nimport { resolveActionSource } from './action-source';\nimport { metaFbc, metaFbp } from './click-ids';\nimport { pageLocation } from './page-location';\n\n/** Matches the Graph API version the pinned business SDK speaks (`FacebookAdsApi.VERSION`). */\nconst API_VERSION = 'v24.0';\n\nconst USER_ASSIGNED_COUNTRIES: string[] = ['xk'];\nfunction normalizeCountry(input: string | undefined): string | undefined {\n const country = input?.split(/[-_]/).at(0)?.toLowerCase();\n if (!country) return undefined;\n return USER_ASSIGNED_COUNTRIES.includes(country) ? undefined : country;\n}\n\n/**\n * Lazy for the same reason as the session and the token bucket (see setup/session.ts):\n * Cloudflare Workers refuse some work in module scope, and nothing here is per-request state.\n */\nlet paramBuilder: ParamBuilder | undefined;\n\n/**\n * Normalize and hash one PII value through the param builder, stripped to the bare SHA-256 the\n * business SDK sends: the builder appends a `.<language-token>` adoption marker that Meta's\n * endpoint tolerates but the business SDK never produced. Pre-hashed input passes through, as\n * it does in the business SDK.\n */\nfunction pii(value: string | undefined, dataType: string): string | undefined {\n if (!value) return undefined;\n const built = (paramBuilder ??= new ParamBuilder()).getNormalizedAndHashedPII(value, dataType);\n return built?.split('.').at(0) ?? undefined;\n}\n\nconst SHA256_OR_MD5 = /^[a-f0-9]{64}$|^[a-f0-9]{32}$/;\n\n/**\n * The business SDK's `normalizeAndHash` for the fields where the param builder normalizes\n * differently (names, city, state, zip): trim + lowercase, pass a pre-hashed value through,\n * apply the field rule, SHA-256. Field rules are copied from `ServerSideUtils` verbatim so the\n * differential test can hold the outputs equal.\n */\nfunction hashed(\n input: string | undefined,\n rule: (lowered: string) => string | undefined\n): string | undefined {\n if (!input) return undefined;\n const lowered = input.trim().toLowerCase();\n if (!lowered) return undefined;\n if (SHA256_OR_MD5.test(lowered)) return lowered;\n const normalized = rule(lowered);\n if (normalized === undefined) return undefined;\n return createHash('sha256').update(normalized).digest('hex');\n}\n\nconst nameRule = (v: string) => v;\nconst f5Rule = (v: string) => (v.length <= 5 ? v : v.slice(0, 5));\nconst cityStateRule = (v: string) => v.replace(/[0-9\\s().-]/g, '');\nconst zipRule = (v: string) => {\n const zip = v.replace(/\\s/g, '').split('-', 1)[0];\n return zip.length < 2 ? undefined : zip;\n};\n\n/**\n * Every multi-value field is deduplicated after hashing, order preserved — the SDK runs each\n * through `dedupArray` (a Set), `external_id` included.\n */\nconst dedupe = (hashes: (string | undefined)[]) =>\n Array.from(new Set(hashes.filter((hash): hash is string => hash !== undefined)));\n\nconst list = (hashes: (string | undefined)[]) => {\n const deduped = dedupe(hashes);\n return deduped.length > 0 ? deduped : undefined;\n};\n\ninterface CapiUserData {\n em?: string[];\n ph?: string[];\n ge?: string[];\n fn?: string[];\n ln?: string[];\n ct?: string[];\n st?: string[];\n zp?: string[];\n country?: string[];\n external_id?: string[];\n client_ip_address?: string;\n client_user_agent?: string;\n fbc?: string;\n fbp?: string;\n fb_login_id?: string;\n page_id?: string;\n f5first?: string;\n f5last?: string;\n dobd?: string;\n dobm?: string;\n doby?: string;\n madid?: string;\n}\n\nfunction getUserData(tags: TrackTags, data: UserProvidedData, eventTimeMs: number): CapiUserData {\n const userData: CapiUserData = {};\n\n if (data.email) {\n const emails = Array.isArray(data.email) ? data.email : [data.email];\n userData.em = list(emails.map((email) => pii(email, PII_DATA_TYPE.EMAIL)));\n }\n if (data.phone_number) {\n const phones = Array.isArray(data.phone_number) ? data.phone_number : [data.phone_number];\n userData.ph = list(phones.map((phone) => pii(phone, PII_DATA_TYPE.PHONE)));\n }\n if (data.gender === 'female' || data.gender === 'male') {\n userData.ge = list([pii(data.gender === 'female' ? 'f' : 'm', PII_DATA_TYPE.GENDER)]);\n }\n if (data.address) {\n if (Array.isArray(data.address)) {\n // The SDK sets these lists unconditionally in the multi-address branch, so an all-empty\n // column still goes out as `[]` — kept for byte-equality with the current sender.\n userData.fn = dedupe(data.address.map((a) => hashed(a.first_name, nameRule)));\n userData.ln = dedupe(data.address.map((a) => hashed(a.last_name, nameRule)));\n userData.ct = dedupe(data.address.map((a) => hashed(a.city, cityStateRule)));\n userData.st = dedupe(data.address.map((a) => hashed(a.region, cityStateRule)));\n userData.zp = dedupe(data.address.map((a) => hashed(a.postal_code, zipRule)));\n userData.country = dedupe(\n data.address.map((a) => pii(normalizeCountry(a.country), PII_DATA_TYPE.COUNTRY))\n );\n } else {\n userData.fn = list([hashed(data.address.first_name, nameRule)]);\n userData.ln = list([hashed(data.address.last_name, nameRule)]);\n userData.ct = list([hashed(data.address.city, cityStateRule)]);\n userData.st = list([hashed(data.address.region, cityStateRule)]);\n userData.zp = list([hashed(data.address.postal_code, zipRule)]);\n userData.country = list([pii(normalizeCountry(data.address.country), PII_DATA_TYPE.COUNTRY)]);\n // The 5-character prefixes, exactly as the current sender builds them: sliced first, then\n // normalized. Only the single-address branch sets them, as before.\n userData.f5first = hashed(data.address.first_name?.slice(0, 5), f5Rule);\n userData.f5last = hashed(data.address.last_name?.slice(0, 5), f5Rule);\n }\n }\n if (data.birthday) {\n const { year, month, day } = data.birthday;\n if (/^\\d{4}$/.test(year.toString())) {\n userData.doby = createHash('sha256').update(year.toString()).digest('hex');\n }\n if (month >= 1 && month <= 12) {\n userData.dobm = createHash('sha256').update(month.toString().padStart(2, '0')).digest('hex');\n }\n if (day >= 1 && day <= 31) {\n userData.dobd = createHash('sha256').update(day.toString().padStart(2, '0')).digest('hex');\n }\n }\n if (data.user_id && data.user_id.length !== 0) {\n // Unhashed, as the business SDK sends it. Meta documents hashing as recommended, not\n // required; changing it here would break continuity with every event sent so far.\n userData.external_id = [data.user_id];\n }\n if (data.ip_address) {\n userData.client_ip_address = data.ip_address;\n }\n if (data.user_agent) {\n userData.client_user_agent = data.user_agent;\n }\n if (data.fb_login_id) {\n userData.fb_login_id = data.fb_login_id;\n }\n if (data.fb_page_id) {\n userData.page_id = data.fb_page_id;\n }\n\n const fbc = metaFbc(tags, eventTimeMs);\n if (fbc) {\n userData.fbc = fbc;\n }\n const fbp = metaFbp(tags);\n if (fbp) {\n userData.fbp = fbp;\n }\n if (tags.advertising_id) {\n userData.madid = tags.advertising_id;\n }\n if (tags.ip_address && typeof tags.ip_address === 'string') {\n userData.client_ip_address = tags.ip_address;\n }\n\n return userData;\n}\n\nconst DELIVERY_CATEGORIES = ['in_store', 'curbside', 'home_delivery'];\n\n// oxlint-disable-next-line @typescript-eslint/no-explicit-any\nfunction getCustomData({ name, properties }: TrackEvent<any>): Record<string, unknown> {\n const [_, _name, fbEventProperties] = mapFBEvent(name, properties);\n const {\n value,\n currency,\n content_name,\n content_category,\n content_ids,\n contents,\n content_type,\n predicted_ltv,\n num_items,\n search_string,\n status,\n delivery_category,\n ...custom_properties\n } = fbEventProperties;\n\n const customData: Record<string, unknown> = {};\n // oxlint-disable-next-line typescript/no-unnecessary-condition -- custom event properties may carry `value: null` at runtime despite the declared type\n if (value !== undefined && value !== null && Number.isFinite(Number.parseFloat(String(value)))) {\n customData.value = Number.parseFloat(String(value));\n }\n if (currency) {\n // The business SDK also validates against the ISO 4217 list and throws; here an unknown\n // code is forwarded uppercased rather than costing the batch.\n customData.currency = currency\n .trim()\n .toUpperCase()\n .replace(/[^A-Z]/g, '');\n }\n if (content_name) customData.content_name = content_name;\n if (content_category) customData.content_category = content_category;\n if (content_ids) customData.content_ids = content_ids;\n if (contents) {\n customData.contents = contents.map((c) => {\n const content: Record<string, unknown> = {};\n if (c.id) content.id = c.id;\n if (c.quantity) content.quantity = c.quantity;\n if (c.item_price) content.item_price = c.item_price;\n if (c.title) content.title = c.title;\n if (c.description) content.description = c.description;\n if (c.brand) content.brand = c.brand;\n if (c.category) content.category = c.category;\n const delivery = c.delivery_category?.trim().toLowerCase();\n if (delivery && DELIVERY_CATEGORIES.includes(delivery)) content.delivery_category = delivery;\n return content;\n });\n }\n if (content_type) customData.content_type = content_type;\n if (predicted_ltv) customData.predicted_ltv = predicted_ltv;\n if (num_items) customData.num_items = num_items;\n if (search_string) customData.search_string = search_string;\n if (status) customData.status = status.toString();\n const delivery = delivery_category?.trim().toLowerCase();\n if (delivery && DELIVERY_CATEGORIES.includes(delivery)) customData.delivery_category = delivery;\n\n return { ...customData, ...custom_properties };\n}\n\n/**\n * `extinfo` is keyed by field index. The business SDK builds the same object and leaves unset\n * indexes out when it serializes; the documented 16-element array is what the Graph endpoint\n * decodes this into. Indexes: 0 version, 1 package, 2 short version, 3 long version, 4 OS\n * version, 5 device model, 6 locale, 9 screen width, 10 height, 11 density.\n */\nfunction getAppData(tags: TrackTags, appPackageName: string): Record<string, unknown> {\n const extinfo: Record<number, string | number> = {};\n if (tags.os_name === 'iOS' || tags.os_name === 'iPadOS') {\n extinfo[0] = 'i2';\n } else if (tags.os_name === 'Android') {\n extinfo[0] = 'a2';\n }\n extinfo[1] = appPackageName;\n const shortVersion = tags.release?.split('.').at(0);\n if (shortVersion) extinfo[2] = shortVersion;\n if (tags.release) extinfo[3] = tags.release;\n if (tags.os_version) extinfo[4] = tags.os_version;\n if (tags.device_model_id) extinfo[5] = tags.device_model_id;\n if (tags.language) extinfo[6] = tags.language;\n if (tags.screen_width) extinfo[9] = tags.screen_width;\n if (tags.screen_height) extinfo[10] = tags.screen_height;\n if (tags.device_pixel_ratio) extinfo[11] = tags.device_pixel_ratio.toString();\n\n const appData: Record<string, unknown> = { extinfo };\n if (tags.advertising_id) appData.advertiser_tracking_enabled = true;\n if (tags.install_referrer) appData.install_referrer = tags.install_referrer;\n return appData;\n}\n\n/** One entry of the `data` array POSTed to `/{pixel_id}/events`. */\nexport interface CapiEvent {\n event_name: string;\n event_time: number;\n event_id: string;\n event_source_url?: string;\n action_source: 'app' | 'website' | 'other';\n user_data: CapiUserData;\n custom_data: Record<string, unknown>;\n app_data?: Record<string, unknown>;\n}\n\n/**\n * `action_source` is derived from `event.platform` alone: an OS platform is an app, `web` is a\n * website, and everything else — including a backend-built offline conversion, which should\n * declare `platform: 'unknown'` — lands in Meta's `other`, exactly where the business-SDK\n * sender put an explicit `'offline'` too.\n */\nexport function getCapiEvent(\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n event: TrackEvent<any>,\n data: UserProvidedData,\n appPackageName?: string\n): CapiEvent {\n const eventTimeMs = new Date(event.created_at).getTime();\n const [_, eventName] = mapFBEvent(event.name, event.properties);\n const source = resolveActionSource(event.platform);\n\n const capiEvent: CapiEvent = {\n event_name: eventName,\n event_time: Math.round(eventTimeMs / 1000),\n event_id: event.tags.idempotency_key ?? event.id,\n // `action_source` is required; an offline conversion and an undeterminable platform both\n // land in Meta's catch-all, exactly as in the business-SDK sender.\n action_source: source === 'app' ? 'app' : source === 'web' ? 'website' : 'other',\n user_data: getUserData(event.tags, data, eventTimeMs),\n custom_data: getCustomData(event),\n };\n if (source === 'app' && appPackageName) {\n capiEvent.app_data = getAppData(event.tags, appPackageName);\n }\n const eventSourceUrl = pageLocation(event.tags);\n if (eventSourceUrl) {\n capiEvent.event_source_url = eventSourceUrl;\n }\n return capiEvent;\n}\n\nexport interface MetaConversionsOptions {\n /** Attaches `app_data`/extinfo to events whose platform resolves to an app. */\n appPackageName?: string;\n /** Routes the batch to Events Manager's Test Events tab instead of production. */\n testEventCode?: string;\n /** Graph API version, e.g. 'v24.0'. */\n apiVersion?: string;\n}\n\nexport interface MetaConversionsResponse {\n events_received?: number;\n messages?: string[];\n fbtrace_id?: string;\n}\n\n/**\n * An `event_time` over 7 days old fails the whole request — \"we return an error for the entire\n * request and process no events\" — so such an event is left out; a minute's margin for the trip.\n * https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/server-event\n */\nconst MAX_EVENT_AGE_MS = 7 * 24 * 60 * 60 * 1000 - 60 * 1000;\n\n/**\n * The lightweight counterpart to the legacy SDK sender (`@shware/analytics/server/legacy`):\n * same filtering, same payload, same never-throws contract, minus the 31MB SDK. The token\n * travels in the JSON body, never in the URL, and is never logged.\n */\nexport async function sendEvents(\n accessToken: string,\n pixelId: string,\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n events: TrackEvent<any>[],\n data: UserProvidedData = {},\n options: MetaConversionsOptions = {}\n): Promise<MetaConversionsResponse | undefined> {\n const capiEvents = events\n .filter((event) => !IGNORED_EVENTS.includes(event.name))\n .filter((event) => Date.now() - Date.parse(event.created_at) <= MAX_EVENT_AGE_MS)\n .map((event) => getCapiEvent(event, data, options.appPackageName));\n if (capiEvents.length === 0) return undefined;\n\n const version = options.apiVersion ?? API_VERSION;\n try {\n const response = await fetch(`https://graph.facebook.com/${version}/${pixelId}/events`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Accept: 'application/json' },\n body: JSON.stringify({\n data: capiEvents,\n test_event_code: options.testEventCode,\n access_token: accessToken,\n }),\n });\n if (response.ok) return (await response.json()) as MetaConversionsResponse;\n const { status } = response;\n const message = await response.text();\n console.error(`Failed to send Meta conversion, status: ${status}, body: ${message}`);\n return undefined;\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n console.error(`Failed to send Meta conversion, network error: ${message}`);\n return undefined;\n }\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"meta-capi.d.cts","names":[],"sources":["../../src/server/meta-capi.ts"],"mappings":";;UAsGU;EACR;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;iBAwLe;EACf;EACA;EACA;EACA;EACA;EACA,WAAW;EACX,aAAa;EACb,WAAW;;;;;;;;wBASG,aAEd,OAAO,iBACP,MAAM,kBACN,0BACC;iBAyBc;;EAEf;;EAEA;;EAEA;;iBAGe;EACf;EACA;EACA;;;;;;;
|
|
1
|
+
{"version":3,"file":"meta-capi.d.cts","names":[],"sources":["../../src/server/meta-capi.ts"],"mappings":";;UAsGU;EACR;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;iBAwLe;EACf;EACA;EACA;EACA;EACA;EACA,WAAW;EACX,aAAa;EACb,WAAW;;;;;;;;wBASG,aAEd,OAAO,iBACP,MAAM,kBACN,0BACC;iBAyBc;;EAEf;;EAEA;;EAEA;;iBAGe;EACf;EACA;EACA;;;;;;;wBAeoB,WACpB,qBACA,iBAEA,QAAQ,mBACR,OAAM,kBACN,UAAS,yBACR,QAAQ"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"meta-capi.d.mts","names":[],"sources":["../../src/server/meta-capi.ts"],"mappings":";;UAsGU;EACR;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;iBAwLe;EACf;EACA;EACA;EACA;EACA;EACA,WAAW;EACX,aAAa;EACb,WAAW;;;;;;;;wBASG,aAEd,OAAO,iBACP,MAAM,kBACN,0BACC;iBAyBc;;EAEf;;EAEA;;EAEA;;iBAGe;EACf;EACA;EACA;;;;;;;
|
|
1
|
+
{"version":3,"file":"meta-capi.d.mts","names":[],"sources":["../../src/server/meta-capi.ts"],"mappings":";;UAsGU;EACR;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;iBAwLe;EACf;EACA;EACA;EACA;EACA;EACA,WAAW;EACX,aAAa;EACb,WAAW;;;;;;;;wBASG,aAEd,OAAO,iBACP,MAAM,kBACN,0BACC;iBAyBc;;EAEf;;EAEA;;EAEA;;iBAGe;EACf;EACA;EACA;;;;;;;wBAeoB,WACpB,qBACA,iBAEA,QAAQ,mBACR,OAAM,kBACN,UAAS,yBACR,QAAQ"}
|
|
@@ -222,12 +222,18 @@ function getCapiEvent(event, data, appPackageName) {
|
|
|
222
222
|
return capiEvent;
|
|
223
223
|
}
|
|
224
224
|
/**
|
|
225
|
+
* An `event_time` over 7 days old fails the whole request — "we return an error for the entire
|
|
226
|
+
* request and process no events" — so such an event is left out; a minute's margin for the trip.
|
|
227
|
+
* https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/server-event
|
|
228
|
+
*/
|
|
229
|
+
const MAX_EVENT_AGE_MS = 60474e4;
|
|
230
|
+
/**
|
|
225
231
|
* The lightweight counterpart to the legacy SDK sender (`@shware/analytics/server/legacy`):
|
|
226
232
|
* same filtering, same payload, same never-throws contract, minus the 31MB SDK. The token
|
|
227
233
|
* travels in the JSON body, never in the URL, and is never logged.
|
|
228
234
|
*/
|
|
229
235
|
async function sendEvents(accessToken, pixelId, events, data = {}, options = {}) {
|
|
230
|
-
const capiEvents = events.filter((event) => !IGNORED_EVENTS.includes(event.name)).map((event) => getCapiEvent(event, data, options.appPackageName));
|
|
236
|
+
const capiEvents = events.filter((event) => !IGNORED_EVENTS.includes(event.name)).filter((event) => Date.now() - Date.parse(event.created_at) <= MAX_EVENT_AGE_MS).map((event) => getCapiEvent(event, data, options.appPackageName));
|
|
231
237
|
if (capiEvents.length === 0) return void 0;
|
|
232
238
|
const version = options.apiVersion ?? API_VERSION;
|
|
233
239
|
try {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"meta-capi.mjs","names":[],"sources":["../../src/server/meta-capi.ts"],"sourcesContent":["/**\n * Meta Conversions API sender built on `capi-param-builder-nodejs` and plain `fetch`, replacing\n * the 31MB `facebook-nodejs-business-sdk` with ~0.5MB of parameter building. The SDK-based\n * sender survives untouched at `@shware/analytics/server/legacy`; this module is its drop-in\n * successor.\n *\n * The wire payload is byte-identical to what the business SDK's `ServerEvent.normalize()`\n * produces — enforced by a differential test that runs both builders over the same events.\n * Where the two libraries normalize differently (the business SDK keeps punctuation in names,\n * spaces in multi-word last names, accents in cities; the param builder strips them per the\n * current documented rules), this module follows the business SDK so that switching senders\n * cannot change a single hash Meta receives. The param builder is used where the two provably\n * agree — email, phone, gender, country — and its adoption-tracking appendix (`<hash>.<token>`)\n * is stripped for the same reason.\n *\n * Two deliberate behavior differences from the business SDK, both on invalid input only:\n * an invalid field (malformed email, non-ISO country, letters in a phone) is dropped from\n * user_data instead of throwing inside `execute()` and costing the whole batch; an invalid\n * currency is uppercased and forwarded instead of rejected. Valid input is unaffected.\n *\n * https://developers.facebook.com/docs/marketing-api/conversions-api/using-the-api\n * https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/customer-information-parameters\n */\nimport { createHash } from 'node:crypto';\nimport { fetch } from '@shware/utils';\nimport { PII_DATA_TYPE, ParamBuilder } from 'capi-param-builder-nodejs';\nimport { IGNORED_EVENTS } from '../third-parties/ignored-events';\nimport { mapFBEvent } from '../track/fbq';\nimport type { TrackEvent, TrackTags, UserProvidedData } from '../track/types';\nimport { resolveActionSource } from './action-source';\nimport { metaFbc, metaFbp } from './click-ids';\nimport { pageLocation } from './page-location';\n\n/** Matches the Graph API version the pinned business SDK speaks (`FacebookAdsApi.VERSION`). */\nconst API_VERSION = 'v24.0';\n\nconst USER_ASSIGNED_COUNTRIES: string[] = ['xk'];\nfunction normalizeCountry(input: string | undefined): string | undefined {\n const country = input?.split(/[-_]/).at(0)?.toLowerCase();\n if (!country) return undefined;\n return USER_ASSIGNED_COUNTRIES.includes(country) ? undefined : country;\n}\n\n/**\n * Lazy for the same reason as the session and the token bucket (see setup/session.ts):\n * Cloudflare Workers refuse some work in module scope, and nothing here is per-request state.\n */\nlet paramBuilder: ParamBuilder | undefined;\n\n/**\n * Normalize and hash one PII value through the param builder, stripped to the bare SHA-256 the\n * business SDK sends: the builder appends a `.<language-token>` adoption marker that Meta's\n * endpoint tolerates but the business SDK never produced. Pre-hashed input passes through, as\n * it does in the business SDK.\n */\nfunction pii(value: string | undefined, dataType: string): string | undefined {\n if (!value) return undefined;\n const built = (paramBuilder ??= new ParamBuilder()).getNormalizedAndHashedPII(value, dataType);\n return built?.split('.').at(0) ?? undefined;\n}\n\nconst SHA256_OR_MD5 = /^[a-f0-9]{64}$|^[a-f0-9]{32}$/;\n\n/**\n * The business SDK's `normalizeAndHash` for the fields where the param builder normalizes\n * differently (names, city, state, zip): trim + lowercase, pass a pre-hashed value through,\n * apply the field rule, SHA-256. Field rules are copied from `ServerSideUtils` verbatim so the\n * differential test can hold the outputs equal.\n */\nfunction hashed(\n input: string | undefined,\n rule: (lowered: string) => string | undefined\n): string | undefined {\n if (!input) return undefined;\n const lowered = input.trim().toLowerCase();\n if (!lowered) return undefined;\n if (SHA256_OR_MD5.test(lowered)) return lowered;\n const normalized = rule(lowered);\n if (normalized === undefined) return undefined;\n return createHash('sha256').update(normalized).digest('hex');\n}\n\nconst nameRule = (v: string) => v;\nconst f5Rule = (v: string) => (v.length <= 5 ? v : v.slice(0, 5));\nconst cityStateRule = (v: string) => v.replace(/[0-9\\s().-]/g, '');\nconst zipRule = (v: string) => {\n const zip = v.replace(/\\s/g, '').split('-', 1)[0];\n return zip.length < 2 ? undefined : zip;\n};\n\n/**\n * Every multi-value field is deduplicated after hashing, order preserved — the SDK runs each\n * through `dedupArray` (a Set), `external_id` included.\n */\nconst dedupe = (hashes: (string | undefined)[]) =>\n Array.from(new Set(hashes.filter((hash): hash is string => hash !== undefined)));\n\nconst list = (hashes: (string | undefined)[]) => {\n const deduped = dedupe(hashes);\n return deduped.length > 0 ? deduped : undefined;\n};\n\ninterface CapiUserData {\n em?: string[];\n ph?: string[];\n ge?: string[];\n fn?: string[];\n ln?: string[];\n ct?: string[];\n st?: string[];\n zp?: string[];\n country?: string[];\n external_id?: string[];\n client_ip_address?: string;\n client_user_agent?: string;\n fbc?: string;\n fbp?: string;\n fb_login_id?: string;\n page_id?: string;\n f5first?: string;\n f5last?: string;\n dobd?: string;\n dobm?: string;\n doby?: string;\n madid?: string;\n}\n\nfunction getUserData(tags: TrackTags, data: UserProvidedData, eventTimeMs: number): CapiUserData {\n const userData: CapiUserData = {};\n\n if (data.email) {\n const emails = Array.isArray(data.email) ? data.email : [data.email];\n userData.em = list(emails.map((email) => pii(email, PII_DATA_TYPE.EMAIL)));\n }\n if (data.phone_number) {\n const phones = Array.isArray(data.phone_number) ? data.phone_number : [data.phone_number];\n userData.ph = list(phones.map((phone) => pii(phone, PII_DATA_TYPE.PHONE)));\n }\n if (data.gender === 'female' || data.gender === 'male') {\n userData.ge = list([pii(data.gender === 'female' ? 'f' : 'm', PII_DATA_TYPE.GENDER)]);\n }\n if (data.address) {\n if (Array.isArray(data.address)) {\n // The SDK sets these lists unconditionally in the multi-address branch, so an all-empty\n // column still goes out as `[]` — kept for byte-equality with the current sender.\n userData.fn = dedupe(data.address.map((a) => hashed(a.first_name, nameRule)));\n userData.ln = dedupe(data.address.map((a) => hashed(a.last_name, nameRule)));\n userData.ct = dedupe(data.address.map((a) => hashed(a.city, cityStateRule)));\n userData.st = dedupe(data.address.map((a) => hashed(a.region, cityStateRule)));\n userData.zp = dedupe(data.address.map((a) => hashed(a.postal_code, zipRule)));\n userData.country = dedupe(\n data.address.map((a) => pii(normalizeCountry(a.country), PII_DATA_TYPE.COUNTRY))\n );\n } else {\n userData.fn = list([hashed(data.address.first_name, nameRule)]);\n userData.ln = list([hashed(data.address.last_name, nameRule)]);\n userData.ct = list([hashed(data.address.city, cityStateRule)]);\n userData.st = list([hashed(data.address.region, cityStateRule)]);\n userData.zp = list([hashed(data.address.postal_code, zipRule)]);\n userData.country = list([pii(normalizeCountry(data.address.country), PII_DATA_TYPE.COUNTRY)]);\n // The 5-character prefixes, exactly as the current sender builds them: sliced first, then\n // normalized. Only the single-address branch sets them, as before.\n userData.f5first = hashed(data.address.first_name?.slice(0, 5), f5Rule);\n userData.f5last = hashed(data.address.last_name?.slice(0, 5), f5Rule);\n }\n }\n if (data.birthday) {\n const { year, month, day } = data.birthday;\n if (/^\\d{4}$/.test(year.toString())) {\n userData.doby = createHash('sha256').update(year.toString()).digest('hex');\n }\n if (month >= 1 && month <= 12) {\n userData.dobm = createHash('sha256').update(month.toString().padStart(2, '0')).digest('hex');\n }\n if (day >= 1 && day <= 31) {\n userData.dobd = createHash('sha256').update(day.toString().padStart(2, '0')).digest('hex');\n }\n }\n if (data.user_id && data.user_id.length !== 0) {\n // Unhashed, as the business SDK sends it. Meta documents hashing as recommended, not\n // required; changing it here would break continuity with every event sent so far.\n userData.external_id = [data.user_id];\n }\n if (data.ip_address) {\n userData.client_ip_address = data.ip_address;\n }\n if (data.user_agent) {\n userData.client_user_agent = data.user_agent;\n }\n if (data.fb_login_id) {\n userData.fb_login_id = data.fb_login_id;\n }\n if (data.fb_page_id) {\n userData.page_id = data.fb_page_id;\n }\n\n const fbc = metaFbc(tags, eventTimeMs);\n if (fbc) {\n userData.fbc = fbc;\n }\n const fbp = metaFbp(tags);\n if (fbp) {\n userData.fbp = fbp;\n }\n if (tags.advertising_id) {\n userData.madid = tags.advertising_id;\n }\n if (tags.ip_address && typeof tags.ip_address === 'string') {\n userData.client_ip_address = tags.ip_address;\n }\n\n return userData;\n}\n\nconst DELIVERY_CATEGORIES = ['in_store', 'curbside', 'home_delivery'];\n\n// oxlint-disable-next-line @typescript-eslint/no-explicit-any\nfunction getCustomData({ name, properties }: TrackEvent<any>): Record<string, unknown> {\n const [_, _name, fbEventProperties] = mapFBEvent(name, properties);\n const {\n value,\n currency,\n content_name,\n content_category,\n content_ids,\n contents,\n content_type,\n predicted_ltv,\n num_items,\n search_string,\n status,\n delivery_category,\n ...custom_properties\n } = fbEventProperties;\n\n const customData: Record<string, unknown> = {};\n // oxlint-disable-next-line typescript/no-unnecessary-condition -- custom event properties may carry `value: null` at runtime despite the declared type\n if (value !== undefined && value !== null && Number.isFinite(Number.parseFloat(String(value)))) {\n customData.value = Number.parseFloat(String(value));\n }\n if (currency) {\n // The business SDK also validates against the ISO 4217 list and throws; here an unknown\n // code is forwarded uppercased rather than costing the batch.\n customData.currency = currency\n .trim()\n .toUpperCase()\n .replace(/[^A-Z]/g, '');\n }\n if (content_name) customData.content_name = content_name;\n if (content_category) customData.content_category = content_category;\n if (content_ids) customData.content_ids = content_ids;\n if (contents) {\n customData.contents = contents.map((c) => {\n const content: Record<string, unknown> = {};\n if (c.id) content.id = c.id;\n if (c.quantity) content.quantity = c.quantity;\n if (c.item_price) content.item_price = c.item_price;\n if (c.title) content.title = c.title;\n if (c.description) content.description = c.description;\n if (c.brand) content.brand = c.brand;\n if (c.category) content.category = c.category;\n const delivery = c.delivery_category?.trim().toLowerCase();\n if (delivery && DELIVERY_CATEGORIES.includes(delivery)) content.delivery_category = delivery;\n return content;\n });\n }\n if (content_type) customData.content_type = content_type;\n if (predicted_ltv) customData.predicted_ltv = predicted_ltv;\n if (num_items) customData.num_items = num_items;\n if (search_string) customData.search_string = search_string;\n if (status) customData.status = status.toString();\n const delivery = delivery_category?.trim().toLowerCase();\n if (delivery && DELIVERY_CATEGORIES.includes(delivery)) customData.delivery_category = delivery;\n\n return { ...customData, ...custom_properties };\n}\n\n/**\n * `extinfo` is keyed by field index. The business SDK builds the same object and leaves unset\n * indexes out when it serializes; the documented 16-element array is what the Graph endpoint\n * decodes this into. Indexes: 0 version, 1 package, 2 short version, 3 long version, 4 OS\n * version, 5 device model, 6 locale, 9 screen width, 10 height, 11 density.\n */\nfunction getAppData(tags: TrackTags, appPackageName: string): Record<string, unknown> {\n const extinfo: Record<number, string | number> = {};\n if (tags.os_name === 'iOS' || tags.os_name === 'iPadOS') {\n extinfo[0] = 'i2';\n } else if (tags.os_name === 'Android') {\n extinfo[0] = 'a2';\n }\n extinfo[1] = appPackageName;\n const shortVersion = tags.release?.split('.').at(0);\n if (shortVersion) extinfo[2] = shortVersion;\n if (tags.release) extinfo[3] = tags.release;\n if (tags.os_version) extinfo[4] = tags.os_version;\n if (tags.device_model_id) extinfo[5] = tags.device_model_id;\n if (tags.language) extinfo[6] = tags.language;\n if (tags.screen_width) extinfo[9] = tags.screen_width;\n if (tags.screen_height) extinfo[10] = tags.screen_height;\n if (tags.device_pixel_ratio) extinfo[11] = tags.device_pixel_ratio.toString();\n\n const appData: Record<string, unknown> = { extinfo };\n if (tags.advertising_id) appData.advertiser_tracking_enabled = true;\n if (tags.install_referrer) appData.install_referrer = tags.install_referrer;\n return appData;\n}\n\n/** One entry of the `data` array POSTed to `/{pixel_id}/events`. */\nexport interface CapiEvent {\n event_name: string;\n event_time: number;\n event_id: string;\n event_source_url?: string;\n action_source: 'app' | 'website' | 'other';\n user_data: CapiUserData;\n custom_data: Record<string, unknown>;\n app_data?: Record<string, unknown>;\n}\n\n/**\n * `action_source` is derived from `event.platform` alone: an OS platform is an app, `web` is a\n * website, and everything else — including a backend-built offline conversion, which should\n * declare `platform: 'unknown'` — lands in Meta's `other`, exactly where the business-SDK\n * sender put an explicit `'offline'` too.\n */\nexport function getCapiEvent(\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n event: TrackEvent<any>,\n data: UserProvidedData,\n appPackageName?: string\n): CapiEvent {\n const eventTimeMs = new Date(event.created_at).getTime();\n const [_, eventName] = mapFBEvent(event.name, event.properties);\n const source = resolveActionSource(event.platform);\n\n const capiEvent: CapiEvent = {\n event_name: eventName,\n event_time: Math.round(eventTimeMs / 1000),\n event_id: event.tags.idempotency_key ?? event.id,\n // `action_source` is required; an offline conversion and an undeterminable platform both\n // land in Meta's catch-all, exactly as in the business-SDK sender.\n action_source: source === 'app' ? 'app' : source === 'web' ? 'website' : 'other',\n user_data: getUserData(event.tags, data, eventTimeMs),\n custom_data: getCustomData(event),\n };\n if (source === 'app' && appPackageName) {\n capiEvent.app_data = getAppData(event.tags, appPackageName);\n }\n const eventSourceUrl = pageLocation(event.tags);\n if (eventSourceUrl) {\n capiEvent.event_source_url = eventSourceUrl;\n }\n return capiEvent;\n}\n\nexport interface MetaConversionsOptions {\n /** Attaches `app_data`/extinfo to events whose platform resolves to an app. */\n appPackageName?: string;\n /** Routes the batch to Events Manager's Test Events tab instead of production. */\n testEventCode?: string;\n /** Graph API version, e.g. 'v24.0'. */\n apiVersion?: string;\n}\n\nexport interface MetaConversionsResponse {\n events_received?: number;\n messages?: string[];\n fbtrace_id?: string;\n}\n\n/**\n * The lightweight counterpart to the legacy SDK sender (`@shware/analytics/server/legacy`):\n * same filtering, same payload, same never-throws contract, minus the 31MB SDK. The token\n * travels in the JSON body, never in the URL, and is never logged.\n */\nexport async function sendEvents(\n accessToken: string,\n pixelId: string,\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n events: TrackEvent<any>[],\n data: UserProvidedData = {},\n options: MetaConversionsOptions = {}\n): Promise<MetaConversionsResponse | undefined> {\n const capiEvents = events\n .filter((event) => !IGNORED_EVENTS.includes(event.name))\n .map((event) => getCapiEvent(event, data, options.appPackageName));\n if (capiEvents.length === 0) return undefined;\n\n const version = options.apiVersion ?? API_VERSION;\n try {\n const response = await fetch(`https://graph.facebook.com/${version}/${pixelId}/events`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Accept: 'application/json' },\n body: JSON.stringify({\n data: capiEvents,\n test_event_code: options.testEventCode,\n access_token: accessToken,\n }),\n });\n if (response.ok) return (await response.json()) as MetaConversionsResponse;\n const { status } = response;\n const message = await response.text();\n console.error(`Failed to send Meta conversion, status: ${status}, body: ${message}`);\n return undefined;\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n console.error(`Failed to send Meta conversion, network error: ${message}`);\n return undefined;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"meta-capi.mjs","names":[],"sources":["../../src/server/meta-capi.ts"],"sourcesContent":["/**\n * Meta Conversions API sender built on `capi-param-builder-nodejs` and plain `fetch`, replacing\n * the 31MB `facebook-nodejs-business-sdk` with ~0.5MB of parameter building. The SDK-based\n * sender survives untouched at `@shware/analytics/server/legacy`; this module is its drop-in\n * successor.\n *\n * The wire payload is byte-identical to what the business SDK's `ServerEvent.normalize()`\n * produces — enforced by a differential test that runs both builders over the same events.\n * Where the two libraries normalize differently (the business SDK keeps punctuation in names,\n * spaces in multi-word last names, accents in cities; the param builder strips them per the\n * current documented rules), this module follows the business SDK so that switching senders\n * cannot change a single hash Meta receives. The param builder is used where the two provably\n * agree — email, phone, gender, country — and its adoption-tracking appendix (`<hash>.<token>`)\n * is stripped for the same reason.\n *\n * Two deliberate behavior differences from the business SDK, both on invalid input only:\n * an invalid field (malformed email, non-ISO country, letters in a phone) is dropped from\n * user_data instead of throwing inside `execute()` and costing the whole batch; an invalid\n * currency is uppercased and forwarded instead of rejected. Valid input is unaffected.\n *\n * https://developers.facebook.com/docs/marketing-api/conversions-api/using-the-api\n * https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/customer-information-parameters\n */\nimport { createHash } from 'node:crypto';\nimport { fetch } from '@shware/utils';\nimport { PII_DATA_TYPE, ParamBuilder } from 'capi-param-builder-nodejs';\nimport { IGNORED_EVENTS } from '../third-parties/ignored-events';\nimport { mapFBEvent } from '../track/fbq';\nimport type { TrackEvent, TrackTags, UserProvidedData } from '../track/types';\nimport { resolveActionSource } from './action-source';\nimport { metaFbc, metaFbp } from './click-ids';\nimport { pageLocation } from './page-location';\n\n/** Matches the Graph API version the pinned business SDK speaks (`FacebookAdsApi.VERSION`). */\nconst API_VERSION = 'v24.0';\n\nconst USER_ASSIGNED_COUNTRIES: string[] = ['xk'];\nfunction normalizeCountry(input: string | undefined): string | undefined {\n const country = input?.split(/[-_]/).at(0)?.toLowerCase();\n if (!country) return undefined;\n return USER_ASSIGNED_COUNTRIES.includes(country) ? undefined : country;\n}\n\n/**\n * Lazy for the same reason as the session and the token bucket (see setup/session.ts):\n * Cloudflare Workers refuse some work in module scope, and nothing here is per-request state.\n */\nlet paramBuilder: ParamBuilder | undefined;\n\n/**\n * Normalize and hash one PII value through the param builder, stripped to the bare SHA-256 the\n * business SDK sends: the builder appends a `.<language-token>` adoption marker that Meta's\n * endpoint tolerates but the business SDK never produced. Pre-hashed input passes through, as\n * it does in the business SDK.\n */\nfunction pii(value: string | undefined, dataType: string): string | undefined {\n if (!value) return undefined;\n const built = (paramBuilder ??= new ParamBuilder()).getNormalizedAndHashedPII(value, dataType);\n return built?.split('.').at(0) ?? undefined;\n}\n\nconst SHA256_OR_MD5 = /^[a-f0-9]{64}$|^[a-f0-9]{32}$/;\n\n/**\n * The business SDK's `normalizeAndHash` for the fields where the param builder normalizes\n * differently (names, city, state, zip): trim + lowercase, pass a pre-hashed value through,\n * apply the field rule, SHA-256. Field rules are copied from `ServerSideUtils` verbatim so the\n * differential test can hold the outputs equal.\n */\nfunction hashed(\n input: string | undefined,\n rule: (lowered: string) => string | undefined\n): string | undefined {\n if (!input) return undefined;\n const lowered = input.trim().toLowerCase();\n if (!lowered) return undefined;\n if (SHA256_OR_MD5.test(lowered)) return lowered;\n const normalized = rule(lowered);\n if (normalized === undefined) return undefined;\n return createHash('sha256').update(normalized).digest('hex');\n}\n\nconst nameRule = (v: string) => v;\nconst f5Rule = (v: string) => (v.length <= 5 ? v : v.slice(0, 5));\nconst cityStateRule = (v: string) => v.replace(/[0-9\\s().-]/g, '');\nconst zipRule = (v: string) => {\n const zip = v.replace(/\\s/g, '').split('-', 1)[0];\n return zip.length < 2 ? undefined : zip;\n};\n\n/**\n * Every multi-value field is deduplicated after hashing, order preserved — the SDK runs each\n * through `dedupArray` (a Set), `external_id` included.\n */\nconst dedupe = (hashes: (string | undefined)[]) =>\n Array.from(new Set(hashes.filter((hash): hash is string => hash !== undefined)));\n\nconst list = (hashes: (string | undefined)[]) => {\n const deduped = dedupe(hashes);\n return deduped.length > 0 ? deduped : undefined;\n};\n\ninterface CapiUserData {\n em?: string[];\n ph?: string[];\n ge?: string[];\n fn?: string[];\n ln?: string[];\n ct?: string[];\n st?: string[];\n zp?: string[];\n country?: string[];\n external_id?: string[];\n client_ip_address?: string;\n client_user_agent?: string;\n fbc?: string;\n fbp?: string;\n fb_login_id?: string;\n page_id?: string;\n f5first?: string;\n f5last?: string;\n dobd?: string;\n dobm?: string;\n doby?: string;\n madid?: string;\n}\n\nfunction getUserData(tags: TrackTags, data: UserProvidedData, eventTimeMs: number): CapiUserData {\n const userData: CapiUserData = {};\n\n if (data.email) {\n const emails = Array.isArray(data.email) ? data.email : [data.email];\n userData.em = list(emails.map((email) => pii(email, PII_DATA_TYPE.EMAIL)));\n }\n if (data.phone_number) {\n const phones = Array.isArray(data.phone_number) ? data.phone_number : [data.phone_number];\n userData.ph = list(phones.map((phone) => pii(phone, PII_DATA_TYPE.PHONE)));\n }\n if (data.gender === 'female' || data.gender === 'male') {\n userData.ge = list([pii(data.gender === 'female' ? 'f' : 'm', PII_DATA_TYPE.GENDER)]);\n }\n if (data.address) {\n if (Array.isArray(data.address)) {\n // The SDK sets these lists unconditionally in the multi-address branch, so an all-empty\n // column still goes out as `[]` — kept for byte-equality with the current sender.\n userData.fn = dedupe(data.address.map((a) => hashed(a.first_name, nameRule)));\n userData.ln = dedupe(data.address.map((a) => hashed(a.last_name, nameRule)));\n userData.ct = dedupe(data.address.map((a) => hashed(a.city, cityStateRule)));\n userData.st = dedupe(data.address.map((a) => hashed(a.region, cityStateRule)));\n userData.zp = dedupe(data.address.map((a) => hashed(a.postal_code, zipRule)));\n userData.country = dedupe(\n data.address.map((a) => pii(normalizeCountry(a.country), PII_DATA_TYPE.COUNTRY))\n );\n } else {\n userData.fn = list([hashed(data.address.first_name, nameRule)]);\n userData.ln = list([hashed(data.address.last_name, nameRule)]);\n userData.ct = list([hashed(data.address.city, cityStateRule)]);\n userData.st = list([hashed(data.address.region, cityStateRule)]);\n userData.zp = list([hashed(data.address.postal_code, zipRule)]);\n userData.country = list([pii(normalizeCountry(data.address.country), PII_DATA_TYPE.COUNTRY)]);\n // The 5-character prefixes, exactly as the current sender builds them: sliced first, then\n // normalized. Only the single-address branch sets them, as before.\n userData.f5first = hashed(data.address.first_name?.slice(0, 5), f5Rule);\n userData.f5last = hashed(data.address.last_name?.slice(0, 5), f5Rule);\n }\n }\n if (data.birthday) {\n const { year, month, day } = data.birthday;\n if (/^\\d{4}$/.test(year.toString())) {\n userData.doby = createHash('sha256').update(year.toString()).digest('hex');\n }\n if (month >= 1 && month <= 12) {\n userData.dobm = createHash('sha256').update(month.toString().padStart(2, '0')).digest('hex');\n }\n if (day >= 1 && day <= 31) {\n userData.dobd = createHash('sha256').update(day.toString().padStart(2, '0')).digest('hex');\n }\n }\n if (data.user_id && data.user_id.length !== 0) {\n // Unhashed, as the business SDK sends it. Meta documents hashing as recommended, not\n // required; changing it here would break continuity with every event sent so far.\n userData.external_id = [data.user_id];\n }\n if (data.ip_address) {\n userData.client_ip_address = data.ip_address;\n }\n if (data.user_agent) {\n userData.client_user_agent = data.user_agent;\n }\n if (data.fb_login_id) {\n userData.fb_login_id = data.fb_login_id;\n }\n if (data.fb_page_id) {\n userData.page_id = data.fb_page_id;\n }\n\n const fbc = metaFbc(tags, eventTimeMs);\n if (fbc) {\n userData.fbc = fbc;\n }\n const fbp = metaFbp(tags);\n if (fbp) {\n userData.fbp = fbp;\n }\n if (tags.advertising_id) {\n userData.madid = tags.advertising_id;\n }\n if (tags.ip_address && typeof tags.ip_address === 'string') {\n userData.client_ip_address = tags.ip_address;\n }\n\n return userData;\n}\n\nconst DELIVERY_CATEGORIES = ['in_store', 'curbside', 'home_delivery'];\n\n// oxlint-disable-next-line @typescript-eslint/no-explicit-any\nfunction getCustomData({ name, properties }: TrackEvent<any>): Record<string, unknown> {\n const [_, _name, fbEventProperties] = mapFBEvent(name, properties);\n const {\n value,\n currency,\n content_name,\n content_category,\n content_ids,\n contents,\n content_type,\n predicted_ltv,\n num_items,\n search_string,\n status,\n delivery_category,\n ...custom_properties\n } = fbEventProperties;\n\n const customData: Record<string, unknown> = {};\n // oxlint-disable-next-line typescript/no-unnecessary-condition -- custom event properties may carry `value: null` at runtime despite the declared type\n if (value !== undefined && value !== null && Number.isFinite(Number.parseFloat(String(value)))) {\n customData.value = Number.parseFloat(String(value));\n }\n if (currency) {\n // The business SDK also validates against the ISO 4217 list and throws; here an unknown\n // code is forwarded uppercased rather than costing the batch.\n customData.currency = currency\n .trim()\n .toUpperCase()\n .replace(/[^A-Z]/g, '');\n }\n if (content_name) customData.content_name = content_name;\n if (content_category) customData.content_category = content_category;\n if (content_ids) customData.content_ids = content_ids;\n if (contents) {\n customData.contents = contents.map((c) => {\n const content: Record<string, unknown> = {};\n if (c.id) content.id = c.id;\n if (c.quantity) content.quantity = c.quantity;\n if (c.item_price) content.item_price = c.item_price;\n if (c.title) content.title = c.title;\n if (c.description) content.description = c.description;\n if (c.brand) content.brand = c.brand;\n if (c.category) content.category = c.category;\n const delivery = c.delivery_category?.trim().toLowerCase();\n if (delivery && DELIVERY_CATEGORIES.includes(delivery)) content.delivery_category = delivery;\n return content;\n });\n }\n if (content_type) customData.content_type = content_type;\n if (predicted_ltv) customData.predicted_ltv = predicted_ltv;\n if (num_items) customData.num_items = num_items;\n if (search_string) customData.search_string = search_string;\n if (status) customData.status = status.toString();\n const delivery = delivery_category?.trim().toLowerCase();\n if (delivery && DELIVERY_CATEGORIES.includes(delivery)) customData.delivery_category = delivery;\n\n return { ...customData, ...custom_properties };\n}\n\n/**\n * `extinfo` is keyed by field index. The business SDK builds the same object and leaves unset\n * indexes out when it serializes; the documented 16-element array is what the Graph endpoint\n * decodes this into. Indexes: 0 version, 1 package, 2 short version, 3 long version, 4 OS\n * version, 5 device model, 6 locale, 9 screen width, 10 height, 11 density.\n */\nfunction getAppData(tags: TrackTags, appPackageName: string): Record<string, unknown> {\n const extinfo: Record<number, string | number> = {};\n if (tags.os_name === 'iOS' || tags.os_name === 'iPadOS') {\n extinfo[0] = 'i2';\n } else if (tags.os_name === 'Android') {\n extinfo[0] = 'a2';\n }\n extinfo[1] = appPackageName;\n const shortVersion = tags.release?.split('.').at(0);\n if (shortVersion) extinfo[2] = shortVersion;\n if (tags.release) extinfo[3] = tags.release;\n if (tags.os_version) extinfo[4] = tags.os_version;\n if (tags.device_model_id) extinfo[5] = tags.device_model_id;\n if (tags.language) extinfo[6] = tags.language;\n if (tags.screen_width) extinfo[9] = tags.screen_width;\n if (tags.screen_height) extinfo[10] = tags.screen_height;\n if (tags.device_pixel_ratio) extinfo[11] = tags.device_pixel_ratio.toString();\n\n const appData: Record<string, unknown> = { extinfo };\n if (tags.advertising_id) appData.advertiser_tracking_enabled = true;\n if (tags.install_referrer) appData.install_referrer = tags.install_referrer;\n return appData;\n}\n\n/** One entry of the `data` array POSTed to `/{pixel_id}/events`. */\nexport interface CapiEvent {\n event_name: string;\n event_time: number;\n event_id: string;\n event_source_url?: string;\n action_source: 'app' | 'website' | 'other';\n user_data: CapiUserData;\n custom_data: Record<string, unknown>;\n app_data?: Record<string, unknown>;\n}\n\n/**\n * `action_source` is derived from `event.platform` alone: an OS platform is an app, `web` is a\n * website, and everything else — including a backend-built offline conversion, which should\n * declare `platform: 'unknown'` — lands in Meta's `other`, exactly where the business-SDK\n * sender put an explicit `'offline'` too.\n */\nexport function getCapiEvent(\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n event: TrackEvent<any>,\n data: UserProvidedData,\n appPackageName?: string\n): CapiEvent {\n const eventTimeMs = new Date(event.created_at).getTime();\n const [_, eventName] = mapFBEvent(event.name, event.properties);\n const source = resolveActionSource(event.platform);\n\n const capiEvent: CapiEvent = {\n event_name: eventName,\n event_time: Math.round(eventTimeMs / 1000),\n event_id: event.tags.idempotency_key ?? event.id,\n // `action_source` is required; an offline conversion and an undeterminable platform both\n // land in Meta's catch-all, exactly as in the business-SDK sender.\n action_source: source === 'app' ? 'app' : source === 'web' ? 'website' : 'other',\n user_data: getUserData(event.tags, data, eventTimeMs),\n custom_data: getCustomData(event),\n };\n if (source === 'app' && appPackageName) {\n capiEvent.app_data = getAppData(event.tags, appPackageName);\n }\n const eventSourceUrl = pageLocation(event.tags);\n if (eventSourceUrl) {\n capiEvent.event_source_url = eventSourceUrl;\n }\n return capiEvent;\n}\n\nexport interface MetaConversionsOptions {\n /** Attaches `app_data`/extinfo to events whose platform resolves to an app. */\n appPackageName?: string;\n /** Routes the batch to Events Manager's Test Events tab instead of production. */\n testEventCode?: string;\n /** Graph API version, e.g. 'v24.0'. */\n apiVersion?: string;\n}\n\nexport interface MetaConversionsResponse {\n events_received?: number;\n messages?: string[];\n fbtrace_id?: string;\n}\n\n/**\n * An `event_time` over 7 days old fails the whole request — \"we return an error for the entire\n * request and process no events\" — so such an event is left out; a minute's margin for the trip.\n * https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/server-event\n */\nconst MAX_EVENT_AGE_MS = 7 * 24 * 60 * 60 * 1000 - 60 * 1000;\n\n/**\n * The lightweight counterpart to the legacy SDK sender (`@shware/analytics/server/legacy`):\n * same filtering, same payload, same never-throws contract, minus the 31MB SDK. The token\n * travels in the JSON body, never in the URL, and is never logged.\n */\nexport async function sendEvents(\n accessToken: string,\n pixelId: string,\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n events: TrackEvent<any>[],\n data: UserProvidedData = {},\n options: MetaConversionsOptions = {}\n): Promise<MetaConversionsResponse | undefined> {\n const capiEvents = events\n .filter((event) => !IGNORED_EVENTS.includes(event.name))\n .filter((event) => Date.now() - Date.parse(event.created_at) <= MAX_EVENT_AGE_MS)\n .map((event) => getCapiEvent(event, data, options.appPackageName));\n if (capiEvents.length === 0) return undefined;\n\n const version = options.apiVersion ?? API_VERSION;\n try {\n const response = await fetch(`https://graph.facebook.com/${version}/${pixelId}/events`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Accept: 'application/json' },\n body: JSON.stringify({\n data: capiEvents,\n test_event_code: options.testEventCode,\n access_token: accessToken,\n }),\n });\n if (response.ok) return (await response.json()) as MetaConversionsResponse;\n const { status } = response;\n const message = await response.text();\n console.error(`Failed to send Meta conversion, status: ${status}, body: ${message}`);\n return undefined;\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n console.error(`Failed to send Meta conversion, network error: ${message}`);\n return undefined;\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkCA,MAAM,cAAc;AAEpB,MAAM,0BAAoC,CAAC,IAAI;AAC/C,SAAS,iBAAiB,OAA+C;CACvE,MAAM,UAAU,OAAO,MAAM,MAAM,CAAC,CAAC,GAAG,CAAC,CAAC,EAAE,YAAY;CACxD,IAAI,CAAC,SAAS,OAAO,KAAA;CACrB,OAAO,wBAAwB,SAAS,OAAO,IAAI,KAAA,IAAY;AACjE;;;;;AAMA,IAAI;;;;;;;AAQJ,SAAS,IAAI,OAA2B,UAAsC;CAC5E,IAAI,CAAC,OAAO,OAAO,KAAA;CAEnB,QADe,iBAAiB,IAAI,aAAa,EAAA,CAAG,0BAA0B,OAAO,QAC1E,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,GAAG,CAAC,KAAK,KAAA;AACpC;AAEA,MAAM,gBAAgB;;;;;;;AAQtB,SAAS,OACP,OACA,MACoB;CACpB,IAAI,CAAC,OAAO,OAAO,KAAA;CACnB,MAAM,UAAU,MAAM,KAAK,CAAC,CAAC,YAAY;CACzC,IAAI,CAAC,SAAS,OAAO,KAAA;CACrB,IAAI,cAAc,KAAK,OAAO,GAAG,OAAO;CACxC,MAAM,aAAa,KAAK,OAAO;CAC/B,IAAI,eAAe,KAAA,GAAW,OAAO,KAAA;CACrC,OAAO,WAAW,QAAQ,CAAC,CAAC,OAAO,UAAU,CAAC,CAAC,OAAO,KAAK;AAC7D;AAEA,MAAM,YAAY,MAAc;AAChC,MAAM,UAAU,MAAe,EAAE,UAAU,IAAI,IAAI,EAAE,MAAM,GAAG,CAAC;AAC/D,MAAM,iBAAiB,MAAc,EAAE,QAAQ,gBAAgB,EAAE;AACjE,MAAM,WAAW,MAAc;CAC7B,MAAM,MAAM,EAAE,QAAQ,OAAO,EAAE,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC;CAC/C,OAAO,IAAI,SAAS,IAAI,KAAA,IAAY;AACtC;;;;;AAMA,MAAM,UAAU,WACd,MAAM,KAAK,IAAI,IAAI,OAAO,QAAQ,SAAyB,SAAS,KAAA,CAAS,CAAC,CAAC;AAEjF,MAAM,QAAQ,WAAmC;CAC/C,MAAM,UAAU,OAAO,MAAM;CAC7B,OAAO,QAAQ,SAAS,IAAI,UAAU,KAAA;AACxC;AA2BA,SAAS,YAAY,MAAiB,MAAwB,aAAmC;CAC/F,MAAM,WAAyB,CAAC;CAEhC,IAAI,KAAK,OAAO;EACd,MAAM,SAAS,MAAM,QAAQ,KAAK,KAAK,IAAI,KAAK,QAAQ,CAAC,KAAK,KAAK;EACnE,SAAS,KAAK,KAAK,OAAO,KAAK,UAAU,IAAI,OAAO,cAAc,KAAK,CAAC,CAAC;CAC3E;CACA,IAAI,KAAK,cAAc;EACrB,MAAM,SAAS,MAAM,QAAQ,KAAK,YAAY,IAAI,KAAK,eAAe,CAAC,KAAK,YAAY;EACxF,SAAS,KAAK,KAAK,OAAO,KAAK,UAAU,IAAI,OAAO,cAAc,KAAK,CAAC,CAAC;CAC3E;CACA,IAAI,KAAK,WAAW,YAAY,KAAK,WAAW,QAC9C,SAAS,KAAK,KAAK,CAAC,IAAI,KAAK,WAAW,WAAW,MAAM,KAAK,cAAc,MAAM,CAAC,CAAC;CAEtF,IAAI,KAAK,SAAS;EAChB,IAAI,MAAM,QAAQ,KAAK,OAAO,GAAG;GAG/B,SAAS,KAAK,OAAO,KAAK,QAAQ,KAAK,MAAM,OAAO,EAAE,YAAY,QAAQ,CAAC,CAAC;GAC5E,SAAS,KAAK,OAAO,KAAK,QAAQ,KAAK,MAAM,OAAO,EAAE,WAAW,QAAQ,CAAC,CAAC;GAC3E,SAAS,KAAK,OAAO,KAAK,QAAQ,KAAK,MAAM,OAAO,EAAE,MAAM,aAAa,CAAC,CAAC;GAC3E,SAAS,KAAK,OAAO,KAAK,QAAQ,KAAK,MAAM,OAAO,EAAE,QAAQ,aAAa,CAAC,CAAC;GAC7E,SAAS,KAAK,OAAO,KAAK,QAAQ,KAAK,MAAM,OAAO,EAAE,aAAa,OAAO,CAAC,CAAC;GAC5E,SAAS,UAAU,OACjB,KAAK,QAAQ,KAAK,MAAM,IAAI,iBAAiB,EAAE,OAAO,GAAG,cAAc,OAAO,CAAC,CACjF;EACF,OAAO;GACL,SAAS,KAAK,KAAK,CAAC,OAAO,KAAK,QAAQ,YAAY,QAAQ,CAAC,CAAC;GAC9D,SAAS,KAAK,KAAK,CAAC,OAAO,KAAK,QAAQ,WAAW,QAAQ,CAAC,CAAC;GAC7D,SAAS,KAAK,KAAK,CAAC,OAAO,KAAK,QAAQ,MAAM,aAAa,CAAC,CAAC;GAC7D,SAAS,KAAK,KAAK,CAAC,OAAO,KAAK,QAAQ,QAAQ,aAAa,CAAC,CAAC;GAC/D,SAAS,KAAK,KAAK,CAAC,OAAO,KAAK,QAAQ,aAAa,OAAO,CAAC,CAAC;GAC9D,SAAS,UAAU,KAAK,CAAC,IAAI,iBAAiB,KAAK,QAAQ,OAAO,GAAG,cAAc,OAAO,CAAC,CAAC;GAG5F,SAAS,UAAU,OAAO,KAAK,QAAQ,YAAY,MAAM,GAAG,CAAC,GAAG,MAAM;GACtE,SAAS,SAAS,OAAO,KAAK,QAAQ,WAAW,MAAM,GAAG,CAAC,GAAG,MAAM;EACtE;CACF;CACA,IAAI,KAAK,UAAU;EACjB,MAAM,EAAE,MAAM,OAAO,QAAQ,KAAK;EAClC,IAAI,UAAU,KAAK,KAAK,SAAS,CAAC,GAChC,SAAS,OAAO,WAAW,QAAQ,CAAC,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,OAAO,KAAK;EAE3E,IAAI,SAAS,KAAK,SAAS,IACzB,SAAS,OAAO,WAAW,QAAQ,CAAC,CAAC,OAAO,MAAM,SAAS,CAAC,CAAC,SAAS,GAAG,GAAG,CAAC,CAAC,CAAC,OAAO,KAAK;EAE7F,IAAI,OAAO,KAAK,OAAO,IACrB,SAAS,OAAO,WAAW,QAAQ,CAAC,CAAC,OAAO,IAAI,SAAS,CAAC,CAAC,SAAS,GAAG,GAAG,CAAC,CAAC,CAAC,OAAO,KAAK;CAE7F;CACA,IAAI,KAAK,WAAW,KAAK,QAAQ,WAAW,GAG1C,SAAS,cAAc,CAAC,KAAK,OAAO;CAEtC,IAAI,KAAK,YACP,SAAS,oBAAoB,KAAK;CAEpC,IAAI,KAAK,YACP,SAAS,oBAAoB,KAAK;CAEpC,IAAI,KAAK,aACP,SAAS,cAAc,KAAK;CAE9B,IAAI,KAAK,YACP,SAAS,UAAU,KAAK;CAG1B,MAAM,MAAM,QAAQ,MAAM,WAAW;CACrC,IAAI,KACF,SAAS,MAAM;CAEjB,MAAM,MAAM,QAAQ,IAAI;CACxB,IAAI,KACF,SAAS,MAAM;CAEjB,IAAI,KAAK,gBACP,SAAS,QAAQ,KAAK;CAExB,IAAI,KAAK,cAAc,OAAO,KAAK,eAAe,UAChD,SAAS,oBAAoB,KAAK;CAGpC,OAAO;AACT;AAEA,MAAM,sBAAsB;CAAC;CAAY;CAAY;AAAe;AAGpE,SAAS,cAAc,EAAE,MAAM,cAAwD;CACrF,MAAM,CAAC,GAAG,OAAO,qBAAqB,WAAW,MAAM,UAAU;CACjE,MAAM,EACJ,OACA,UACA,cACA,kBACA,aACA,UACA,cACA,eACA,WACA,eACA,QACA,mBACA,GAAG,sBACD;CAEJ,MAAM,aAAsC,CAAC;CAE7C,IAAI,UAAU,KAAA,KAAa,UAAU,QAAQ,OAAO,SAAS,OAAO,WAAW,OAAO,KAAK,CAAC,CAAC,GAC3F,WAAW,QAAQ,OAAO,WAAW,OAAO,KAAK,CAAC;CAEpD,IAAI,UAGF,WAAW,WAAW,SACnB,KAAK,CAAC,CACN,YAAY,CAAC,CACb,QAAQ,WAAW,EAAE;CAE1B,IAAI,cAAc,WAAW,eAAe;CAC5C,IAAI,kBAAkB,WAAW,mBAAmB;CACpD,IAAI,aAAa,WAAW,cAAc;CAC1C,IAAI,UACF,WAAW,WAAW,SAAS,KAAK,MAAM;EACxC,MAAM,UAAmC,CAAC;EAC1C,IAAI,EAAE,IAAI,QAAQ,KAAK,EAAE;EACzB,IAAI,EAAE,UAAU,QAAQ,WAAW,EAAE;EACrC,IAAI,EAAE,YAAY,QAAQ,aAAa,EAAE;EACzC,IAAI,EAAE,OAAO,QAAQ,QAAQ,EAAE;EAC/B,IAAI,EAAE,aAAa,QAAQ,cAAc,EAAE;EAC3C,IAAI,EAAE,OAAO,QAAQ,QAAQ,EAAE;EAC/B,IAAI,EAAE,UAAU,QAAQ,WAAW,EAAE;EACrC,MAAM,WAAW,EAAE,mBAAmB,KAAK,CAAC,CAAC,YAAY;EACzD,IAAI,YAAY,oBAAoB,SAAS,QAAQ,GAAG,QAAQ,oBAAoB;EACpF,OAAO;CACT,CAAC;CAEH,IAAI,cAAc,WAAW,eAAe;CAC5C,IAAI,eAAe,WAAW,gBAAgB;CAC9C,IAAI,WAAW,WAAW,YAAY;CACtC,IAAI,eAAe,WAAW,gBAAgB;CAC9C,IAAI,QAAQ,WAAW,SAAS,OAAO,SAAS;CAChD,MAAM,WAAW,mBAAmB,KAAK,CAAC,CAAC,YAAY;CACvD,IAAI,YAAY,oBAAoB,SAAS,QAAQ,GAAG,WAAW,oBAAoB;CAEvF,OAAO;EAAE,GAAG;EAAY,GAAG;CAAkB;AAC/C;;;;;;;AAQA,SAAS,WAAW,MAAiB,gBAAiD;CACpF,MAAM,UAA2C,CAAC;CAClD,IAAI,KAAK,YAAY,SAAS,KAAK,YAAY,UAC7C,QAAQ,KAAK;MACR,IAAI,KAAK,YAAY,WAC1B,QAAQ,KAAK;CAEf,QAAQ,KAAK;CACb,MAAM,eAAe,KAAK,SAAS,MAAM,GAAG,CAAC,CAAC,GAAG,CAAC;CAClD,IAAI,cAAc,QAAQ,KAAK;CAC/B,IAAI,KAAK,SAAS,QAAQ,KAAK,KAAK;CACpC,IAAI,KAAK,YAAY,QAAQ,KAAK,KAAK;CACvC,IAAI,KAAK,iBAAiB,QAAQ,KAAK,KAAK;CAC5C,IAAI,KAAK,UAAU,QAAQ,KAAK,KAAK;CACrC,IAAI,KAAK,cAAc,QAAQ,KAAK,KAAK;CACzC,IAAI,KAAK,eAAe,QAAQ,MAAM,KAAK;CAC3C,IAAI,KAAK,oBAAoB,QAAQ,MAAM,KAAK,mBAAmB,SAAS;CAE5E,MAAM,UAAmC,EAAE,QAAQ;CACnD,IAAI,KAAK,gBAAgB,QAAQ,8BAA8B;CAC/D,IAAI,KAAK,kBAAkB,QAAQ,mBAAmB,KAAK;CAC3D,OAAO;AACT;;;;;;;AAoBA,SAAgB,aAEd,OACA,MACA,gBACW;CACX,MAAM,cAAc,IAAI,KAAK,MAAM,UAAU,CAAC,CAAC,QAAQ;CACvD,MAAM,CAAC,GAAG,aAAa,WAAW,MAAM,MAAM,MAAM,UAAU;CAC9D,MAAM,SAAS,oBAAoB,MAAM,QAAQ;CAEjD,MAAM,YAAuB;EAC3B,YAAY;EACZ,YAAY,KAAK,MAAM,cAAc,GAAI;EACzC,UAAU,MAAM,KAAK,mBAAmB,MAAM;EAG9C,eAAe,WAAW,QAAQ,QAAQ,WAAW,QAAQ,YAAY;EACzE,WAAW,YAAY,MAAM,MAAM,MAAM,WAAW;EACpD,aAAa,cAAc,KAAK;CAClC;CACA,IAAI,WAAW,SAAS,gBACtB,UAAU,WAAW,WAAW,MAAM,MAAM,cAAc;CAE5D,MAAM,iBAAiB,aAAa,MAAM,IAAI;CAC9C,IAAI,gBACF,UAAU,mBAAmB;CAE/B,OAAO;AACT;;;;;;AAsBA,MAAM,mBAAmB;;;;;;AAOzB,eAAsB,WACpB,aACA,SAEA,QACA,OAAyB,CAAC,GAC1B,UAAkC,CAAC,GACW;CAC9C,MAAM,aAAa,OAChB,QAAQ,UAAU,CAAC,eAAe,SAAS,MAAM,IAAI,CAAC,CAAC,CACvD,QAAQ,UAAU,KAAK,IAAI,IAAI,KAAK,MAAM,MAAM,UAAU,KAAK,gBAAgB,CAAC,CAChF,KAAK,UAAU,aAAa,OAAO,MAAM,QAAQ,cAAc,CAAC;CACnE,IAAI,WAAW,WAAW,GAAG,OAAO,KAAA;CAEpC,MAAM,UAAU,QAAQ,cAAc;CACtC,IAAI;EACF,MAAM,WAAW,MAAM,MAAM,8BAA8B,QAAQ,GAAG,QAAQ,UAAU;GACtF,QAAQ;GACR,SAAS;IAAE,gBAAgB;IAAoB,QAAQ;GAAmB;GAC1E,MAAM,KAAK,UAAU;IACnB,MAAM;IACN,iBAAiB,QAAQ;IACzB,cAAc;GAChB,CAAC;EACH,CAAC;EACD,IAAI,SAAS,IAAI,OAAQ,MAAM,SAAS,KAAK;EAC7C,MAAM,EAAE,WAAW;EACnB,MAAM,UAAU,MAAM,SAAS,KAAK;EACpC,QAAQ,MAAM,2CAA2C,OAAO,UAAU,SAAS;EACnF;CACF,SAAS,OAAO;EACd,MAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;EACrE,QAAQ,MAAM,kDAAkD,SAAS;EACzE;CACF;AACF"}
|
|
@@ -176,8 +176,10 @@ async function sendEvent(accessToken, pixelId, event, data = {}, appPackageName,
|
|
|
176
176
|
return;
|
|
177
177
|
}
|
|
178
178
|
}
|
|
179
|
+
/** An `event_time` over 7 days old fails the whole request; see `meta-capi.ts`. */
|
|
180
|
+
const MAX_EVENT_AGE_MS = 60474e4;
|
|
179
181
|
async function sendEvents(accessToken, pixelId, events, data = {}, appPackageName, actionSource) {
|
|
180
|
-
const fbEvents = events.filter((event) => !require_third_parties_ignored_events.IGNORED_EVENTS.includes(event.name)).map((event) => getServerEvent(event, data, appPackageName, actionSource));
|
|
182
|
+
const fbEvents = events.filter((event) => !require_third_parties_ignored_events.IGNORED_EVENTS.includes(event.name)).filter((event) => Date.now() - Date.parse(event.created_at) <= MAX_EVENT_AGE_MS).map((event) => getServerEvent(event, data, appPackageName, actionSource));
|
|
181
183
|
if (fbEvents.length === 0) return void 0;
|
|
182
184
|
const request = new facebook_nodejs_business_sdk.EventRequest(accessToken, pixelId);
|
|
183
185
|
request.setEvents(fbEvents);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"meta-conversions-api.cjs","names":["UserData","metaFbc","metaFbp","ExtendedDeviceInfo","AppData","CustomData","mapFBEvent","Content","ServerEvent","resolveActionSource","pageLocation","IGNORED_EVENTS","EventRequest"],"sources":["../../src/server/meta-conversions-api.ts"],"sourcesContent":["import {\n AppData,\n Content,\n CustomData,\n EventRequest,\n ExtendedDeviceInfo,\n ServerEvent,\n UserData,\n} from 'facebook-nodejs-business-sdk';\nimport { IGNORED_EVENTS } from '../third-parties/ignored-events';\nimport { mapFBEvent } from '../track/fbq';\nimport type { TrackEvent, TrackTags, UserProvidedData } from '../track/types';\nimport { type EventActionSource, resolveActionSource } from './action-source';\nimport { metaFbc, metaFbp } from './click-ids';\nimport { pageLocation } from './page-location';\n\nconst USER_ASSIGNED_COUNTRIES: string[] = ['xk'];\nfunction normalizeCountry(input: string | undefined): string | undefined {\n const country = input?.split(/[-_]/).at(0)?.toLowerCase();\n if (!country) return undefined;\n return USER_ASSIGNED_COUNTRIES.includes(country) ? undefined : country;\n}\n\nfunction getUserData(tags: TrackTags, data: UserProvidedData, eventTimeMs: number) {\n const userData = new UserData();\n\n // set user-provided data\n if (data.email) {\n if (Array.isArray(data.email)) {\n userData.setEmails(data.email);\n } else {\n userData.setEmail(data.email);\n }\n }\n if (data.phone_number) {\n if (Array.isArray(data.phone_number)) {\n userData.setPhones(data.phone_number);\n } else {\n userData.setPhone(data.phone_number);\n }\n }\n if (data.gender) {\n if (data.gender === 'female') {\n userData.setGender('f');\n // oxlint-disable-next-line typescript/no-unnecessary-condition -- user-provided data may carry values outside the declared union at runtime\n } else if (data.gender === 'male') {\n userData.setGender('m');\n }\n }\n if (data.address) {\n if (Array.isArray(data.address)) {\n const firstNames = data.address.map((a) => a.first_name).filter(Boolean);\n const lastNames = data.address.map((a) => a.last_name).filter(Boolean);\n const cities = data.address.map((a) => a.city).filter(Boolean);\n const states = data.address.map((a) => a.region).filter(Boolean);\n const postalCodes = data.address.map((a) => a.postal_code).filter(Boolean);\n const countries = data.address.map((a) => normalizeCountry(a.country)).filter(Boolean);\n\n userData.setFirstNames(firstNames as string[]);\n userData.setLastNames(lastNames as string[]);\n userData.setCities(cities as string[]);\n userData.setStates(states as string[]);\n userData.setZips(postalCodes as string[]);\n userData.setCountries(countries as string[]);\n } else {\n if (data.address.first_name) {\n userData.setFirstName(data.address.first_name);\n userData.setF5First(data.address.first_name.slice(0, 5));\n }\n if (data.address.last_name) {\n userData.setLastName(data.address.last_name);\n userData.setF5Last(data.address.last_name.slice(0, 5));\n }\n if (data.address.city) userData.setCity(data.address.city);\n if (data.address.region) userData.setState(data.address.region);\n if (data.address.postal_code) userData.setZip(data.address.postal_code);\n if (data.address.country) {\n const country = normalizeCountry(data.address.country);\n if (country) userData.setCountry(country);\n }\n }\n }\n if (data.birthday) {\n userData.setDoby(data.birthday.year.toString());\n userData.setDobm(data.birthday.month.toString());\n userData.setDobd(data.birthday.day.toString());\n }\n if (data.user_id && data.user_id.length !== 0) {\n userData.setExternalId(data.user_id);\n }\n if (data.ip_address) {\n userData.setClientIpAddress(data.ip_address);\n }\n if (data.user_agent) {\n userData.setClientUserAgent(data.user_agent);\n }\n if (data.fb_login_id) {\n userData.setFbLoginId(data.fb_login_id);\n }\n if (data.fb_page_id) {\n userData.setPageId(data.fb_page_id);\n }\n\n // set tags info\n const fbc = metaFbc(tags, eventTimeMs);\n if (fbc) {\n userData.setFbc(fbc);\n }\n const fbp = metaFbp(tags);\n if (fbp) {\n userData.setFbp(fbp);\n }\n if (tags.advertising_id) {\n userData.setMadid(tags.advertising_id);\n }\n if (tags.ip_address && typeof tags.ip_address === 'string') {\n userData.setClientIpAddress(tags.ip_address);\n }\n\n return userData;\n}\n\nfunction getAppData(tags: TrackTags, appPackageName: string) {\n const extinfo = new ExtendedDeviceInfo();\n if (tags.os_name) {\n if (tags.os_name === 'iOS' || tags.os_name === 'iPadOS') {\n extinfo.setExtInfoVersion('i2');\n } else if (tags.os_name === 'Android') {\n extinfo.setExtInfoVersion('a2');\n }\n }\n extinfo.setAppPackageName(appPackageName);\n const shortVersion = tags.release?.split('.').at(0);\n if (shortVersion) {\n extinfo.setShortVersion(shortVersion);\n }\n if (tags.release) {\n extinfo.setLongVersion(tags.release);\n }\n if (tags.os_version) {\n extinfo.setOsVersion(tags.os_version);\n }\n if (tags.device_model_id) {\n extinfo.setDeviceModelName(tags.device_model_id);\n }\n if (tags.language) {\n extinfo.setLocale(tags.language);\n }\n if (tags.screen_width) {\n extinfo.setScreenWidth(tags.screen_width);\n }\n if (tags.screen_height) {\n extinfo.setScreenHeight(tags.screen_height);\n }\n if (tags.device_pixel_ratio) {\n extinfo.setScreenDensity(tags.device_pixel_ratio.toString());\n }\n\n const appData = new AppData();\n appData.setExtinfo(extinfo);\n if (tags.install_referrer) {\n appData.setInstallReferrer(tags.install_referrer);\n }\n if (tags.advertising_id) {\n appData.setAdvertiserTrackingEnabled(true);\n }\n if (tags.install_referrer) {\n appData.setInstallReferrer(tags.install_referrer);\n }\n\n return appData;\n}\n\n// oxlint-disable-next-line @typescript-eslint/no-explicit-any\nfunction getCustomData({ name, properties }: TrackEvent<any>) {\n const data = new CustomData();\n const [_, _name, fbEventProperties] = mapFBEvent(name, properties);\n const {\n value,\n currency,\n content_name,\n content_category,\n content_ids,\n contents,\n content_type,\n // order_id,\n predicted_ltv,\n num_items,\n search_string,\n status,\n // item_number,\n delivery_category,\n ...custom_properties\n } = fbEventProperties;\n // oxlint-disable-next-line typescript/no-unnecessary-condition -- custom event properties may carry `value: null` at runtime despite the declared type\n if (value !== undefined && value !== null) data.setValue(value);\n if (currency) data.setCurrency(currency);\n if (content_name) data.setContentName(content_name);\n if (content_category) data.setContentCategory(content_category);\n if (content_ids) data.setContentIds(content_ids);\n if (contents)\n data.setContents(\n contents.map((c) => {\n const result = new Content().setId(c.id).setQuantity(c.quantity);\n if (c.item_price) result.setItemPrice(c.item_price);\n if (c.title) result.setTitle(c.title);\n if (c.description) result.setDescription(c.description);\n if (c.brand) result.setBrand(c.brand);\n if (c.category) result.setCategory(c.category);\n if (c.delivery_category) result.setDeliveryCategory(c.delivery_category);\n return result;\n })\n );\n if (content_type) data.setContentType(content_type);\n // if (order_id) data.setOrderId(order_id);\n if (predicted_ltv) data.setPredictedLtv(predicted_ltv);\n if (num_items) data.setNumItems(num_items);\n if (search_string) data.setSearchString(search_string);\n if (status) data.setStatus(status.toString());\n // if (item_number) data.setItemNumber(item_number);\n if (delivery_category) data.setDeliveryCategory(delivery_category);\n data.setCustomProperties(custom_properties);\n return data;\n}\n\nexport function getServerEvent(\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n event: TrackEvent<any>,\n data: UserProvidedData,\n appPackageName?: string,\n actionSource?: EventActionSource\n) {\n const eventTimeMs = new Date(event.created_at).getTime();\n const userData = getUserData(event.tags, data, eventTimeMs);\n const customData = getCustomData(event);\n const [_, eventName] = mapFBEvent(event.name, event.properties);\n const serverEvent = new ServerEvent()\n .setEventId(event.tags.idempotency_key ?? event.id)\n .setEventName(eventName)\n .setEventTime(Math.round(eventTimeMs / 1000))\n .setUserData(userData)\n .setCustomData(customData);\n\n const source = resolveActionSource(event.platform, actionSource);\n if (source === 'app' && appPackageName) {\n const appData = getAppData(event.tags, appPackageName);\n serverEvent.setAppData(appData);\n }\n const eventSourceUrl = pageLocation(event.tags);\n if (eventSourceUrl) {\n serverEvent.setEventSourceUrl(eventSourceUrl);\n }\n switch (source) {\n case 'app':\n serverEvent.setActionSource('app');\n break;\n case 'web':\n serverEvent.setActionSource('website');\n break;\n default:\n // `action_source` is required on a server event, so both an offline conversion and an\n // undeterminable platform still have to send something. Meta has no generic offline\n // value: 'physical_store' and 'system_generated' are narrower claims only the caller can\n // make, so everything left lands in Meta's own catch-all.\n serverEvent.setActionSource('other');\n break;\n }\n return serverEvent;\n}\n\n/**\n * The shape `FacebookRequestError` exposes: `status` and `response` are the HTTP status and the\n * parsed error body, and both are null when the request never received a response.\n */\ntype MetaRequestError = { status?: number | null; message?: string; response?: unknown };\n\n/**\n * Meta's SDK rejects on API errors, where every other conversion sender here logs and resolves.\n * Keep the failure inside this module, so one rejected batch cannot fail the caller's request.\n * Never log the error itself: it also carries the access token, in both `url` and `data`.\n */\nfunction logError(error: unknown) {\n const { status, message, response } = (error ?? {}) as MetaRequestError;\n if (typeof status !== 'number') {\n console.error(`Failed to send Meta conversion, network error: ${message ?? String(error)}`);\n return;\n }\n console.error(\n `Failed to send Meta conversion, status: ${status}, body: ${JSON.stringify(response)}`\n );\n}\n\nexport async function sendEvent(\n accessToken: string,\n pixelId: string,\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n event: TrackEvent<any>,\n data: UserProvidedData = {},\n appPackageName?: string,\n actionSource?: EventActionSource\n) {\n if (IGNORED_EVENTS.includes(event.name)) return undefined;\n const request = new EventRequest(accessToken, pixelId);\n const fbEvent = getServerEvent(event, data, appPackageName, actionSource);\n request.setEvents([fbEvent]);\n try {\n return await request.execute();\n } catch (error) {\n logError(error);\n return undefined;\n }\n}\n\nexport async function sendEvents(\n accessToken: string,\n pixelId: string,\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n events: TrackEvent<any>[],\n data: UserProvidedData = {},\n appPackageName?: string,\n actionSource?: EventActionSource\n) {\n const fbEvents = events\n .filter((event) => !IGNORED_EVENTS.includes(event.name))\n .map((event) => getServerEvent(event, data, appPackageName, actionSource));\n if (fbEvents.length === 0) return undefined;\n const request = new EventRequest(accessToken, pixelId);\n request.setEvents(fbEvents);\n try {\n return await request.execute();\n } catch (error) {\n logError(error);\n return undefined;\n }\n}\n\nexport async function sendTestEvent(accessToken: string, pixelId: string, testEventCode: string) {\n const extinfo = new ExtendedDeviceInfo()\n .setExtInfoVersion('a2')\n .setAppPackageName('com.some.app')\n .setShortVersion('771')\n .setLongVersion('Version 7.7.1')\n .setOsVersion('10.1.1')\n .setDeviceModelName('OnePlus6')\n .setLocale('en_US')\n .setTimezoneAbbreviation('GMT-1')\n .setCarrier('TMobile')\n .setScreenWidth(1920)\n .setScreenHeight(1080)\n .setScreenDensity('2.00')\n .setCpuCoreCount(2)\n .setTotalDiskSpaceGb(128)\n .setFreeDiskSpaceGb(8)\n .setDeviceTimeZone('USA/New York');\n\n const userData = new UserData().setEmail('test@example.com');\n const appData = new AppData().setExtinfo(extinfo);\n const event = new ServerEvent()\n .setEventId(Math.round(Math.random() * 1000_000).toString())\n .setEventName('TestEvent')\n .setEventTime(Math.round(Date.now() / 1000))\n .setUserData(userData)\n .setAppData(appData)\n .setActionSource('app');\n\n const request = new EventRequest(accessToken, pixelId);\n request.setTestEventCode(testEventCode);\n request.setEvents([event]);\n return request.execute();\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"meta-conversions-api.cjs","names":["UserData","metaFbc","metaFbp","ExtendedDeviceInfo","AppData","CustomData","mapFBEvent","Content","ServerEvent","resolveActionSource","pageLocation","IGNORED_EVENTS","EventRequest"],"sources":["../../src/server/meta-conversions-api.ts"],"sourcesContent":["import {\n AppData,\n Content,\n CustomData,\n EventRequest,\n ExtendedDeviceInfo,\n ServerEvent,\n UserData,\n} from 'facebook-nodejs-business-sdk';\nimport { IGNORED_EVENTS } from '../third-parties/ignored-events';\nimport { mapFBEvent } from '../track/fbq';\nimport type { TrackEvent, TrackTags, UserProvidedData } from '../track/types';\nimport { type EventActionSource, resolveActionSource } from './action-source';\nimport { metaFbc, metaFbp } from './click-ids';\nimport { pageLocation } from './page-location';\n\nconst USER_ASSIGNED_COUNTRIES: string[] = ['xk'];\nfunction normalizeCountry(input: string | undefined): string | undefined {\n const country = input?.split(/[-_]/).at(0)?.toLowerCase();\n if (!country) return undefined;\n return USER_ASSIGNED_COUNTRIES.includes(country) ? undefined : country;\n}\n\nfunction getUserData(tags: TrackTags, data: UserProvidedData, eventTimeMs: number) {\n const userData = new UserData();\n\n // set user-provided data\n if (data.email) {\n if (Array.isArray(data.email)) {\n userData.setEmails(data.email);\n } else {\n userData.setEmail(data.email);\n }\n }\n if (data.phone_number) {\n if (Array.isArray(data.phone_number)) {\n userData.setPhones(data.phone_number);\n } else {\n userData.setPhone(data.phone_number);\n }\n }\n if (data.gender) {\n if (data.gender === 'female') {\n userData.setGender('f');\n // oxlint-disable-next-line typescript/no-unnecessary-condition -- user-provided data may carry values outside the declared union at runtime\n } else if (data.gender === 'male') {\n userData.setGender('m');\n }\n }\n if (data.address) {\n if (Array.isArray(data.address)) {\n const firstNames = data.address.map((a) => a.first_name).filter(Boolean);\n const lastNames = data.address.map((a) => a.last_name).filter(Boolean);\n const cities = data.address.map((a) => a.city).filter(Boolean);\n const states = data.address.map((a) => a.region).filter(Boolean);\n const postalCodes = data.address.map((a) => a.postal_code).filter(Boolean);\n const countries = data.address.map((a) => normalizeCountry(a.country)).filter(Boolean);\n\n userData.setFirstNames(firstNames as string[]);\n userData.setLastNames(lastNames as string[]);\n userData.setCities(cities as string[]);\n userData.setStates(states as string[]);\n userData.setZips(postalCodes as string[]);\n userData.setCountries(countries as string[]);\n } else {\n if (data.address.first_name) {\n userData.setFirstName(data.address.first_name);\n userData.setF5First(data.address.first_name.slice(0, 5));\n }\n if (data.address.last_name) {\n userData.setLastName(data.address.last_name);\n userData.setF5Last(data.address.last_name.slice(0, 5));\n }\n if (data.address.city) userData.setCity(data.address.city);\n if (data.address.region) userData.setState(data.address.region);\n if (data.address.postal_code) userData.setZip(data.address.postal_code);\n if (data.address.country) {\n const country = normalizeCountry(data.address.country);\n if (country) userData.setCountry(country);\n }\n }\n }\n if (data.birthday) {\n userData.setDoby(data.birthday.year.toString());\n userData.setDobm(data.birthday.month.toString());\n userData.setDobd(data.birthday.day.toString());\n }\n if (data.user_id && data.user_id.length !== 0) {\n userData.setExternalId(data.user_id);\n }\n if (data.ip_address) {\n userData.setClientIpAddress(data.ip_address);\n }\n if (data.user_agent) {\n userData.setClientUserAgent(data.user_agent);\n }\n if (data.fb_login_id) {\n userData.setFbLoginId(data.fb_login_id);\n }\n if (data.fb_page_id) {\n userData.setPageId(data.fb_page_id);\n }\n\n // set tags info\n const fbc = metaFbc(tags, eventTimeMs);\n if (fbc) {\n userData.setFbc(fbc);\n }\n const fbp = metaFbp(tags);\n if (fbp) {\n userData.setFbp(fbp);\n }\n if (tags.advertising_id) {\n userData.setMadid(tags.advertising_id);\n }\n if (tags.ip_address && typeof tags.ip_address === 'string') {\n userData.setClientIpAddress(tags.ip_address);\n }\n\n return userData;\n}\n\nfunction getAppData(tags: TrackTags, appPackageName: string) {\n const extinfo = new ExtendedDeviceInfo();\n if (tags.os_name) {\n if (tags.os_name === 'iOS' || tags.os_name === 'iPadOS') {\n extinfo.setExtInfoVersion('i2');\n } else if (tags.os_name === 'Android') {\n extinfo.setExtInfoVersion('a2');\n }\n }\n extinfo.setAppPackageName(appPackageName);\n const shortVersion = tags.release?.split('.').at(0);\n if (shortVersion) {\n extinfo.setShortVersion(shortVersion);\n }\n if (tags.release) {\n extinfo.setLongVersion(tags.release);\n }\n if (tags.os_version) {\n extinfo.setOsVersion(tags.os_version);\n }\n if (tags.device_model_id) {\n extinfo.setDeviceModelName(tags.device_model_id);\n }\n if (tags.language) {\n extinfo.setLocale(tags.language);\n }\n if (tags.screen_width) {\n extinfo.setScreenWidth(tags.screen_width);\n }\n if (tags.screen_height) {\n extinfo.setScreenHeight(tags.screen_height);\n }\n if (tags.device_pixel_ratio) {\n extinfo.setScreenDensity(tags.device_pixel_ratio.toString());\n }\n\n const appData = new AppData();\n appData.setExtinfo(extinfo);\n if (tags.install_referrer) {\n appData.setInstallReferrer(tags.install_referrer);\n }\n if (tags.advertising_id) {\n appData.setAdvertiserTrackingEnabled(true);\n }\n if (tags.install_referrer) {\n appData.setInstallReferrer(tags.install_referrer);\n }\n\n return appData;\n}\n\n// oxlint-disable-next-line @typescript-eslint/no-explicit-any\nfunction getCustomData({ name, properties }: TrackEvent<any>) {\n const data = new CustomData();\n const [_, _name, fbEventProperties] = mapFBEvent(name, properties);\n const {\n value,\n currency,\n content_name,\n content_category,\n content_ids,\n contents,\n content_type,\n // order_id,\n predicted_ltv,\n num_items,\n search_string,\n status,\n // item_number,\n delivery_category,\n ...custom_properties\n } = fbEventProperties;\n // oxlint-disable-next-line typescript/no-unnecessary-condition -- custom event properties may carry `value: null` at runtime despite the declared type\n if (value !== undefined && value !== null) data.setValue(value);\n if (currency) data.setCurrency(currency);\n if (content_name) data.setContentName(content_name);\n if (content_category) data.setContentCategory(content_category);\n if (content_ids) data.setContentIds(content_ids);\n if (contents)\n data.setContents(\n contents.map((c) => {\n const result = new Content().setId(c.id).setQuantity(c.quantity);\n if (c.item_price) result.setItemPrice(c.item_price);\n if (c.title) result.setTitle(c.title);\n if (c.description) result.setDescription(c.description);\n if (c.brand) result.setBrand(c.brand);\n if (c.category) result.setCategory(c.category);\n if (c.delivery_category) result.setDeliveryCategory(c.delivery_category);\n return result;\n })\n );\n if (content_type) data.setContentType(content_type);\n // if (order_id) data.setOrderId(order_id);\n if (predicted_ltv) data.setPredictedLtv(predicted_ltv);\n if (num_items) data.setNumItems(num_items);\n if (search_string) data.setSearchString(search_string);\n if (status) data.setStatus(status.toString());\n // if (item_number) data.setItemNumber(item_number);\n if (delivery_category) data.setDeliveryCategory(delivery_category);\n data.setCustomProperties(custom_properties);\n return data;\n}\n\nexport function getServerEvent(\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n event: TrackEvent<any>,\n data: UserProvidedData,\n appPackageName?: string,\n actionSource?: EventActionSource\n) {\n const eventTimeMs = new Date(event.created_at).getTime();\n const userData = getUserData(event.tags, data, eventTimeMs);\n const customData = getCustomData(event);\n const [_, eventName] = mapFBEvent(event.name, event.properties);\n const serverEvent = new ServerEvent()\n .setEventId(event.tags.idempotency_key ?? event.id)\n .setEventName(eventName)\n .setEventTime(Math.round(eventTimeMs / 1000))\n .setUserData(userData)\n .setCustomData(customData);\n\n const source = resolveActionSource(event.platform, actionSource);\n if (source === 'app' && appPackageName) {\n const appData = getAppData(event.tags, appPackageName);\n serverEvent.setAppData(appData);\n }\n const eventSourceUrl = pageLocation(event.tags);\n if (eventSourceUrl) {\n serverEvent.setEventSourceUrl(eventSourceUrl);\n }\n switch (source) {\n case 'app':\n serverEvent.setActionSource('app');\n break;\n case 'web':\n serverEvent.setActionSource('website');\n break;\n default:\n // `action_source` is required on a server event, so both an offline conversion and an\n // undeterminable platform still have to send something. Meta has no generic offline\n // value: 'physical_store' and 'system_generated' are narrower claims only the caller can\n // make, so everything left lands in Meta's own catch-all.\n serverEvent.setActionSource('other');\n break;\n }\n return serverEvent;\n}\n\n/**\n * The shape `FacebookRequestError` exposes: `status` and `response` are the HTTP status and the\n * parsed error body, and both are null when the request never received a response.\n */\ntype MetaRequestError = { status?: number | null; message?: string; response?: unknown };\n\n/**\n * Meta's SDK rejects on API errors, where every other conversion sender here logs and resolves.\n * Keep the failure inside this module, so one rejected batch cannot fail the caller's request.\n * Never log the error itself: it also carries the access token, in both `url` and `data`.\n */\nfunction logError(error: unknown) {\n const { status, message, response } = (error ?? {}) as MetaRequestError;\n if (typeof status !== 'number') {\n console.error(`Failed to send Meta conversion, network error: ${message ?? String(error)}`);\n return;\n }\n console.error(\n `Failed to send Meta conversion, status: ${status}, body: ${JSON.stringify(response)}`\n );\n}\n\nexport async function sendEvent(\n accessToken: string,\n pixelId: string,\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n event: TrackEvent<any>,\n data: UserProvidedData = {},\n appPackageName?: string,\n actionSource?: EventActionSource\n) {\n if (IGNORED_EVENTS.includes(event.name)) return undefined;\n const request = new EventRequest(accessToken, pixelId);\n const fbEvent = getServerEvent(event, data, appPackageName, actionSource);\n request.setEvents([fbEvent]);\n try {\n return await request.execute();\n } catch (error) {\n logError(error);\n return undefined;\n }\n}\n\n/** An `event_time` over 7 days old fails the whole request; see `meta-capi.ts`. */\nconst MAX_EVENT_AGE_MS = 7 * 24 * 60 * 60 * 1000 - 60 * 1000;\n\nexport async function sendEvents(\n accessToken: string,\n pixelId: string,\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n events: TrackEvent<any>[],\n data: UserProvidedData = {},\n appPackageName?: string,\n actionSource?: EventActionSource\n) {\n const fbEvents = events\n .filter((event) => !IGNORED_EVENTS.includes(event.name))\n .filter((event) => Date.now() - Date.parse(event.created_at) <= MAX_EVENT_AGE_MS)\n .map((event) => getServerEvent(event, data, appPackageName, actionSource));\n if (fbEvents.length === 0) return undefined;\n const request = new EventRequest(accessToken, pixelId);\n request.setEvents(fbEvents);\n try {\n return await request.execute();\n } catch (error) {\n logError(error);\n return undefined;\n }\n}\n\nexport async function sendTestEvent(accessToken: string, pixelId: string, testEventCode: string) {\n const extinfo = new ExtendedDeviceInfo()\n .setExtInfoVersion('a2')\n .setAppPackageName('com.some.app')\n .setShortVersion('771')\n .setLongVersion('Version 7.7.1')\n .setOsVersion('10.1.1')\n .setDeviceModelName('OnePlus6')\n .setLocale('en_US')\n .setTimezoneAbbreviation('GMT-1')\n .setCarrier('TMobile')\n .setScreenWidth(1920)\n .setScreenHeight(1080)\n .setScreenDensity('2.00')\n .setCpuCoreCount(2)\n .setTotalDiskSpaceGb(128)\n .setFreeDiskSpaceGb(8)\n .setDeviceTimeZone('USA/New York');\n\n const userData = new UserData().setEmail('test@example.com');\n const appData = new AppData().setExtinfo(extinfo);\n const event = new ServerEvent()\n .setEventId(Math.round(Math.random() * 1000_000).toString())\n .setEventName('TestEvent')\n .setEventTime(Math.round(Date.now() / 1000))\n .setUserData(userData)\n .setAppData(appData)\n .setActionSource('app');\n\n const request = new EventRequest(accessToken, pixelId);\n request.setTestEventCode(testEventCode);\n request.setEvents([event]);\n return request.execute();\n}\n"],"mappings":";;;;;;;;AAgBA,MAAM,0BAAoC,CAAC,IAAI;AAC/C,SAAS,iBAAiB,OAA+C;CACvE,MAAM,UAAU,OAAO,MAAM,MAAM,CAAC,CAAC,GAAG,CAAC,CAAC,EAAE,YAAY;CACxD,IAAI,CAAC,SAAS,OAAO,KAAA;CACrB,OAAO,wBAAwB,SAAS,OAAO,IAAI,KAAA,IAAY;AACjE;AAEA,SAAS,YAAY,MAAiB,MAAwB,aAAqB;CACjF,MAAM,WAAW,IAAIA,6BAAAA,SAAS;CAG9B,IAAI,KAAK,OAAO;EACd,IAAI,MAAM,QAAQ,KAAK,KAAK,GAC1B,SAAS,UAAU,KAAK,KAAK;OAE7B,SAAS,SAAS,KAAK,KAAK;CAEhC;CACA,IAAI,KAAK,cAAc;EACrB,IAAI,MAAM,QAAQ,KAAK,YAAY,GACjC,SAAS,UAAU,KAAK,YAAY;OAEpC,SAAS,SAAS,KAAK,YAAY;CAEvC;CACA,IAAI,KAAK,QAAQ;EACf,IAAI,KAAK,WAAW,UAClB,SAAS,UAAU,GAAG;OAEjB,IAAI,KAAK,WAAW,QACzB,SAAS,UAAU,GAAG;CAE1B;CACA,IAAI,KAAK,SAAS;EAChB,IAAI,MAAM,QAAQ,KAAK,OAAO,GAAG;GAC/B,MAAM,aAAa,KAAK,QAAQ,KAAK,MAAM,EAAE,UAAU,CAAC,CAAC,OAAO,OAAO;GACvE,MAAM,YAAY,KAAK,QAAQ,KAAK,MAAM,EAAE,SAAS,CAAC,CAAC,OAAO,OAAO;GACrE,MAAM,SAAS,KAAK,QAAQ,KAAK,MAAM,EAAE,IAAI,CAAC,CAAC,OAAO,OAAO;GAC7D,MAAM,SAAS,KAAK,QAAQ,KAAK,MAAM,EAAE,MAAM,CAAC,CAAC,OAAO,OAAO;GAC/D,MAAM,cAAc,KAAK,QAAQ,KAAK,MAAM,EAAE,WAAW,CAAC,CAAC,OAAO,OAAO;GACzE,MAAM,YAAY,KAAK,QAAQ,KAAK,MAAM,iBAAiB,EAAE,OAAO,CAAC,CAAC,CAAC,OAAO,OAAO;GAErF,SAAS,cAAc,UAAsB;GAC7C,SAAS,aAAa,SAAqB;GAC3C,SAAS,UAAU,MAAkB;GACrC,SAAS,UAAU,MAAkB;GACrC,SAAS,QAAQ,WAAuB;GACxC,SAAS,aAAa,SAAqB;EAC7C,OAAO;GACL,IAAI,KAAK,QAAQ,YAAY;IAC3B,SAAS,aAAa,KAAK,QAAQ,UAAU;IAC7C,SAAS,WAAW,KAAK,QAAQ,WAAW,MAAM,GAAG,CAAC,CAAC;GACzD;GACA,IAAI,KAAK,QAAQ,WAAW;IAC1B,SAAS,YAAY,KAAK,QAAQ,SAAS;IAC3C,SAAS,UAAU,KAAK,QAAQ,UAAU,MAAM,GAAG,CAAC,CAAC;GACvD;GACA,IAAI,KAAK,QAAQ,MAAM,SAAS,QAAQ,KAAK,QAAQ,IAAI;GACzD,IAAI,KAAK,QAAQ,QAAQ,SAAS,SAAS,KAAK,QAAQ,MAAM;GAC9D,IAAI,KAAK,QAAQ,aAAa,SAAS,OAAO,KAAK,QAAQ,WAAW;GACtE,IAAI,KAAK,QAAQ,SAAS;IACxB,MAAM,UAAU,iBAAiB,KAAK,QAAQ,OAAO;IACrD,IAAI,SAAS,SAAS,WAAW,OAAO;GAC1C;EACF;CACF;CACA,IAAI,KAAK,UAAU;EACjB,SAAS,QAAQ,KAAK,SAAS,KAAK,SAAS,CAAC;EAC9C,SAAS,QAAQ,KAAK,SAAS,MAAM,SAAS,CAAC;EAC/C,SAAS,QAAQ,KAAK,SAAS,IAAI,SAAS,CAAC;CAC/C;CACA,IAAI,KAAK,WAAW,KAAK,QAAQ,WAAW,GAC1C,SAAS,cAAc,KAAK,OAAO;CAErC,IAAI,KAAK,YACP,SAAS,mBAAmB,KAAK,UAAU;CAE7C,IAAI,KAAK,YACP,SAAS,mBAAmB,KAAK,UAAU;CAE7C,IAAI,KAAK,aACP,SAAS,aAAa,KAAK,WAAW;CAExC,IAAI,KAAK,YACP,SAAS,UAAU,KAAK,UAAU;CAIpC,MAAM,MAAMC,yBAAAA,QAAQ,MAAM,WAAW;CACrC,IAAI,KACF,SAAS,OAAO,GAAG;CAErB,MAAM,MAAMC,yBAAAA,QAAQ,IAAI;CACxB,IAAI,KACF,SAAS,OAAO,GAAG;CAErB,IAAI,KAAK,gBACP,SAAS,SAAS,KAAK,cAAc;CAEvC,IAAI,KAAK,cAAc,OAAO,KAAK,eAAe,UAChD,SAAS,mBAAmB,KAAK,UAAU;CAG7C,OAAO;AACT;AAEA,SAAS,WAAW,MAAiB,gBAAwB;CAC3D,MAAM,UAAU,IAAIC,6BAAAA,mBAAmB;CACvC,IAAI,KAAK,SAAS;EAChB,IAAI,KAAK,YAAY,SAAS,KAAK,YAAY,UAC7C,QAAQ,kBAAkB,IAAI;OACzB,IAAI,KAAK,YAAY,WAC1B,QAAQ,kBAAkB,IAAI;CAElC;CACA,QAAQ,kBAAkB,cAAc;CACxC,MAAM,eAAe,KAAK,SAAS,MAAM,GAAG,CAAC,CAAC,GAAG,CAAC;CAClD,IAAI,cACF,QAAQ,gBAAgB,YAAY;CAEtC,IAAI,KAAK,SACP,QAAQ,eAAe,KAAK,OAAO;CAErC,IAAI,KAAK,YACP,QAAQ,aAAa,KAAK,UAAU;CAEtC,IAAI,KAAK,iBACP,QAAQ,mBAAmB,KAAK,eAAe;CAEjD,IAAI,KAAK,UACP,QAAQ,UAAU,KAAK,QAAQ;CAEjC,IAAI,KAAK,cACP,QAAQ,eAAe,KAAK,YAAY;CAE1C,IAAI,KAAK,eACP,QAAQ,gBAAgB,KAAK,aAAa;CAE5C,IAAI,KAAK,oBACP,QAAQ,iBAAiB,KAAK,mBAAmB,SAAS,CAAC;CAG7D,MAAM,UAAU,IAAIC,6BAAAA,QAAQ;CAC5B,QAAQ,WAAW,OAAO;CAC1B,IAAI,KAAK,kBACP,QAAQ,mBAAmB,KAAK,gBAAgB;CAElD,IAAI,KAAK,gBACP,QAAQ,6BAA6B,IAAI;CAE3C,IAAI,KAAK,kBACP,QAAQ,mBAAmB,KAAK,gBAAgB;CAGlD,OAAO;AACT;AAGA,SAAS,cAAc,EAAE,MAAM,cAA+B;CAC5D,MAAM,OAAO,IAAIC,6BAAAA,WAAW;CAC5B,MAAM,CAAC,GAAG,OAAO,qBAAqBC,kBAAAA,WAAW,MAAM,UAAU;CACjE,MAAM,EACJ,OACA,UACA,cACA,kBACA,aACA,UACA,cAEA,eACA,WACA,eACA,QAEA,mBACA,GAAG,sBACD;CAEJ,IAAI,UAAU,KAAA,KAAa,UAAU,MAAM,KAAK,SAAS,KAAK;CAC9D,IAAI,UAAU,KAAK,YAAY,QAAQ;CACvC,IAAI,cAAc,KAAK,eAAe,YAAY;CAClD,IAAI,kBAAkB,KAAK,mBAAmB,gBAAgB;CAC9D,IAAI,aAAa,KAAK,cAAc,WAAW;CAC/C,IAAI,UACF,KAAK,YACH,SAAS,KAAK,MAAM;EAClB,MAAM,SAAS,IAAIC,6BAAAA,QAAQ,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,YAAY,EAAE,QAAQ;EAC/D,IAAI,EAAE,YAAY,OAAO,aAAa,EAAE,UAAU;EAClD,IAAI,EAAE,OAAO,OAAO,SAAS,EAAE,KAAK;EACpC,IAAI,EAAE,aAAa,OAAO,eAAe,EAAE,WAAW;EACtD,IAAI,EAAE,OAAO,OAAO,SAAS,EAAE,KAAK;EACpC,IAAI,EAAE,UAAU,OAAO,YAAY,EAAE,QAAQ;EAC7C,IAAI,EAAE,mBAAmB,OAAO,oBAAoB,EAAE,iBAAiB;EACvE,OAAO;CACT,CAAC,CACH;CACF,IAAI,cAAc,KAAK,eAAe,YAAY;CAElD,IAAI,eAAe,KAAK,gBAAgB,aAAa;CACrD,IAAI,WAAW,KAAK,YAAY,SAAS;CACzC,IAAI,eAAe,KAAK,gBAAgB,aAAa;CACrD,IAAI,QAAQ,KAAK,UAAU,OAAO,SAAS,CAAC;CAE5C,IAAI,mBAAmB,KAAK,oBAAoB,iBAAiB;CACjE,KAAK,oBAAoB,iBAAiB;CAC1C,OAAO;AACT;AAEA,SAAgB,eAEd,OACA,MACA,gBACA,cACA;CACA,MAAM,cAAc,IAAI,KAAK,MAAM,UAAU,CAAC,CAAC,QAAQ;CACvD,MAAM,WAAW,YAAY,MAAM,MAAM,MAAM,WAAW;CAC1D,MAAM,aAAa,cAAc,KAAK;CACtC,MAAM,CAAC,GAAG,aAAaD,kBAAAA,WAAW,MAAM,MAAM,MAAM,UAAU;CAC9D,MAAM,cAAc,IAAIE,6BAAAA,YAAY,CAAC,CAClC,WAAW,MAAM,KAAK,mBAAmB,MAAM,EAAE,CAAC,CAClD,aAAa,SAAS,CAAC,CACvB,aAAa,KAAK,MAAM,cAAc,GAAI,CAAC,CAAC,CAC5C,YAAY,QAAQ,CAAC,CACrB,cAAc,UAAU;CAE3B,MAAM,SAASC,6BAAAA,oBAAoB,MAAM,UAAU,YAAY;CAC/D,IAAI,WAAW,SAAS,gBAAgB;EACtC,MAAM,UAAU,WAAW,MAAM,MAAM,cAAc;EACrD,YAAY,WAAW,OAAO;CAChC;CACA,MAAM,iBAAiBC,6BAAAA,aAAa,MAAM,IAAI;CAC9C,IAAI,gBACF,YAAY,kBAAkB,cAAc;CAE9C,QAAQ,QAAR;EACE,KAAK;GACH,YAAY,gBAAgB,KAAK;GACjC;EACF,KAAK;GACH,YAAY,gBAAgB,SAAS;GACrC;EACF,SAKE,YAAY,gBAAgB,OAAO;CAEvC;CACA,OAAO;AACT;;;;;;AAaA,SAAS,SAAS,OAAgB;CAChC,MAAM,EAAE,QAAQ,SAAS,aAAc,SAAS,CAAC;CACjD,IAAI,OAAO,WAAW,UAAU;EAC9B,QAAQ,MAAM,kDAAkD,WAAW,OAAO,KAAK,GAAG;EAC1F;CACF;CACA,QAAQ,MACN,2CAA2C,OAAO,UAAU,KAAK,UAAU,QAAQ,GACrF;AACF;AAEA,eAAsB,UACpB,aACA,SAEA,OACA,OAAyB,CAAC,GAC1B,gBACA,cACA;CACA,IAAIC,qCAAAA,eAAe,SAAS,MAAM,IAAI,GAAG,OAAO,KAAA;CAChD,MAAM,UAAU,IAAIC,6BAAAA,aAAa,aAAa,OAAO;CACrD,MAAM,UAAU,eAAe,OAAO,MAAM,gBAAgB,YAAY;CACxE,QAAQ,UAAU,CAAC,OAAO,CAAC;CAC3B,IAAI;EACF,OAAO,MAAM,QAAQ,QAAQ;CAC/B,SAAS,OAAO;EACd,SAAS,KAAK;EACd;CACF;AACF;;AAGA,MAAM,mBAAmB;AAEzB,eAAsB,WACpB,aACA,SAEA,QACA,OAAyB,CAAC,GAC1B,gBACA,cACA;CACA,MAAM,WAAW,OACd,QAAQ,UAAU,CAACD,qCAAAA,eAAe,SAAS,MAAM,IAAI,CAAC,CAAC,CACvD,QAAQ,UAAU,KAAK,IAAI,IAAI,KAAK,MAAM,MAAM,UAAU,KAAK,gBAAgB,CAAC,CAChF,KAAK,UAAU,eAAe,OAAO,MAAM,gBAAgB,YAAY,CAAC;CAC3E,IAAI,SAAS,WAAW,GAAG,OAAO,KAAA;CAClC,MAAM,UAAU,IAAIC,6BAAAA,aAAa,aAAa,OAAO;CACrD,QAAQ,UAAU,QAAQ;CAC1B,IAAI;EACF,OAAO,MAAM,QAAQ,QAAQ;CAC/B,SAAS,OAAO;EACd,SAAS,KAAK;EACd;CACF;AACF;AAEA,eAAsB,cAAc,aAAqB,SAAiB,eAAuB;CAC/F,MAAM,UAAU,IAAIT,6BAAAA,mBAAmB,CAAC,CACrC,kBAAkB,IAAI,CAAC,CACvB,kBAAkB,cAAc,CAAC,CACjC,gBAAgB,KAAK,CAAC,CACtB,eAAe,eAAe,CAAC,CAC/B,aAAa,QAAQ,CAAC,CACtB,mBAAmB,UAAU,CAAC,CAC9B,UAAU,OAAO,CAAC,CAClB,wBAAwB,OAAO,CAAC,CAChC,WAAW,SAAS,CAAC,CACrB,eAAe,IAAI,CAAC,CACpB,gBAAgB,IAAI,CAAC,CACrB,iBAAiB,MAAM,CAAC,CACxB,gBAAgB,CAAC,CAAC,CAClB,oBAAoB,GAAG,CAAC,CACxB,mBAAmB,CAAC,CAAC,CACrB,kBAAkB,cAAc;CAEnC,MAAM,WAAW,IAAIH,6BAAAA,SAAS,CAAC,CAAC,SAAS,kBAAkB;CAC3D,MAAM,UAAU,IAAII,6BAAAA,QAAQ,CAAC,CAAC,WAAW,OAAO;CAChD,MAAM,QAAQ,IAAII,6BAAAA,YAAY,CAAC,CAC5B,WAAW,KAAK,MAAM,KAAK,OAAO,IAAI,GAAQ,CAAC,CAAC,SAAS,CAAC,CAAC,CAC3D,aAAa,WAAW,CAAC,CACzB,aAAa,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI,CAAC,CAAC,CAC3C,YAAY,QAAQ,CAAC,CACrB,WAAW,OAAO,CAAC,CACnB,gBAAgB,KAAK;CAExB,MAAM,UAAU,IAAII,6BAAAA,aAAa,aAAa,OAAO;CACrD,QAAQ,iBAAiB,aAAa;CACtC,QAAQ,UAAU,CAAC,KAAK,CAAC;CACzB,OAAO,QAAQ,QAAQ;AACzB"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"meta-conversions-api.d.cts","names":[],"sources":["../../src/server/meta-conversions-api.ts"],"mappings":";;;;wBAiOgB,eAEd,OAAO,iBACP,MAAM,kBACN,yBACA,eAAe,oBAAiB;wBA8DZ,UACpB,qBACA,iBAEA,OAAO,iBACP,OAAM,kBACN,yBACA,eAAe,oBAAiB,+CAAA;
|
|
1
|
+
{"version":3,"file":"meta-conversions-api.d.cts","names":[],"sources":["../../src/server/meta-conversions-api.ts"],"mappings":";;;;wBAiOgB,eAEd,OAAO,iBACP,MAAM,kBACN,yBACA,eAAe,oBAAiB;wBA8DZ,UACpB,qBACA,iBAEA,OAAO,iBACP,OAAM,kBACN,yBACA,eAAe,oBAAiB,+CAAA;wBAiBZ,WACpB,qBACA,iBAEA,QAAQ,mBACR,OAAM,kBACN,yBACA,eAAe,oBAAiB,+CAAA;wBAiBZ,cAAc,qBAAqB,iBAAiB,wBAAqB,+CAAA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"meta-conversions-api.d.mts","names":[],"sources":["../../src/server/meta-conversions-api.ts"],"mappings":";;;;wBAiOgB,eAEd,OAAO,iBACP,MAAM,kBACN,yBACA,eAAe,oBAAiB;wBA8DZ,UACpB,qBACA,iBAEA,OAAO,iBACP,OAAM,kBACN,yBACA,eAAe,oBAAiB,+CAAA;
|
|
1
|
+
{"version":3,"file":"meta-conversions-api.d.mts","names":[],"sources":["../../src/server/meta-conversions-api.ts"],"mappings":";;;;wBAiOgB,eAEd,OAAO,iBACP,MAAM,kBACN,yBACA,eAAe,oBAAiB;wBA8DZ,UACpB,qBACA,iBAEA,OAAO,iBACP,OAAM,kBACN,yBACA,eAAe,oBAAiB,+CAAA;wBAiBZ,WACpB,qBACA,iBAEA,QAAQ,mBACR,OAAM,kBACN,yBACA,eAAe,oBAAiB,+CAAA;wBAiBZ,cAAc,qBAAqB,iBAAiB,wBAAqB,+CAAA"}
|
|
@@ -175,8 +175,10 @@ async function sendEvent(accessToken, pixelId, event, data = {}, appPackageName,
|
|
|
175
175
|
return;
|
|
176
176
|
}
|
|
177
177
|
}
|
|
178
|
+
/** An `event_time` over 7 days old fails the whole request; see `meta-capi.ts`. */
|
|
179
|
+
const MAX_EVENT_AGE_MS = 60474e4;
|
|
178
180
|
async function sendEvents(accessToken, pixelId, events, data = {}, appPackageName, actionSource) {
|
|
179
|
-
const fbEvents = events.filter((event) => !IGNORED_EVENTS.includes(event.name)).map((event) => getServerEvent(event, data, appPackageName, actionSource));
|
|
181
|
+
const fbEvents = events.filter((event) => !IGNORED_EVENTS.includes(event.name)).filter((event) => Date.now() - Date.parse(event.created_at) <= MAX_EVENT_AGE_MS).map((event) => getServerEvent(event, data, appPackageName, actionSource));
|
|
180
182
|
if (fbEvents.length === 0) return void 0;
|
|
181
183
|
const request = new EventRequest(accessToken, pixelId);
|
|
182
184
|
request.setEvents(fbEvents);
|