@blotoutio/providers-app-lovin-sdk 1.71.1 → 1.73.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.
Files changed (4) hide show
  1. package/index.cjs.js +224 -35
  2. package/index.js +224 -35
  3. package/index.mjs +224 -35
  4. package/package.json +1 -1
package/index.cjs.js CHANGED
@@ -1,5 +1,35 @@
1
1
  'use strict';
2
2
 
3
+ /**
4
+ * AppLovin's `user_data.zip` is documented as "The user's billing zip code. Use
5
+ * only the first 5 digits for US zip codes", so a ZIP+4 arriving from a billing
6
+ * address is truncated to its first five digits.
7
+ *
8
+ * Only for US values. A blind five-character slice would corrupt every other
9
+ * postal format — UK `SW1A 1AA` becomes `SW1A `, Canadian `K1A 0B1` becomes
10
+ * `K1A 0` — so anything else passes through as given.
11
+ *
12
+ * Shared by the cdn and sdk paths so both send the same value.
13
+ */
14
+ const getNormalizedZip = (zip, country) => {
15
+ var _a;
16
+ const trimmed = zip === null || zip === void 0 ? void 0 : zip.trim();
17
+ if (!trimmed) {
18
+ return undefined;
19
+ }
20
+ // ISO 3166 per the same spec; `USA` is accepted because billing addresses
21
+ // supply both spellings.
22
+ const isUS = ['US', 'USA'].includes((_a = country === null || country === void 0 ? void 0 : country.trim().toUpperCase()) !== null && _a !== void 0 ? _a : '');
23
+ // The country is optional on both call paths, and the ZIP+4 shape belongs to
24
+ // no other postal format, so a value in that shape is truncated even when no
25
+ // country accompanies it.
26
+ const isZipPlusFour = /^\d{5}-?\d{4}$/.test(trimmed);
27
+ if (!isUS && !isZipPlusFour) {
28
+ return trimmed;
29
+ }
30
+ return trimmed.replace(/\D/g, '').slice(0, 5) || undefined;
31
+ };
32
+
3
33
  const packageName = 'appLovin';
4
34
 
5
35
  const t = [
@@ -121,6 +151,20 @@ const getSession = (key) => {
121
151
  }
122
152
  };
123
153
 
154
+ const getIsNewCustomerFlag = async (getData) => {
155
+ const { isNewCustomer } = await new Promise((resolve) => getData(['isNewCustomer'], resolve));
156
+ return isNewCustomer ? isNewCustomer === 'true' : undefined;
157
+ };
158
+ // Reads the isNewCustomer flag already persisted in the browser's session KV
159
+ // (written by the `data` API call, which runs before the Purchase event). Lets
160
+ // callers use the local value and avoid a redundant edge round-trip; returns
161
+ // undefined when the value has not been stored yet so the caller can fall back.
162
+ const getIsNewCustomerFromBrowser = (destination) => {
163
+ const kv = getData(destination, 'session')['kv'];
164
+ const isNewCustomer = kv === null || kv === void 0 ? void 0 : kv['isNewCustomer'];
165
+ return isNewCustomer ? isNewCustomer === 'true' : undefined;
166
+ };
167
+
124
168
  /**
125
169
  * Known ad-network click ID query parameters and the human-readable provider
126
170
  * label each one maps to. Used by the CDN worker to resolve `inSessionTouch`
@@ -158,6 +202,18 @@ const CLICK_IDS = [
158
202
  { param: 'duel_a', label: 'Duel' }, // Duel referral/advocacy
159
203
  ];
160
204
 
205
+ /**
206
+ * Checks whether a value is a plain JavaScript object (created via `{}` or `new Object()`).
207
+ * Returns false for arrays, class instances, null, and objects with modified prototypes.
208
+ * Note: Objects created with `Object.create(null)` will return false.
209
+ */
210
+ const isPlainObject = (value) => {
211
+ if (typeof value !== 'object' || value === null) {
212
+ return false;
213
+ }
214
+ return Object.getPrototypeOf(value) === Object.prototype;
215
+ };
216
+
161
217
  const expand = (str) => str.split(',').flatMap((entry) => {
162
218
  if (!entry.includes('-')) {
163
219
  return entry;
@@ -223,6 +279,18 @@ const normalizeEmail = (email) => {
223
279
  return trimmedEmail;
224
280
  };
225
281
 
282
+ function tryParse(json, fallbackValue) {
283
+ try {
284
+ if (json) {
285
+ return JSON.parse(json);
286
+ }
287
+ }
288
+ catch {
289
+ /* do nothing */
290
+ }
291
+ return fallbackValue;
292
+ }
293
+
226
294
  /**
227
295
  * ISO-3166 2-leter country codes and their names
228
296
  * @see https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
@@ -569,6 +637,78 @@ new Set([
569
637
  ...caProvinces.keys(),
570
638
  ]);
571
639
 
640
+ /**
641
+ * Self-describing JSON compaction codec (`packMappings` / `unpackMappings`).
642
+ *
643
+ * Worker variables have a 5KB-per-variable limit, and objects waste most of it
644
+ * repeating long field names on every row. `packMappings` rewrites every object
645
+ * key (at any depth) to a short auto-derived token and carries a single embedded
646
+ * legend, so the long name is stored once. `unpackMappings` reverses it using
647
+ * that legend alone — there is NO external key-map to maintain per provider.
648
+ *
649
+ * Works on ANY JSON value: a mapping array (the common case), a single plain
650
+ * object, nested structures, or primitives all round-trip.
651
+ *
652
+ * The wire format is a versioned envelope:
653
+ * { "~": 1, "k": { <short>: <long>, ... }, "d": <transformed value> }
654
+ *
655
+ * Guarantees (all covered by tests):
656
+ * - `unpackMappings(packMappings(x))` deep-equals `x` with empty fields removed,
657
+ * for any JSON value (objects, arrays, nesting, primitives).
658
+ * - Short keys are always unique — long names sharing a prefix are disambiguated
659
+ * automatically, so `packMappings` never loses or merges a field.
660
+ * - `unpackMappings` returns legacy plain JSON (no envelope) unchanged, so values
661
+ * written before compaction keep decoding; invalid/empty input → `undefined`.
662
+ *
663
+ * The building blocks (`isEmptyValue`, `deriveShortKey`, `encodeMappings`,
664
+ * `decodeMappings`, `isMappingEnvelope`) are exported as pure functions so each
665
+ * is independently testable.
666
+ */
667
+ const CODEC_VERSION = 1;
668
+ const VERSION_KEY = '~';
669
+ const LEGEND_KEY = 'k';
670
+ const DATA_KEY = 'd';
671
+ /**
672
+ * Pure inverse of `encodeMappings`: rewrites every object key back to its long
673
+ * name via the legend (unknown keys pass through). Arrays and primitives pass
674
+ * through structurally.
675
+ */
676
+ const decodeMappings = (data, legend) => {
677
+ var _a;
678
+ if (Array.isArray(data)) {
679
+ return data.map((item) => decodeMappings(item, legend));
680
+ }
681
+ if (isPlainObject(data)) {
682
+ const result = {};
683
+ for (const [key, val] of Object.entries(data)) {
684
+ result[(_a = legend[key]) !== null && _a !== void 0 ? _a : key] = decodeMappings(val, legend);
685
+ }
686
+ return result;
687
+ }
688
+ return data;
689
+ };
690
+ /** True when a parsed value is a `packMappings` envelope (vs. legacy plain JSON). */
691
+ const isMappingEnvelope = (value) => isPlainObject(value) &&
692
+ value[VERSION_KEY] === CODEC_VERSION &&
693
+ isPlainObject(value[LEGEND_KEY]) &&
694
+ DATA_KEY in value;
695
+ function unpackMappings(text, fallback) {
696
+ const parsed = tryParse(text);
697
+ if (parsed === undefined || parsed === null) {
698
+ return fallback;
699
+ }
700
+ if (isMappingEnvelope(parsed)) {
701
+ return decodeMappings(parsed[DATA_KEY], parsed[LEGEND_KEY]);
702
+ }
703
+ // A versioned envelope this reader can't decode (e.g. a future CODEC_VERSION
704
+ // written by a newer UI while this worker still lags). Returning the raw
705
+ // `{~,k,d}` object would be silent corruption, so fall back instead.
706
+ if (isPlainObject(parsed) && VERSION_KEY in parsed) {
707
+ return fallback;
708
+ }
709
+ return parsed;
710
+ }
711
+
572
712
  /**
573
713
  * Exact utm_source normalization (lowercase key → canonical name).
574
714
  *
@@ -691,24 +831,17 @@ new Set([
691
831
  ]);
692
832
 
693
833
  // eslint-disable-next-line @nx/enforce-module-boundaries
694
- const getParsedCategoryId = (category) => {
695
- try {
696
- const parsed = JSON.parse(category);
697
- return parsed.value;
698
- }
699
- catch (error) {
700
- logger.error(`Error parsing category: ${category}`, error);
701
- return undefined;
702
- }
703
- };
834
+ // The same helpers as the cdn copy in cdn/src/lib/utils.ts, so a malformed
835
+ // mapping fails identically on both sides. `unpackMappings` yields the fallback
836
+ // for input that parses to `null`, and the array guard covers valid JSON that
837
+ // is not an array; either would otherwise reach `.map` and throw, which in
838
+ // browser SDK code takes the whole tag down.
839
+ const getParsedCategoryId = (category) => { var _a; return (_a = tryParse(category)) === null || _a === void 0 ? void 0 : _a.value; };
704
840
  const getCategories = (categoryMapping) => {
705
- let categoryMappings = [];
706
- try {
707
- categoryMappings = JSON.parse(categoryMapping || '[]');
708
- }
709
- catch {
841
+ const categoryMappings = unpackMappings(categoryMapping, []);
842
+ if (!Array.isArray(categoryMappings)) {
710
843
  logger.error('Unable to parse category mappings');
711
- categoryMappings = [];
844
+ return new Map();
712
845
  }
713
846
  return new Map(categoryMappings.map(({ storeCategory, appLovinCategory }) => [
714
847
  storeCategory.toLowerCase(),
@@ -745,6 +878,13 @@ const getEventUserData = (userId, data) => {
745
878
  userData.phone = normalizedPhone;
746
879
  }
747
880
  }
881
+ const zip = getNormalizedZip(data.zip, data.country_code);
882
+ if (zip) {
883
+ userData.zip = zip;
884
+ }
885
+ if (data.country_code) {
886
+ userData.country_code = data.country_code;
887
+ }
748
888
  return userData;
749
889
  };
750
890
  /**
@@ -756,9 +896,10 @@ const getEventUserData = (userId, data) => {
756
896
  * @param defaultCategory - JSON string containing default category configuration with label and value
757
897
  * @param categoryMapping - JSON string containing array of category mappings
758
898
  * @param userId - The explicit user ID to use in event user_data
899
+ * @param isNewCustomer - New/returning buyer flag, applied to Purchase only
759
900
  * @returns EventData payload if the event is supported, undefined otherwise
760
901
  */
761
- const getEventData = ({ eventName, userData, data, defaultCategory, categoryMapping, userId, }) => {
902
+ const getEventData = ({ eventName, userData, data, defaultCategory, categoryMapping, userId, isNewCustomer, }) => {
762
903
  var _a, _b, _c;
763
904
  const categories = getCategories(categoryMapping);
764
905
  const defaultCategoryId = getParsedCategoryId(defaultCategory);
@@ -857,6 +998,9 @@ const getEventData = ({ eventName, userData, data, defaultCategory, categoryMapp
857
998
  shipping: (_b = data.shippingCost) !== null && _b !== void 0 ? _b : 0,
858
999
  tax: (_c = data.taxCost) !== null && _c !== void 0 ? _c : 0,
859
1000
  user_data: getEventUserData(userId, userData),
1001
+ ...(typeof isNewCustomer === 'boolean' && {
1002
+ measurement: { is_new_customer: isNewCustomer },
1003
+ }),
860
1004
  },
861
1005
  };
862
1006
  }
@@ -903,9 +1047,14 @@ const getEventData = ({ eventName, userData, data, defaultCategory, categoryMapp
903
1047
  }
904
1048
  };
905
1049
 
906
- const tag = ({ data, eventName, eventId, manifestVariables, userId, destination, }) => {
1050
+ const getString = (kv, key) => {
1051
+ const value = kv === null || kv === void 0 ? void 0 : kv[key];
1052
+ return typeof value === 'string' && value ? value : undefined;
1053
+ };
1054
+ const tag = ({ data, eventName, eventId, manifestVariables, userId, destination, getEdgeData, }) => {
1055
+ var _a, _b;
907
1056
  const payload = {
908
- sdkVersion: "1.71.1" ,
1057
+ sdkVersion: "1.73.0" ,
909
1058
  };
910
1059
  if (window.axon && manifestVariables['enableBrowser'] === '1') {
911
1060
  try {
@@ -914,28 +1063,68 @@ const tag = ({ data, eventName, eventId, manifestVariables, userId, destination,
914
1063
  // email/phone usually arrive via the data API (stored in session KV),
915
1064
  // not on the event payload itself.
916
1065
  const kv = getData(destination, 'session')['kv'];
1066
+ // Same identity set the CAPI attaches server-side, so the browser pixel
1067
+ // is not the weaker of the two paths on a shopper we already know.
917
1068
  const userData = { user_id: userId };
918
- const email = (data === null || data === void 0 ? void 0 : data['email']) || (kv === null || kv === void 0 ? void 0 : kv['email']);
919
- if (email && typeof email === 'string') {
1069
+ // Checked per source rather than after the `||`, so a non-string on the
1070
+ // payload cannot displace a valid string in KV.
1071
+ const email = (_a = getString(data, 'email')) !== null && _a !== void 0 ? _a : getString(kv, 'email');
1072
+ if (email) {
920
1073
  userData.email = email;
921
1074
  }
922
- const phone = (data === null || data === void 0 ? void 0 : data['phone']) || (kv === null || kv === void 0 ? void 0 : kv['phone']);
923
- if (phone && typeof phone === 'string') {
1075
+ const phone = (_b = getString(data, 'phone')) !== null && _b !== void 0 ? _b : getString(kv, 'phone');
1076
+ if (phone) {
924
1077
  userData.phone = phone;
925
1078
  }
926
- const event = getEventData({
927
- eventName,
928
- userData,
929
- data,
930
- defaultCategory,
931
- categoryMapping,
932
- userId,
933
- });
934
- if (!event) {
935
- logger.log(`[${packageName}] ${eventName} not supported`);
936
- return;
1079
+ const zip = getString(kv, 'zip');
1080
+ if (zip) {
1081
+ userData.zip = zip;
1082
+ }
1083
+ const country = getString(kv, 'country');
1084
+ if (country) {
1085
+ userData.country_code = country;
1086
+ }
1087
+ const send = (isNewCustomer) => {
1088
+ var _a;
1089
+ const event = getEventData({
1090
+ eventName,
1091
+ userData,
1092
+ data,
1093
+ defaultCategory,
1094
+ categoryMapping,
1095
+ userId,
1096
+ isNewCustomer,
1097
+ });
1098
+ if (!event) {
1099
+ logger.log(`[${packageName}] ${eventName} not supported`);
1100
+ return;
1101
+ }
1102
+ (_a = window.axon) === null || _a === void 0 ? void 0 : _a.call(window, 'track', event.name, event.data, { dedupe_id: eventId });
1103
+ };
1104
+ if (eventName !== 'Purchase') {
1105
+ send();
1106
+ return payload;
1107
+ }
1108
+ // Prefer the flag already saved in the browser (the Shopify pixel's
1109
+ // `data` call writes it just before Purchase). Only query the edge when
1110
+ // the browser value is missing.
1111
+ const browserValue = destination
1112
+ ? getIsNewCustomerFromBrowser(destination)
1113
+ : undefined;
1114
+ if (typeof browserValue === 'boolean') {
1115
+ send(browserValue);
1116
+ }
1117
+ else if (getEdgeData) {
1118
+ getIsNewCustomerFlag(getEdgeData)
1119
+ // The fallback is the rejection handler rather than a trailing
1120
+ // `catch`, so only a failed lookup sends without the flag; a `send`
1121
+ // that throws must not be retried into a duplicate pixel call.
1122
+ .then(send, () => send())
1123
+ .catch((error) => logger.error(error));
1124
+ }
1125
+ else {
1126
+ send();
937
1127
  }
938
- window.axon('track', event.name, event.data, { dedupe_id: eventId });
939
1128
  }
940
1129
  catch (e) {
941
1130
  logger.error(e);
package/index.js CHANGED
@@ -1,6 +1,36 @@
1
1
  var ProvidersAppLovinSdk = (function () {
2
2
  'use strict';
3
3
 
4
+ /**
5
+ * AppLovin's `user_data.zip` is documented as "The user's billing zip code. Use
6
+ * only the first 5 digits for US zip codes", so a ZIP+4 arriving from a billing
7
+ * address is truncated to its first five digits.
8
+ *
9
+ * Only for US values. A blind five-character slice would corrupt every other
10
+ * postal format — UK `SW1A 1AA` becomes `SW1A `, Canadian `K1A 0B1` becomes
11
+ * `K1A 0` — so anything else passes through as given.
12
+ *
13
+ * Shared by the cdn and sdk paths so both send the same value.
14
+ */
15
+ const getNormalizedZip = (zip, country) => {
16
+ var _a;
17
+ const trimmed = zip === null || zip === void 0 ? void 0 : zip.trim();
18
+ if (!trimmed) {
19
+ return undefined;
20
+ }
21
+ // ISO 3166 per the same spec; `USA` is accepted because billing addresses
22
+ // supply both spellings.
23
+ const isUS = ['US', 'USA'].includes((_a = country === null || country === void 0 ? void 0 : country.trim().toUpperCase()) !== null && _a !== void 0 ? _a : '');
24
+ // The country is optional on both call paths, and the ZIP+4 shape belongs to
25
+ // no other postal format, so a value in that shape is truncated even when no
26
+ // country accompanies it.
27
+ const isZipPlusFour = /^\d{5}-?\d{4}$/.test(trimmed);
28
+ if (!isUS && !isZipPlusFour) {
29
+ return trimmed;
30
+ }
31
+ return trimmed.replace(/\D/g, '').slice(0, 5) || undefined;
32
+ };
33
+
4
34
  const packageName = 'appLovin';
5
35
 
6
36
  const t = [
@@ -122,6 +152,20 @@ var ProvidersAppLovinSdk = (function () {
122
152
  }
123
153
  };
124
154
 
155
+ const getIsNewCustomerFlag = async (getData) => {
156
+ const { isNewCustomer } = await new Promise((resolve) => getData(['isNewCustomer'], resolve));
157
+ return isNewCustomer ? isNewCustomer === 'true' : undefined;
158
+ };
159
+ // Reads the isNewCustomer flag already persisted in the browser's session KV
160
+ // (written by the `data` API call, which runs before the Purchase event). Lets
161
+ // callers use the local value and avoid a redundant edge round-trip; returns
162
+ // undefined when the value has not been stored yet so the caller can fall back.
163
+ const getIsNewCustomerFromBrowser = (destination) => {
164
+ const kv = getData(destination, 'session')['kv'];
165
+ const isNewCustomer = kv === null || kv === void 0 ? void 0 : kv['isNewCustomer'];
166
+ return isNewCustomer ? isNewCustomer === 'true' : undefined;
167
+ };
168
+
125
169
  /**
126
170
  * Known ad-network click ID query parameters and the human-readable provider
127
171
  * label each one maps to. Used by the CDN worker to resolve `inSessionTouch`
@@ -159,6 +203,18 @@ var ProvidersAppLovinSdk = (function () {
159
203
  { param: 'duel_a', label: 'Duel' }, // Duel referral/advocacy
160
204
  ];
161
205
 
206
+ /**
207
+ * Checks whether a value is a plain JavaScript object (created via `{}` or `new Object()`).
208
+ * Returns false for arrays, class instances, null, and objects with modified prototypes.
209
+ * Note: Objects created with `Object.create(null)` will return false.
210
+ */
211
+ const isPlainObject = (value) => {
212
+ if (typeof value !== 'object' || value === null) {
213
+ return false;
214
+ }
215
+ return Object.getPrototypeOf(value) === Object.prototype;
216
+ };
217
+
162
218
  const expand = (str) => str.split(',').flatMap((entry) => {
163
219
  if (!entry.includes('-')) {
164
220
  return entry;
@@ -224,6 +280,18 @@ var ProvidersAppLovinSdk = (function () {
224
280
  return trimmedEmail;
225
281
  };
226
282
 
283
+ function tryParse(json, fallbackValue) {
284
+ try {
285
+ if (json) {
286
+ return JSON.parse(json);
287
+ }
288
+ }
289
+ catch {
290
+ /* do nothing */
291
+ }
292
+ return fallbackValue;
293
+ }
294
+
227
295
  /**
228
296
  * ISO-3166 2-leter country codes and their names
229
297
  * @see https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
@@ -570,6 +638,78 @@ var ProvidersAppLovinSdk = (function () {
570
638
  ...caProvinces.keys(),
571
639
  ]);
572
640
 
641
+ /**
642
+ * Self-describing JSON compaction codec (`packMappings` / `unpackMappings`).
643
+ *
644
+ * Worker variables have a 5KB-per-variable limit, and objects waste most of it
645
+ * repeating long field names on every row. `packMappings` rewrites every object
646
+ * key (at any depth) to a short auto-derived token and carries a single embedded
647
+ * legend, so the long name is stored once. `unpackMappings` reverses it using
648
+ * that legend alone — there is NO external key-map to maintain per provider.
649
+ *
650
+ * Works on ANY JSON value: a mapping array (the common case), a single plain
651
+ * object, nested structures, or primitives all round-trip.
652
+ *
653
+ * The wire format is a versioned envelope:
654
+ * { "~": 1, "k": { <short>: <long>, ... }, "d": <transformed value> }
655
+ *
656
+ * Guarantees (all covered by tests):
657
+ * - `unpackMappings(packMappings(x))` deep-equals `x` with empty fields removed,
658
+ * for any JSON value (objects, arrays, nesting, primitives).
659
+ * - Short keys are always unique — long names sharing a prefix are disambiguated
660
+ * automatically, so `packMappings` never loses or merges a field.
661
+ * - `unpackMappings` returns legacy plain JSON (no envelope) unchanged, so values
662
+ * written before compaction keep decoding; invalid/empty input → `undefined`.
663
+ *
664
+ * The building blocks (`isEmptyValue`, `deriveShortKey`, `encodeMappings`,
665
+ * `decodeMappings`, `isMappingEnvelope`) are exported as pure functions so each
666
+ * is independently testable.
667
+ */
668
+ const CODEC_VERSION = 1;
669
+ const VERSION_KEY = '~';
670
+ const LEGEND_KEY = 'k';
671
+ const DATA_KEY = 'd';
672
+ /**
673
+ * Pure inverse of `encodeMappings`: rewrites every object key back to its long
674
+ * name via the legend (unknown keys pass through). Arrays and primitives pass
675
+ * through structurally.
676
+ */
677
+ const decodeMappings = (data, legend) => {
678
+ var _a;
679
+ if (Array.isArray(data)) {
680
+ return data.map((item) => decodeMappings(item, legend));
681
+ }
682
+ if (isPlainObject(data)) {
683
+ const result = {};
684
+ for (const [key, val] of Object.entries(data)) {
685
+ result[(_a = legend[key]) !== null && _a !== void 0 ? _a : key] = decodeMappings(val, legend);
686
+ }
687
+ return result;
688
+ }
689
+ return data;
690
+ };
691
+ /** True when a parsed value is a `packMappings` envelope (vs. legacy plain JSON). */
692
+ const isMappingEnvelope = (value) => isPlainObject(value) &&
693
+ value[VERSION_KEY] === CODEC_VERSION &&
694
+ isPlainObject(value[LEGEND_KEY]) &&
695
+ DATA_KEY in value;
696
+ function unpackMappings(text, fallback) {
697
+ const parsed = tryParse(text);
698
+ if (parsed === undefined || parsed === null) {
699
+ return fallback;
700
+ }
701
+ if (isMappingEnvelope(parsed)) {
702
+ return decodeMappings(parsed[DATA_KEY], parsed[LEGEND_KEY]);
703
+ }
704
+ // A versioned envelope this reader can't decode (e.g. a future CODEC_VERSION
705
+ // written by a newer UI while this worker still lags). Returning the raw
706
+ // `{~,k,d}` object would be silent corruption, so fall back instead.
707
+ if (isPlainObject(parsed) && VERSION_KEY in parsed) {
708
+ return fallback;
709
+ }
710
+ return parsed;
711
+ }
712
+
573
713
  /**
574
714
  * Exact utm_source normalization (lowercase key → canonical name).
575
715
  *
@@ -692,24 +832,17 @@ var ProvidersAppLovinSdk = (function () {
692
832
  ]);
693
833
 
694
834
  // eslint-disable-next-line @nx/enforce-module-boundaries
695
- const getParsedCategoryId = (category) => {
696
- try {
697
- const parsed = JSON.parse(category);
698
- return parsed.value;
699
- }
700
- catch (error) {
701
- logger.error(`Error parsing category: ${category}`, error);
702
- return undefined;
703
- }
704
- };
835
+ // The same helpers as the cdn copy in cdn/src/lib/utils.ts, so a malformed
836
+ // mapping fails identically on both sides. `unpackMappings` yields the fallback
837
+ // for input that parses to `null`, and the array guard covers valid JSON that
838
+ // is not an array; either would otherwise reach `.map` and throw, which in
839
+ // browser SDK code takes the whole tag down.
840
+ const getParsedCategoryId = (category) => { var _a; return (_a = tryParse(category)) === null || _a === void 0 ? void 0 : _a.value; };
705
841
  const getCategories = (categoryMapping) => {
706
- let categoryMappings = [];
707
- try {
708
- categoryMappings = JSON.parse(categoryMapping || '[]');
709
- }
710
- catch {
842
+ const categoryMappings = unpackMappings(categoryMapping, []);
843
+ if (!Array.isArray(categoryMappings)) {
711
844
  logger.error('Unable to parse category mappings');
712
- categoryMappings = [];
845
+ return new Map();
713
846
  }
714
847
  return new Map(categoryMappings.map(({ storeCategory, appLovinCategory }) => [
715
848
  storeCategory.toLowerCase(),
@@ -746,6 +879,13 @@ var ProvidersAppLovinSdk = (function () {
746
879
  userData.phone = normalizedPhone;
747
880
  }
748
881
  }
882
+ const zip = getNormalizedZip(data.zip, data.country_code);
883
+ if (zip) {
884
+ userData.zip = zip;
885
+ }
886
+ if (data.country_code) {
887
+ userData.country_code = data.country_code;
888
+ }
749
889
  return userData;
750
890
  };
751
891
  /**
@@ -757,9 +897,10 @@ var ProvidersAppLovinSdk = (function () {
757
897
  * @param defaultCategory - JSON string containing default category configuration with label and value
758
898
  * @param categoryMapping - JSON string containing array of category mappings
759
899
  * @param userId - The explicit user ID to use in event user_data
900
+ * @param isNewCustomer - New/returning buyer flag, applied to Purchase only
760
901
  * @returns EventData payload if the event is supported, undefined otherwise
761
902
  */
762
- const getEventData = ({ eventName, userData, data, defaultCategory, categoryMapping, userId, }) => {
903
+ const getEventData = ({ eventName, userData, data, defaultCategory, categoryMapping, userId, isNewCustomer, }) => {
763
904
  var _a, _b, _c;
764
905
  const categories = getCategories(categoryMapping);
765
906
  const defaultCategoryId = getParsedCategoryId(defaultCategory);
@@ -858,6 +999,9 @@ var ProvidersAppLovinSdk = (function () {
858
999
  shipping: (_b = data.shippingCost) !== null && _b !== void 0 ? _b : 0,
859
1000
  tax: (_c = data.taxCost) !== null && _c !== void 0 ? _c : 0,
860
1001
  user_data: getEventUserData(userId, userData),
1002
+ ...(typeof isNewCustomer === 'boolean' && {
1003
+ measurement: { is_new_customer: isNewCustomer },
1004
+ }),
861
1005
  },
862
1006
  };
863
1007
  }
@@ -904,9 +1048,14 @@ var ProvidersAppLovinSdk = (function () {
904
1048
  }
905
1049
  };
906
1050
 
907
- const tag = ({ data, eventName, eventId, manifestVariables, userId, destination, }) => {
1051
+ const getString = (kv, key) => {
1052
+ const value = kv === null || kv === void 0 ? void 0 : kv[key];
1053
+ return typeof value === 'string' && value ? value : undefined;
1054
+ };
1055
+ const tag = ({ data, eventName, eventId, manifestVariables, userId, destination, getEdgeData, }) => {
1056
+ var _a, _b;
908
1057
  const payload = {
909
- sdkVersion: "1.71.1" ,
1058
+ sdkVersion: "1.73.0" ,
910
1059
  };
911
1060
  if (window.axon && manifestVariables['enableBrowser'] === '1') {
912
1061
  try {
@@ -915,28 +1064,68 @@ var ProvidersAppLovinSdk = (function () {
915
1064
  // email/phone usually arrive via the data API (stored in session KV),
916
1065
  // not on the event payload itself.
917
1066
  const kv = getData(destination, 'session')['kv'];
1067
+ // Same identity set the CAPI attaches server-side, so the browser pixel
1068
+ // is not the weaker of the two paths on a shopper we already know.
918
1069
  const userData = { user_id: userId };
919
- const email = (data === null || data === void 0 ? void 0 : data['email']) || (kv === null || kv === void 0 ? void 0 : kv['email']);
920
- if (email && typeof email === 'string') {
1070
+ // Checked per source rather than after the `||`, so a non-string on the
1071
+ // payload cannot displace a valid string in KV.
1072
+ const email = (_a = getString(data, 'email')) !== null && _a !== void 0 ? _a : getString(kv, 'email');
1073
+ if (email) {
921
1074
  userData.email = email;
922
1075
  }
923
- const phone = (data === null || data === void 0 ? void 0 : data['phone']) || (kv === null || kv === void 0 ? void 0 : kv['phone']);
924
- if (phone && typeof phone === 'string') {
1076
+ const phone = (_b = getString(data, 'phone')) !== null && _b !== void 0 ? _b : getString(kv, 'phone');
1077
+ if (phone) {
925
1078
  userData.phone = phone;
926
1079
  }
927
- const event = getEventData({
928
- eventName,
929
- userData,
930
- data,
931
- defaultCategory,
932
- categoryMapping,
933
- userId,
934
- });
935
- if (!event) {
936
- logger.log(`[${packageName}] ${eventName} not supported`);
937
- return;
1080
+ const zip = getString(kv, 'zip');
1081
+ if (zip) {
1082
+ userData.zip = zip;
1083
+ }
1084
+ const country = getString(kv, 'country');
1085
+ if (country) {
1086
+ userData.country_code = country;
1087
+ }
1088
+ const send = (isNewCustomer) => {
1089
+ var _a;
1090
+ const event = getEventData({
1091
+ eventName,
1092
+ userData,
1093
+ data,
1094
+ defaultCategory,
1095
+ categoryMapping,
1096
+ userId,
1097
+ isNewCustomer,
1098
+ });
1099
+ if (!event) {
1100
+ logger.log(`[${packageName}] ${eventName} not supported`);
1101
+ return;
1102
+ }
1103
+ (_a = window.axon) === null || _a === void 0 ? void 0 : _a.call(window, 'track', event.name, event.data, { dedupe_id: eventId });
1104
+ };
1105
+ if (eventName !== 'Purchase') {
1106
+ send();
1107
+ return payload;
1108
+ }
1109
+ // Prefer the flag already saved in the browser (the Shopify pixel's
1110
+ // `data` call writes it just before Purchase). Only query the edge when
1111
+ // the browser value is missing.
1112
+ const browserValue = destination
1113
+ ? getIsNewCustomerFromBrowser(destination)
1114
+ : undefined;
1115
+ if (typeof browserValue === 'boolean') {
1116
+ send(browserValue);
1117
+ }
1118
+ else if (getEdgeData) {
1119
+ getIsNewCustomerFlag(getEdgeData)
1120
+ // The fallback is the rejection handler rather than a trailing
1121
+ // `catch`, so only a failed lookup sends without the flag; a `send`
1122
+ // that throws must not be retried into a duplicate pixel call.
1123
+ .then(send, () => send())
1124
+ .catch((error) => logger.error(error));
1125
+ }
1126
+ else {
1127
+ send();
938
1128
  }
939
- window.axon('track', event.name, event.data, { dedupe_id: eventId });
940
1129
  }
941
1130
  catch (e) {
942
1131
  logger.error(e);
package/index.mjs CHANGED
@@ -1,3 +1,33 @@
1
+ /**
2
+ * AppLovin's `user_data.zip` is documented as "The user's billing zip code. Use
3
+ * only the first 5 digits for US zip codes", so a ZIP+4 arriving from a billing
4
+ * address is truncated to its first five digits.
5
+ *
6
+ * Only for US values. A blind five-character slice would corrupt every other
7
+ * postal format — UK `SW1A 1AA` becomes `SW1A `, Canadian `K1A 0B1` becomes
8
+ * `K1A 0` — so anything else passes through as given.
9
+ *
10
+ * Shared by the cdn and sdk paths so both send the same value.
11
+ */
12
+ const getNormalizedZip = (zip, country) => {
13
+ var _a;
14
+ const trimmed = zip === null || zip === void 0 ? void 0 : zip.trim();
15
+ if (!trimmed) {
16
+ return undefined;
17
+ }
18
+ // ISO 3166 per the same spec; `USA` is accepted because billing addresses
19
+ // supply both spellings.
20
+ const isUS = ['US', 'USA'].includes((_a = country === null || country === void 0 ? void 0 : country.trim().toUpperCase()) !== null && _a !== void 0 ? _a : '');
21
+ // The country is optional on both call paths, and the ZIP+4 shape belongs to
22
+ // no other postal format, so a value in that shape is truncated even when no
23
+ // country accompanies it.
24
+ const isZipPlusFour = /^\d{5}-?\d{4}$/.test(trimmed);
25
+ if (!isUS && !isZipPlusFour) {
26
+ return trimmed;
27
+ }
28
+ return trimmed.replace(/\D/g, '').slice(0, 5) || undefined;
29
+ };
30
+
1
31
  const packageName = 'appLovin';
2
32
 
3
33
  const t = [
@@ -119,6 +149,20 @@ const getSession = (key) => {
119
149
  }
120
150
  };
121
151
 
152
+ const getIsNewCustomerFlag = async (getData) => {
153
+ const { isNewCustomer } = await new Promise((resolve) => getData(['isNewCustomer'], resolve));
154
+ return isNewCustomer ? isNewCustomer === 'true' : undefined;
155
+ };
156
+ // Reads the isNewCustomer flag already persisted in the browser's session KV
157
+ // (written by the `data` API call, which runs before the Purchase event). Lets
158
+ // callers use the local value and avoid a redundant edge round-trip; returns
159
+ // undefined when the value has not been stored yet so the caller can fall back.
160
+ const getIsNewCustomerFromBrowser = (destination) => {
161
+ const kv = getData(destination, 'session')['kv'];
162
+ const isNewCustomer = kv === null || kv === void 0 ? void 0 : kv['isNewCustomer'];
163
+ return isNewCustomer ? isNewCustomer === 'true' : undefined;
164
+ };
165
+
122
166
  /**
123
167
  * Known ad-network click ID query parameters and the human-readable provider
124
168
  * label each one maps to. Used by the CDN worker to resolve `inSessionTouch`
@@ -156,6 +200,18 @@ const CLICK_IDS = [
156
200
  { param: 'duel_a', label: 'Duel' }, // Duel referral/advocacy
157
201
  ];
158
202
 
203
+ /**
204
+ * Checks whether a value is a plain JavaScript object (created via `{}` or `new Object()`).
205
+ * Returns false for arrays, class instances, null, and objects with modified prototypes.
206
+ * Note: Objects created with `Object.create(null)` will return false.
207
+ */
208
+ const isPlainObject = (value) => {
209
+ if (typeof value !== 'object' || value === null) {
210
+ return false;
211
+ }
212
+ return Object.getPrototypeOf(value) === Object.prototype;
213
+ };
214
+
159
215
  const expand = (str) => str.split(',').flatMap((entry) => {
160
216
  if (!entry.includes('-')) {
161
217
  return entry;
@@ -221,6 +277,18 @@ const normalizeEmail = (email) => {
221
277
  return trimmedEmail;
222
278
  };
223
279
 
280
+ function tryParse(json, fallbackValue) {
281
+ try {
282
+ if (json) {
283
+ return JSON.parse(json);
284
+ }
285
+ }
286
+ catch {
287
+ /* do nothing */
288
+ }
289
+ return fallbackValue;
290
+ }
291
+
224
292
  /**
225
293
  * ISO-3166 2-leter country codes and their names
226
294
  * @see https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
@@ -567,6 +635,78 @@ new Set([
567
635
  ...caProvinces.keys(),
568
636
  ]);
569
637
 
638
+ /**
639
+ * Self-describing JSON compaction codec (`packMappings` / `unpackMappings`).
640
+ *
641
+ * Worker variables have a 5KB-per-variable limit, and objects waste most of it
642
+ * repeating long field names on every row. `packMappings` rewrites every object
643
+ * key (at any depth) to a short auto-derived token and carries a single embedded
644
+ * legend, so the long name is stored once. `unpackMappings` reverses it using
645
+ * that legend alone — there is NO external key-map to maintain per provider.
646
+ *
647
+ * Works on ANY JSON value: a mapping array (the common case), a single plain
648
+ * object, nested structures, or primitives all round-trip.
649
+ *
650
+ * The wire format is a versioned envelope:
651
+ * { "~": 1, "k": { <short>: <long>, ... }, "d": <transformed value> }
652
+ *
653
+ * Guarantees (all covered by tests):
654
+ * - `unpackMappings(packMappings(x))` deep-equals `x` with empty fields removed,
655
+ * for any JSON value (objects, arrays, nesting, primitives).
656
+ * - Short keys are always unique — long names sharing a prefix are disambiguated
657
+ * automatically, so `packMappings` never loses or merges a field.
658
+ * - `unpackMappings` returns legacy plain JSON (no envelope) unchanged, so values
659
+ * written before compaction keep decoding; invalid/empty input → `undefined`.
660
+ *
661
+ * The building blocks (`isEmptyValue`, `deriveShortKey`, `encodeMappings`,
662
+ * `decodeMappings`, `isMappingEnvelope`) are exported as pure functions so each
663
+ * is independently testable.
664
+ */
665
+ const CODEC_VERSION = 1;
666
+ const VERSION_KEY = '~';
667
+ const LEGEND_KEY = 'k';
668
+ const DATA_KEY = 'd';
669
+ /**
670
+ * Pure inverse of `encodeMappings`: rewrites every object key back to its long
671
+ * name via the legend (unknown keys pass through). Arrays and primitives pass
672
+ * through structurally.
673
+ */
674
+ const decodeMappings = (data, legend) => {
675
+ var _a;
676
+ if (Array.isArray(data)) {
677
+ return data.map((item) => decodeMappings(item, legend));
678
+ }
679
+ if (isPlainObject(data)) {
680
+ const result = {};
681
+ for (const [key, val] of Object.entries(data)) {
682
+ result[(_a = legend[key]) !== null && _a !== void 0 ? _a : key] = decodeMappings(val, legend);
683
+ }
684
+ return result;
685
+ }
686
+ return data;
687
+ };
688
+ /** True when a parsed value is a `packMappings` envelope (vs. legacy plain JSON). */
689
+ const isMappingEnvelope = (value) => isPlainObject(value) &&
690
+ value[VERSION_KEY] === CODEC_VERSION &&
691
+ isPlainObject(value[LEGEND_KEY]) &&
692
+ DATA_KEY in value;
693
+ function unpackMappings(text, fallback) {
694
+ const parsed = tryParse(text);
695
+ if (parsed === undefined || parsed === null) {
696
+ return fallback;
697
+ }
698
+ if (isMappingEnvelope(parsed)) {
699
+ return decodeMappings(parsed[DATA_KEY], parsed[LEGEND_KEY]);
700
+ }
701
+ // A versioned envelope this reader can't decode (e.g. a future CODEC_VERSION
702
+ // written by a newer UI while this worker still lags). Returning the raw
703
+ // `{~,k,d}` object would be silent corruption, so fall back instead.
704
+ if (isPlainObject(parsed) && VERSION_KEY in parsed) {
705
+ return fallback;
706
+ }
707
+ return parsed;
708
+ }
709
+
570
710
  /**
571
711
  * Exact utm_source normalization (lowercase key → canonical name).
572
712
  *
@@ -689,24 +829,17 @@ new Set([
689
829
  ]);
690
830
 
691
831
  // eslint-disable-next-line @nx/enforce-module-boundaries
692
- const getParsedCategoryId = (category) => {
693
- try {
694
- const parsed = JSON.parse(category);
695
- return parsed.value;
696
- }
697
- catch (error) {
698
- logger.error(`Error parsing category: ${category}`, error);
699
- return undefined;
700
- }
701
- };
832
+ // The same helpers as the cdn copy in cdn/src/lib/utils.ts, so a malformed
833
+ // mapping fails identically on both sides. `unpackMappings` yields the fallback
834
+ // for input that parses to `null`, and the array guard covers valid JSON that
835
+ // is not an array; either would otherwise reach `.map` and throw, which in
836
+ // browser SDK code takes the whole tag down.
837
+ const getParsedCategoryId = (category) => { var _a; return (_a = tryParse(category)) === null || _a === void 0 ? void 0 : _a.value; };
702
838
  const getCategories = (categoryMapping) => {
703
- let categoryMappings = [];
704
- try {
705
- categoryMappings = JSON.parse(categoryMapping || '[]');
706
- }
707
- catch {
839
+ const categoryMappings = unpackMappings(categoryMapping, []);
840
+ if (!Array.isArray(categoryMappings)) {
708
841
  logger.error('Unable to parse category mappings');
709
- categoryMappings = [];
842
+ return new Map();
710
843
  }
711
844
  return new Map(categoryMappings.map(({ storeCategory, appLovinCategory }) => [
712
845
  storeCategory.toLowerCase(),
@@ -743,6 +876,13 @@ const getEventUserData = (userId, data) => {
743
876
  userData.phone = normalizedPhone;
744
877
  }
745
878
  }
879
+ const zip = getNormalizedZip(data.zip, data.country_code);
880
+ if (zip) {
881
+ userData.zip = zip;
882
+ }
883
+ if (data.country_code) {
884
+ userData.country_code = data.country_code;
885
+ }
746
886
  return userData;
747
887
  };
748
888
  /**
@@ -754,9 +894,10 @@ const getEventUserData = (userId, data) => {
754
894
  * @param defaultCategory - JSON string containing default category configuration with label and value
755
895
  * @param categoryMapping - JSON string containing array of category mappings
756
896
  * @param userId - The explicit user ID to use in event user_data
897
+ * @param isNewCustomer - New/returning buyer flag, applied to Purchase only
757
898
  * @returns EventData payload if the event is supported, undefined otherwise
758
899
  */
759
- const getEventData = ({ eventName, userData, data, defaultCategory, categoryMapping, userId, }) => {
900
+ const getEventData = ({ eventName, userData, data, defaultCategory, categoryMapping, userId, isNewCustomer, }) => {
760
901
  var _a, _b, _c;
761
902
  const categories = getCategories(categoryMapping);
762
903
  const defaultCategoryId = getParsedCategoryId(defaultCategory);
@@ -855,6 +996,9 @@ const getEventData = ({ eventName, userData, data, defaultCategory, categoryMapp
855
996
  shipping: (_b = data.shippingCost) !== null && _b !== void 0 ? _b : 0,
856
997
  tax: (_c = data.taxCost) !== null && _c !== void 0 ? _c : 0,
857
998
  user_data: getEventUserData(userId, userData),
999
+ ...(typeof isNewCustomer === 'boolean' && {
1000
+ measurement: { is_new_customer: isNewCustomer },
1001
+ }),
858
1002
  },
859
1003
  };
860
1004
  }
@@ -901,9 +1045,14 @@ const getEventData = ({ eventName, userData, data, defaultCategory, categoryMapp
901
1045
  }
902
1046
  };
903
1047
 
904
- const tag = ({ data, eventName, eventId, manifestVariables, userId, destination, }) => {
1048
+ const getString = (kv, key) => {
1049
+ const value = kv === null || kv === void 0 ? void 0 : kv[key];
1050
+ return typeof value === 'string' && value ? value : undefined;
1051
+ };
1052
+ const tag = ({ data, eventName, eventId, manifestVariables, userId, destination, getEdgeData, }) => {
1053
+ var _a, _b;
905
1054
  const payload = {
906
- sdkVersion: "1.71.1" ,
1055
+ sdkVersion: "1.73.0" ,
907
1056
  };
908
1057
  if (window.axon && manifestVariables['enableBrowser'] === '1') {
909
1058
  try {
@@ -912,28 +1061,68 @@ const tag = ({ data, eventName, eventId, manifestVariables, userId, destination,
912
1061
  // email/phone usually arrive via the data API (stored in session KV),
913
1062
  // not on the event payload itself.
914
1063
  const kv = getData(destination, 'session')['kv'];
1064
+ // Same identity set the CAPI attaches server-side, so the browser pixel
1065
+ // is not the weaker of the two paths on a shopper we already know.
915
1066
  const userData = { user_id: userId };
916
- const email = (data === null || data === void 0 ? void 0 : data['email']) || (kv === null || kv === void 0 ? void 0 : kv['email']);
917
- if (email && typeof email === 'string') {
1067
+ // Checked per source rather than after the `||`, so a non-string on the
1068
+ // payload cannot displace a valid string in KV.
1069
+ const email = (_a = getString(data, 'email')) !== null && _a !== void 0 ? _a : getString(kv, 'email');
1070
+ if (email) {
918
1071
  userData.email = email;
919
1072
  }
920
- const phone = (data === null || data === void 0 ? void 0 : data['phone']) || (kv === null || kv === void 0 ? void 0 : kv['phone']);
921
- if (phone && typeof phone === 'string') {
1073
+ const phone = (_b = getString(data, 'phone')) !== null && _b !== void 0 ? _b : getString(kv, 'phone');
1074
+ if (phone) {
922
1075
  userData.phone = phone;
923
1076
  }
924
- const event = getEventData({
925
- eventName,
926
- userData,
927
- data,
928
- defaultCategory,
929
- categoryMapping,
930
- userId,
931
- });
932
- if (!event) {
933
- logger.log(`[${packageName}] ${eventName} not supported`);
934
- return;
1077
+ const zip = getString(kv, 'zip');
1078
+ if (zip) {
1079
+ userData.zip = zip;
1080
+ }
1081
+ const country = getString(kv, 'country');
1082
+ if (country) {
1083
+ userData.country_code = country;
1084
+ }
1085
+ const send = (isNewCustomer) => {
1086
+ var _a;
1087
+ const event = getEventData({
1088
+ eventName,
1089
+ userData,
1090
+ data,
1091
+ defaultCategory,
1092
+ categoryMapping,
1093
+ userId,
1094
+ isNewCustomer,
1095
+ });
1096
+ if (!event) {
1097
+ logger.log(`[${packageName}] ${eventName} not supported`);
1098
+ return;
1099
+ }
1100
+ (_a = window.axon) === null || _a === void 0 ? void 0 : _a.call(window, 'track', event.name, event.data, { dedupe_id: eventId });
1101
+ };
1102
+ if (eventName !== 'Purchase') {
1103
+ send();
1104
+ return payload;
1105
+ }
1106
+ // Prefer the flag already saved in the browser (the Shopify pixel's
1107
+ // `data` call writes it just before Purchase). Only query the edge when
1108
+ // the browser value is missing.
1109
+ const browserValue = destination
1110
+ ? getIsNewCustomerFromBrowser(destination)
1111
+ : undefined;
1112
+ if (typeof browserValue === 'boolean') {
1113
+ send(browserValue);
1114
+ }
1115
+ else if (getEdgeData) {
1116
+ getIsNewCustomerFlag(getEdgeData)
1117
+ // The fallback is the rejection handler rather than a trailing
1118
+ // `catch`, so only a failed lookup sends without the flag; a `send`
1119
+ // that throws must not be retried into a duplicate pixel call.
1120
+ .then(send, () => send())
1121
+ .catch((error) => logger.error(error));
1122
+ }
1123
+ else {
1124
+ send();
935
1125
  }
936
- window.axon('track', event.name, event.data, { dedupe_id: eventId });
937
1126
  }
938
1127
  catch (e) {
939
1128
  logger.error(e);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blotoutio/providers-app-lovin-sdk",
3
- "version": "1.71.1",
3
+ "version": "1.73.0",
4
4
  "description": "App Lovin Browser SDK for EdgeTag",
5
5
  "author": "Blotout",
6
6
  "license": "MIT",