@shware/analytics 11.1.0 → 11.2.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 (36) hide show
  1. package/README.md +9 -5
  2. package/dist/index.cjs +4 -2
  3. package/dist/index.d.cts +3 -2
  4. package/dist/index.d.mts +3 -2
  5. package/dist/index.mjs +3 -2
  6. package/dist/third-parties/ignored-events.cjs +7 -1
  7. package/dist/third-parties/ignored-events.cjs.map +1 -1
  8. package/dist/third-parties/ignored-events.d.cts.map +1 -1
  9. package/dist/third-parties/ignored-events.d.mts.map +1 -1
  10. package/dist/third-parties/ignored-events.mjs +7 -1
  11. package/dist/third-parties/ignored-events.mjs.map +1 -1
  12. package/dist/third-parties/microsoft-uet.cjs +2 -2
  13. package/dist/third-parties/microsoft-uet.cjs.map +1 -1
  14. package/dist/third-parties/microsoft-uet.mjs +1 -1
  15. package/dist/third-parties/microsoft-uet.mjs.map +1 -1
  16. package/dist/track/index.cjs +4 -4
  17. package/dist/track/index.cjs.map +1 -1
  18. package/dist/track/index.mjs +1 -1
  19. package/dist/track/index.mjs.map +1 -1
  20. package/dist/visitor/id.cjs +25 -0
  21. package/dist/visitor/id.cjs.map +1 -0
  22. package/dist/visitor/id.d.cts +13 -0
  23. package/dist/visitor/id.d.cts.map +1 -0
  24. package/dist/visitor/id.d.mts +13 -0
  25. package/dist/visitor/id.d.mts.map +1 -0
  26. package/dist/visitor/id.mjs +24 -0
  27. package/dist/visitor/id.mjs.map +1 -0
  28. package/dist/visitor/index.cjs +24 -29
  29. package/dist/visitor/index.cjs.map +1 -1
  30. package/dist/visitor/index.d.cts +15 -9
  31. package/dist/visitor/index.d.cts.map +1 -1
  32. package/dist/visitor/index.d.mts +15 -9
  33. package/dist/visitor/index.d.mts.map +1 -1
  34. package/dist/visitor/index.mjs +23 -29
  35. package/dist/visitor/index.mjs.map +1 -1
  36. package/package.json +1 -1
package/README.md CHANGED
@@ -85,11 +85,15 @@ the events are the visitor's record. The server:
85
85
  once per session is both enough and the right moment. `setVisitor` sends no tags (11.1): at
86
86
  sign-in the page is the login page, and its tags would overwrite the landing's. Clients before
87
87
  11.1 still send tags on their PATCH; merge them when present.
88
- - **does not create it on `PATCH /visitors/:id`** (`setVisitor`): that is a sign-in handing the
89
- user to the ad platforms' pixels and gtag, and by then the visitor's events have created it. On
90
- the rare page where a signed-in user's sign-in lands before the first batch of a new visitor id
91
- (storage cleared, the session kept), answer 404: `setVisitor` rejects, and the next page load
92
- sets the user.
88
+ - **binds it to the user from an `identify` event** (`IDENTIFY_EVENT`): `setVisitor` (11.2) sends no
89
+ request of its own — it hands the user to gtag and the pixels at once, and queues
90
+ `identify` with `{ user_id }` when the user differs from the one the page last identified. In
91
+ the batch's transaction, after creating the visitor, set its `user_id` and `distinct_id` to that
92
+ user (taking only a valid uuid, so a host's own event of that name binds nothing). Creation and
93
+ binding then travel in one request, in order: the binding can never arrive before the visitor.
94
+ `identify` is the SDK's: never forward it to the ad platforms or a CRM.
95
+ - **keeps `PATCH /visitors/:id`** for clients before 11.2, which bind the user there — answer 404
96
+ for a visitor no event has created, and merge the `tags` of clients before 11.1 when present.
93
97
  - **keeps `POST /visitors`** (`createVisitorSchema`) while clients before 11.0 are in use.
94
98
 
95
99
  Deploy the server first: a server that only creates visitors on `POST /visitors` rejects the
package/dist/index.cjs CHANGED
@@ -1,7 +1,8 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_setup_index = require("./setup/index.cjs");
3
- const require_visitor_index = require("./visitor/index.cjs");
3
+ const require_visitor_id = require("./visitor/id.cjs");
4
4
  const require_track_index = require("./track/index.cjs");
5
+ const require_visitor_index = require("./visitor/index.cjs");
5
6
  const require_feedback_index = require("./feedback/index.cjs");
6
7
  const require_link_index = require("./link/index.cjs");
7
8
  const require_schema_index = require("./schema/index.cjs");
@@ -11,6 +12,7 @@ const require_hooks_use_track_impression = require("./hooks/use-track-impression
11
12
  exports.ALL_ENVIRONMENTS = require_schema_index.ALL_ENVIRONMENTS;
12
13
  exports.ALL_PLATFORMS = require_schema_index.ALL_PLATFORMS;
13
14
  exports.BOT_CATEGORIES = require_bot_index.BOT_CATEGORIES;
15
+ exports.IDENTIFY_EVENT = require_visitor_index.IDENTIFY_EVENT;
14
16
  exports.NOT_A_BOT = require_bot_index.NOT_A_BOT;
15
17
  exports.botOf = require_bot_index.botOf;
16
18
  exports.createFeedbackSchema = require_schema_index.createFeedbackSchema;
@@ -29,4 +31,4 @@ exports.track = require_track_index.track;
29
31
  exports.trackAsync = require_track_index.trackAsync;
30
32
  exports.updateVisitorSchema = require_schema_index.updateVisitorSchema;
31
33
  exports.useTrackImpression = require_hooks_use_track_impression.useTrackImpression;
32
- exports.visitorId = require_visitor_index.visitorId;
34
+ exports.visitorId = require_visitor_id.visitorId;
package/dist/index.d.cts CHANGED
@@ -6,7 +6,8 @@ import { useTrackImpression } from "./hooks/use-track-impression.cjs";
6
6
  import { VisitorProperties } from "./visitor/types.cjs";
7
7
  import { setupAnalytics } from "./setup/index.cjs";
8
8
  import { sendBeacon, sendPendingEvents, track, trackAsync } from "./track/index.cjs";
9
- import { setVisitor, visitorId } from "./visitor/index.cjs";
9
+ import { visitorId } from "./visitor/id.cjs";
10
+ import { IDENTIFY_EVENT, setVisitor } from "./visitor/index.cjs";
10
11
  import { Link, createLink, getLink } from "./link/index.cjs";
11
12
  import { stripeMinorUnits } from "./utils/stripe.cjs";
12
- export { ALL_ENVIRONMENTS, ALL_PLATFORMS, type AllowedPropertyValues, BOT_CATEGORIES, type Bot, type BotCategory, type CreateFeedbackDTO, type CreateLinkDTO, type CreateTrackEventDTO, type CreateVisitorDTO, type CustomEventProperties, type Environment, type Link, NOT_A_BOT, type Platform, type TrackProperties, type TrackTags, type UTMParams, type UpdateVisitorDTO, type UserProvidedData, type VisitorProperties, botOf, createFeedbackSchema, createLink, createLinkSchema, createTrackEventSchema, createVisitorSchema, getLink, sendBeacon, sendFeedback, sendPendingEvents, setVisitor, setupAnalytics, stripeMinorUnits, track, trackAsync, updateVisitorSchema, useTrackImpression, visitorId };
13
+ export { ALL_ENVIRONMENTS, ALL_PLATFORMS, type AllowedPropertyValues, BOT_CATEGORIES, type Bot, type BotCategory, type CreateFeedbackDTO, type CreateLinkDTO, type CreateTrackEventDTO, type CreateVisitorDTO, type CustomEventProperties, type Environment, IDENTIFY_EVENT, type Link, NOT_A_BOT, type Platform, type TrackProperties, type TrackTags, type UTMParams, type UpdateVisitorDTO, type UserProvidedData, type VisitorProperties, botOf, createFeedbackSchema, createLink, createLinkSchema, createTrackEventSchema, createVisitorSchema, getLink, sendBeacon, sendFeedback, sendPendingEvents, setVisitor, setupAnalytics, stripeMinorUnits, track, trackAsync, updateVisitorSchema, useTrackImpression, visitorId };
package/dist/index.d.mts CHANGED
@@ -6,7 +6,8 @@ import { useTrackImpression } from "./hooks/use-track-impression.mjs";
6
6
  import { VisitorProperties } from "./visitor/types.mjs";
7
7
  import { setupAnalytics } from "./setup/index.mjs";
8
8
  import { sendBeacon, sendPendingEvents, track, trackAsync } from "./track/index.mjs";
9
- import { setVisitor, visitorId } from "./visitor/index.mjs";
9
+ import { visitorId } from "./visitor/id.mjs";
10
+ import { IDENTIFY_EVENT, setVisitor } from "./visitor/index.mjs";
10
11
  import { Link, createLink, getLink } from "./link/index.mjs";
11
12
  import { stripeMinorUnits } from "./utils/stripe.mjs";
12
- export { ALL_ENVIRONMENTS, ALL_PLATFORMS, type AllowedPropertyValues, BOT_CATEGORIES, type Bot, type BotCategory, type CreateFeedbackDTO, type CreateLinkDTO, type CreateTrackEventDTO, type CreateVisitorDTO, type CustomEventProperties, type Environment, type Link, NOT_A_BOT, type Platform, type TrackProperties, type TrackTags, type UTMParams, type UpdateVisitorDTO, type UserProvidedData, type VisitorProperties, botOf, createFeedbackSchema, createLink, createLinkSchema, createTrackEventSchema, createVisitorSchema, getLink, sendBeacon, sendFeedback, sendPendingEvents, setVisitor, setupAnalytics, stripeMinorUnits, track, trackAsync, updateVisitorSchema, useTrackImpression, visitorId };
13
+ export { ALL_ENVIRONMENTS, ALL_PLATFORMS, type AllowedPropertyValues, BOT_CATEGORIES, type Bot, type BotCategory, type CreateFeedbackDTO, type CreateLinkDTO, type CreateTrackEventDTO, type CreateVisitorDTO, type CustomEventProperties, type Environment, IDENTIFY_EVENT, type Link, NOT_A_BOT, type Platform, type TrackProperties, type TrackTags, type UTMParams, type UpdateVisitorDTO, type UserProvidedData, type VisitorProperties, botOf, createFeedbackSchema, createLink, createLinkSchema, createTrackEventSchema, createVisitorSchema, getLink, sendBeacon, sendFeedback, sendPendingEvents, setVisitor, setupAnalytics, stripeMinorUnits, track, trackAsync, updateVisitorSchema, useTrackImpression, visitorId };
package/dist/index.mjs CHANGED
@@ -1,10 +1,11 @@
1
1
  import { setupAnalytics } from "./setup/index.mjs";
2
- import { setVisitor, visitorId } from "./visitor/index.mjs";
2
+ import { visitorId } from "./visitor/id.mjs";
3
3
  import { sendBeacon, sendPendingEvents, track, trackAsync } from "./track/index.mjs";
4
+ import { IDENTIFY_EVENT, setVisitor } from "./visitor/index.mjs";
4
5
  import { sendFeedback } from "./feedback/index.mjs";
5
6
  import { createLink, getLink } from "./link/index.mjs";
6
7
  import { ALL_ENVIRONMENTS, ALL_PLATFORMS, createFeedbackSchema, createLinkSchema, createTrackEventSchema, createVisitorSchema, updateVisitorSchema } from "./schema/index.mjs";
7
8
  import { BOT_CATEGORIES, NOT_A_BOT, botOf } from "./bot/index.mjs";
8
9
  import { stripeMinorUnits } from "./utils/stripe.mjs";
9
10
  import { useTrackImpression } from "./hooks/use-track-impression.mjs";
10
- export { ALL_ENVIRONMENTS, ALL_PLATFORMS, BOT_CATEGORIES, NOT_A_BOT, botOf, createFeedbackSchema, createLink, createLinkSchema, createTrackEventSchema, createVisitorSchema, getLink, sendBeacon, sendFeedback, sendPendingEvents, setVisitor, setupAnalytics, stripeMinorUnits, track, trackAsync, updateVisitorSchema, useTrackImpression, visitorId };
11
+ export { ALL_ENVIRONMENTS, ALL_PLATFORMS, BOT_CATEGORIES, IDENTIFY_EVENT, NOT_A_BOT, botOf, createFeedbackSchema, createLink, createLinkSchema, createTrackEventSchema, createVisitorSchema, getLink, sendBeacon, sendFeedback, sendPendingEvents, setVisitor, setupAnalytics, stripeMinorUnits, track, trackAsync, updateVisitorSchema, useTrackImpression, visitorId };
@@ -31,7 +31,13 @@ const AUTOMATICALLY_COLLECTED_EVENTS = [
31
31
  "session_start",
32
32
  "user_engagement"
33
33
  ];
34
- const IGNORED_EVENTS = [...METRICS_EVENTS, ...AUTOMATICALLY_COLLECTED_EVENTS];
34
+ /** The SDK's own: the server binds the visitor to the user from it; no third party has a use for it. */
35
+ const SDK_EVENTS = ["identify"];
36
+ const IGNORED_EVENTS = [
37
+ ...METRICS_EVENTS,
38
+ ...AUTOMATICALLY_COLLECTED_EVENTS,
39
+ ...SDK_EVENTS
40
+ ];
35
41
  //#endregion
36
42
  exports.IGNORED_EVENTS = IGNORED_EVENTS;
37
43
 
@@ -1 +1 @@
1
- {"version":3,"file":"ignored-events.cjs","names":[],"sources":["../../src/third-parties/ignored-events.ts"],"sourcesContent":["const METRICS_EVENTS = ['CLS', 'FCP', 'FID', 'INP', 'LCP', 'TTFB'];\n\n/**\n * Automatically collected events: https://support.google.com/analytics/answer/9234069\n * Enhanced measurement events: https://support.google.com/analytics/answer/9216061\n */\nconst ENHANCED_MEASUREMENT_EVENTS = [\n // Page views\n 'page_view',\n // Scrolls: the first time a user reaches the bottom of each page\n // (i.e., when a 90% vertical depth becomes visible)\n 'scroll',\n // Outbound clicks\n 'click',\n // Site search\n 'view_search_results',\n // Video engagement\n 'video_start',\n 'video_progress',\n 'video_complete',\n // File downloads\n 'file_download',\n // Form interactions\n 'form_start',\n 'form_submit',\n];\n\nconst AUTOMATICALLY_COLLECTED_EVENTS = [\n ...ENHANCED_MEASUREMENT_EVENTS,\n 'first_visit',\n 'first_open',\n // ref: https://support.google.com/analytics/answer/9234069\n // 'in_app_purchase',\n 'notification_dismiss',\n 'notification_foreground',\n 'notification_open',\n 'notification_receive',\n 'notification_send',\n /**\n * Note: The 'screen_view' event is automatically sent only when using Android or iOS native SDKs.\n * If you are using React Native, you need to send this event manually; it should not be ignored.\n * */\n // 'screen_view',\n 'session_start',\n 'user_engagement',\n];\n\nexport const IGNORED_EVENTS = [...METRICS_EVENTS, ...AUTOMATICALLY_COLLECTED_EVENTS];\n"],"mappings":";;AAAA,MAAM,iBAAiB;CAAC;CAAO;CAAO;CAAO;CAAO;CAAO;AAAM;AA2BjE,MAAM,iCAAiC;CACrC,GAAG;EApBH;EAGA;EAEA;EAEA;EAEA;EACA;EACA;EAEA;EAEA;EACA;CAI6B;CAC7B;CACA;CAGA;CACA;CACA;CACA;CACA;CAMA;CACA;AACF;AAEA,MAAa,iBAAiB,CAAC,GAAG,gBAAgB,GAAG,8BAA8B"}
1
+ {"version":3,"file":"ignored-events.cjs","names":[],"sources":["../../src/third-parties/ignored-events.ts"],"sourcesContent":["const METRICS_EVENTS = ['CLS', 'FCP', 'FID', 'INP', 'LCP', 'TTFB'];\n\n/**\n * Automatically collected events: https://support.google.com/analytics/answer/9234069\n * Enhanced measurement events: https://support.google.com/analytics/answer/9216061\n */\nconst ENHANCED_MEASUREMENT_EVENTS = [\n // Page views\n 'page_view',\n // Scrolls: the first time a user reaches the bottom of each page\n // (i.e., when a 90% vertical depth becomes visible)\n 'scroll',\n // Outbound clicks\n 'click',\n // Site search\n 'view_search_results',\n // Video engagement\n 'video_start',\n 'video_progress',\n 'video_complete',\n // File downloads\n 'file_download',\n // Form interactions\n 'form_start',\n 'form_submit',\n];\n\nconst AUTOMATICALLY_COLLECTED_EVENTS = [\n ...ENHANCED_MEASUREMENT_EVENTS,\n 'first_visit',\n 'first_open',\n // ref: https://support.google.com/analytics/answer/9234069\n // 'in_app_purchase',\n 'notification_dismiss',\n 'notification_foreground',\n 'notification_open',\n 'notification_receive',\n 'notification_send',\n /**\n * Note: The 'screen_view' event is automatically sent only when using Android or iOS native SDKs.\n * If you are using React Native, you need to send this event manually; it should not be ignored.\n * */\n // 'screen_view',\n 'session_start',\n 'user_engagement',\n];\n\n/** The SDK's own: the server binds the visitor to the user from it; no third party has a use for it. */\nconst SDK_EVENTS = ['identify'];\n\nexport const IGNORED_EVENTS = [...METRICS_EVENTS, ...AUTOMATICALLY_COLLECTED_EVENTS, ...SDK_EVENTS];\n"],"mappings":";;AAAA,MAAM,iBAAiB;CAAC;CAAO;CAAO;CAAO;CAAO;CAAO;AAAM;AA2BjE,MAAM,iCAAiC;CACrC,GAAG;EApBH;EAGA;EAEA;EAEA;EAEA;EACA;EACA;EAEA;EAEA;EACA;CAI6B;CAC7B;CACA;CAGA;CACA;CACA;CACA;CACA;CAMA;CACA;AACF;;AAGA,MAAM,aAAa,CAAC,UAAU;AAE9B,MAAa,iBAAiB;CAAC,GAAG;CAAgB,GAAG;CAAgC,GAAG;AAAU"}
@@ -1 +1 @@
1
- {"version":3,"file":"ignored-events.d.cts","names":[],"sources":["../../src/third-parties/ignored-events.ts"],"mappings":";qBA+Ca"}
1
+ {"version":3,"file":"ignored-events.d.cts","names":[],"sources":["../../src/third-parties/ignored-events.ts"],"mappings":";qBAkDa"}
@@ -1 +1 @@
1
- {"version":3,"file":"ignored-events.d.mts","names":[],"sources":["../../src/third-parties/ignored-events.ts"],"mappings":";qBA+Ca"}
1
+ {"version":3,"file":"ignored-events.d.mts","names":[],"sources":["../../src/third-parties/ignored-events.ts"],"mappings":";qBAkDa"}
@@ -30,7 +30,13 @@ const AUTOMATICALLY_COLLECTED_EVENTS = [
30
30
  "session_start",
31
31
  "user_engagement"
32
32
  ];
33
- const IGNORED_EVENTS = [...METRICS_EVENTS, ...AUTOMATICALLY_COLLECTED_EVENTS];
33
+ /** The SDK's own: the server binds the visitor to the user from it; no third party has a use for it. */
34
+ const SDK_EVENTS = ["identify"];
35
+ const IGNORED_EVENTS = [
36
+ ...METRICS_EVENTS,
37
+ ...AUTOMATICALLY_COLLECTED_EVENTS,
38
+ ...SDK_EVENTS
39
+ ];
34
40
  //#endregion
35
41
  export { IGNORED_EVENTS };
36
42
 
@@ -1 +1 @@
1
- {"version":3,"file":"ignored-events.mjs","names":[],"sources":["../../src/third-parties/ignored-events.ts"],"sourcesContent":["const METRICS_EVENTS = ['CLS', 'FCP', 'FID', 'INP', 'LCP', 'TTFB'];\n\n/**\n * Automatically collected events: https://support.google.com/analytics/answer/9234069\n * Enhanced measurement events: https://support.google.com/analytics/answer/9216061\n */\nconst ENHANCED_MEASUREMENT_EVENTS = [\n // Page views\n 'page_view',\n // Scrolls: the first time a user reaches the bottom of each page\n // (i.e., when a 90% vertical depth becomes visible)\n 'scroll',\n // Outbound clicks\n 'click',\n // Site search\n 'view_search_results',\n // Video engagement\n 'video_start',\n 'video_progress',\n 'video_complete',\n // File downloads\n 'file_download',\n // Form interactions\n 'form_start',\n 'form_submit',\n];\n\nconst AUTOMATICALLY_COLLECTED_EVENTS = [\n ...ENHANCED_MEASUREMENT_EVENTS,\n 'first_visit',\n 'first_open',\n // ref: https://support.google.com/analytics/answer/9234069\n // 'in_app_purchase',\n 'notification_dismiss',\n 'notification_foreground',\n 'notification_open',\n 'notification_receive',\n 'notification_send',\n /**\n * Note: The 'screen_view' event is automatically sent only when using Android or iOS native SDKs.\n * If you are using React Native, you need to send this event manually; it should not be ignored.\n * */\n // 'screen_view',\n 'session_start',\n 'user_engagement',\n];\n\nexport const IGNORED_EVENTS = [...METRICS_EVENTS, ...AUTOMATICALLY_COLLECTED_EVENTS];\n"],"mappings":";AAAA,MAAM,iBAAiB;CAAC;CAAO;CAAO;CAAO;CAAO;CAAO;AAAM;AA2BjE,MAAM,iCAAiC;CACrC,GAAG;EApBH;EAGA;EAEA;EAEA;EAEA;EACA;EACA;EAEA;EAEA;EACA;CAI6B;CAC7B;CACA;CAGA;CACA;CACA;CACA;CACA;CAMA;CACA;AACF;AAEA,MAAa,iBAAiB,CAAC,GAAG,gBAAgB,GAAG,8BAA8B"}
1
+ {"version":3,"file":"ignored-events.mjs","names":[],"sources":["../../src/third-parties/ignored-events.ts"],"sourcesContent":["const METRICS_EVENTS = ['CLS', 'FCP', 'FID', 'INP', 'LCP', 'TTFB'];\n\n/**\n * Automatically collected events: https://support.google.com/analytics/answer/9234069\n * Enhanced measurement events: https://support.google.com/analytics/answer/9216061\n */\nconst ENHANCED_MEASUREMENT_EVENTS = [\n // Page views\n 'page_view',\n // Scrolls: the first time a user reaches the bottom of each page\n // (i.e., when a 90% vertical depth becomes visible)\n 'scroll',\n // Outbound clicks\n 'click',\n // Site search\n 'view_search_results',\n // Video engagement\n 'video_start',\n 'video_progress',\n 'video_complete',\n // File downloads\n 'file_download',\n // Form interactions\n 'form_start',\n 'form_submit',\n];\n\nconst AUTOMATICALLY_COLLECTED_EVENTS = [\n ...ENHANCED_MEASUREMENT_EVENTS,\n 'first_visit',\n 'first_open',\n // ref: https://support.google.com/analytics/answer/9234069\n // 'in_app_purchase',\n 'notification_dismiss',\n 'notification_foreground',\n 'notification_open',\n 'notification_receive',\n 'notification_send',\n /**\n * Note: The 'screen_view' event is automatically sent only when using Android or iOS native SDKs.\n * If you are using React Native, you need to send this event manually; it should not be ignored.\n * */\n // 'screen_view',\n 'session_start',\n 'user_engagement',\n];\n\n/** The SDK's own: the server binds the visitor to the user from it; no third party has a use for it. */\nconst SDK_EVENTS = ['identify'];\n\nexport const IGNORED_EVENTS = [...METRICS_EVENTS, ...AUTOMATICALLY_COLLECTED_EVENTS, ...SDK_EVENTS];\n"],"mappings":";AAAA,MAAM,iBAAiB;CAAC;CAAO;CAAO;CAAO;CAAO;CAAO;AAAM;AA2BjE,MAAM,iCAAiC;CACrC,GAAG;EApBH;EAGA;EAEA;EAEA;EAEA;EACA;EACA;EAEA;EAEA;EACA;CAI6B;CAC7B;CACA;CAGA;CACA;CACA;CACA;CACA;CAMA;CACA;AACF;;AAGA,MAAM,aAAa,CAAC,UAAU;AAE9B,MAAa,iBAAiB;CAAC,GAAG;CAAgB,GAAG;CAAgC,GAAG;AAAU"}
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_visitor_index = require("../visitor/index.cjs");
2
+ const require_visitor_id = require("../visitor/id.cjs");
3
3
  const require_track_uetq = require("../track/uetq.cjs");
4
4
  const require_utils_field = require("../utils/field.cjs");
5
5
  const require_utils_sha256 = require("../utils/sha256.cjs");
@@ -112,7 +112,7 @@ async function syncUETVisitor(userId) {
112
112
  if (!uetCustomerId) return;
113
113
  sendUETIdSync({
114
114
  customerId: uetCustomerId,
115
- visitorId: require_visitor_index.visitorId(),
115
+ visitorId: require_visitor_id.visitorId(),
116
116
  userId
117
117
  });
118
118
  }
@@ -1 +1 @@
1
- {"version":3,"file":"microsoft-uet.cjs","names":["mapUETEvent","getFirst","visitorId"],"sources":["../../src/third-parties/microsoft-uet.ts"],"sourcesContent":["import type { UpdateVisitorDTO } from '../schema/index';\nimport type { EventName, TrackName, TrackProperties } from '../track/types';\nimport { type UETConsent, type UETQ, mapUETEvent } from '../track/uetq';\nimport { getFirst } from '../utils/field';\nimport { sha256 } from '../utils/sha256';\nimport { visitorId } from '../visitor/index';\n\ndeclare global {\n interface Window {\n /**\n * Defined by the UET snippet before `bat.js` loads (as a plain array), so pushes made early\n * are queued rather than lost. Undefined only where the snippet was never installed.\n */\n uetq?: UETQ;\n }\n}\n\nconst metrics = ['CLS', 'FCP', 'FID', 'INP', 'LCP', 'TTFB'];\n\n/** Drop `undefined` fields: bat.js validates every key it receives, and `undefined` is a value. */\n// oxlint-disable-next-line @typescript-eslint/no-explicit-any\nfunction clean(value: unknown): any {\n return JSON.parse(JSON.stringify(value));\n}\n\nfunction isLocal(): boolean {\n return window.location.host.includes('127.0.0.1') || window.location.host.includes('localhost');\n}\n\n/**\n * Forward an internal track event to the Microsoft Advertising UET tag.\n *\n * `eventId` is reused as UET's `event_id` so the browser event deduplicates against the same\n * conversion sent through the Conversions API (`sendMicrosoftEvents`). The action name is the\n * internal name on both channels — see `mapUETEvent`.\n *\n * `page_view` is not forwarded: bat.js turns that action into its page-load beacon, which the\n * snippet already fires on load and, with `enableAutoSpaTracking: true` (the setting in\n * Microsoft's generated snippet), on every history change — forwarding the SDK's own page views\n * would report each page twice. The tag owns page loads; this tracker owns everything else.\n */\nexport function sendUETEvent<T extends EventName>(\n name: TrackName<T>,\n properties?: TrackProperties<T>,\n eventId?: string\n) {\n if (typeof window === 'undefined' || !window.uetq) {\n console.warn('uetq has not been initialized');\n return;\n }\n if (metrics.includes(name) || name === 'page_view') return;\n if (isLocal()) return;\n\n const [action, params] = mapUETEvent(name, properties, eventId);\n window.uetq.push('event', action, clean(params));\n}\n\n/**\n * Enhanced conversions: hand the tag the visitor's email and phone so Microsoft can match a\n * conversion to an ad click without a third-party cookie. A `set` applies to every event fired\n * from the page afterwards, `pageLoad` included, and the tag fires a `pid` beacon on its own\n * when the page load already went out. Values are sent raw — the tag normalizes and SHA-256\n * hashes them in the browser (bat.js `validatePid`) before anything leaves the page.\n *\n * A setter itself, like `setGAUser`, rather than a factory like `setFBUser(pixelId)`: UET is\n * bound to its tag by the snippet and `set` addresses whichever tag drains `uetq`, so there is\n * nothing to bind first.\n */\nexport function setUETUser({ user_id, user_data }: UpdateVisitorDTO) {\n if (typeof window === 'undefined' || !window.uetq) {\n console.warn('uetq has not been initialized');\n return;\n }\n\n // A signed-in user is also the moment the ID Sync pixel can carry a `UID` — see `syncUETVisitor`.\n if (user_id) void syncUETVisitor(user_id);\n\n const em = getFirst(user_data?.email);\n const ph = getFirst(user_data?.phone_number);\n if (!em && !ph) return;\n\n window.uetq.push('set', { pid: clean({ em, ph }) });\n}\n\n/**\n * UET consent mode. Push `default` before the tag loads (the tag processes `consent` early, ahead\n * of the queued events) and `update` when the visitor decides. With `ad_storage: 'denied'` the\n * tag neither writes its cookies nor reads the `msclkid` from the URL.\n * https://help.ads.microsoft.com/#apex/ads/en/60119/1\n */\nexport function setUETConsent(mode: 'default' | 'update', consent: UETConsent) {\n if (typeof window === 'undefined') return;\n window.uetq ??= [] as unknown as UETQ;\n window.uetq.push('consent', mode, consent);\n}\n\n/**\n * The Conversions API's client-side ID Sync pixel: ties the visitor id sent server-side as\n * `anonymousId` to Microsoft's own identifier, so server events can be attributed across\n * contexts (view-through, audiences). Microsoft requires it for remarketing built from CAPI\n * events and recommends it for measurement quality; fire it at least once per session, ideally\n * on the first page view. A pixel rather than a fetch on purpose: Microsoft must observe the\n * browser context of the sync, which a server-side call cannot carry.\n *\n * `customerId` is the Microsoft Advertising customer id (`cid` in the UI's URLs) — not the UET\n * tag id. `visitorId` is the SDK visitor id (`visitorId()`), which is what\n * `sendMicrosoftEvents` sends as `anonymousId`; Microsoft requires the two to be equal.\n * `userId` is the raw signed-in user id: it is SHA-256 hashed here, exactly as the server sender\n * hashes it into `externalId`, so the pair matches as well. Hashing is asynchronous, so the\n * pixel fires once the digest resolves; where SubtleCrypto is unavailable (an insecure context)\n * the pixel goes out without `UID` rather than with a raw id.\n * https://learn.microsoft.com/en-us/advertising/guides/uet-conversion-api-integration#id-sync-and-why-it-matters\n */\n/**\n * The Microsoft Advertising customer id the ID Sync pixel reports to, set once by the framework\n * `Analytics` component's `uetCustomerId` prop (or `configureUET`). Unset, nothing syncs.\n */\nlet uetCustomerId: string | undefined;\n\nexport function configureUET(options: { customerId?: string | number }) {\n uetCustomerId = options.customerId === undefined ? undefined : String(options.customerId);\n}\n\n/**\n * Sync the SDK visitor — the `anonymousId` of the events the server sends to the Conversions\n * API — with Microsoft, plus the user's `UID` when one is known. The `Analytics` components run\n * it on mount so every visit syncs at least once, anonymous visitors included (they are who\n * remarketing audiences are built from), and `setUETUser` runs it again on sign-in.\n */\nexport async function syncUETVisitor(userId?: string): Promise<void> {\n if (!uetCustomerId) return;\n sendUETIdSync({ customerId: uetCustomerId, visitorId: visitorId(), userId });\n}\n\nexport function sendUETIdSync(options: {\n customerId: string | number;\n visitorId: string;\n /** The signed-in user's id, raw; hashed before it leaves the page. */\n userId?: string;\n}) {\n if (typeof window === 'undefined' || typeof Image === 'undefined') return;\n if (isLocal()) return;\n\n const fire = (uid?: string) => {\n const params = new URLSearchParams({\n Red3: `BACID_${options.customerId}`,\n VID: options.visitorId,\n });\n if (uid) params.set('UID', uid);\n new Image().src = `https://c.bing.com/c.gif?${params.toString()}`;\n };\n\n if (!options.userId) {\n fire();\n return;\n }\n sha256(options.userId).then(fire, () => fire());\n}\n"],"mappings":";;;;;;AAiBA,MAAM,UAAU;CAAC;CAAO;CAAO;CAAO;CAAO;CAAO;AAAM;;AAI1D,SAAS,MAAM,OAAqB;CAClC,OAAO,KAAK,MAAM,KAAK,UAAU,KAAK,CAAC;AACzC;AAEA,SAAS,UAAmB;CAC1B,OAAO,OAAO,SAAS,KAAK,SAAS,WAAW,KAAK,OAAO,SAAS,KAAK,SAAS,WAAW;AAChG;;;;;;;;;;;;;AAcA,SAAgB,aACd,MACA,YACA,SACA;CACA,IAAI,OAAO,WAAW,eAAe,CAAC,OAAO,MAAM;EACjD,QAAQ,KAAK,+BAA+B;EAC5C;CACF;CACA,IAAI,QAAQ,SAAS,IAAI,KAAK,SAAS,aAAa;CACpD,IAAI,QAAQ,GAAG;CAEf,MAAM,CAAC,QAAQ,UAAUA,mBAAAA,YAAY,MAAM,YAAY,OAAO;CAC9D,OAAO,KAAK,KAAK,SAAS,QAAQ,MAAM,MAAM,CAAC;AACjD;;;;;;;;;;;;AAaA,SAAgB,WAAW,EAAE,SAAS,aAA+B;CACnE,IAAI,OAAO,WAAW,eAAe,CAAC,OAAO,MAAM;EACjD,QAAQ,KAAK,+BAA+B;EAC5C;CACF;CAGA,IAAI,SAAS,eAAoB,OAAO;CAExC,MAAM,KAAKC,oBAAAA,SAAS,WAAW,KAAK;CACpC,MAAM,KAAKA,oBAAAA,SAAS,WAAW,YAAY;CAC3C,IAAI,CAAC,MAAM,CAAC,IAAI;CAEhB,OAAO,KAAK,KAAK,OAAO,EAAE,KAAK,MAAM;EAAE;EAAI;CAAG,CAAC,EAAE,CAAC;AACpD;;;;;;;AAQA,SAAgB,cAAc,MAA4B,SAAqB;CAC7E,IAAI,OAAO,WAAW,aAAa;CACnC,OAAO,SAAS,CAAC;CACjB,OAAO,KAAK,KAAK,WAAW,MAAM,OAAO;AAC3C;;;;;;;;;;;;;;;;;;;;;;AAuBA,IAAI;AAEJ,SAAgB,aAAa,SAA2C;CACtE,gBAAgB,QAAQ,eAAe,KAAA,IAAY,KAAA,IAAY,OAAO,QAAQ,UAAU;AAC1F;;;;;;;AAQA,eAAsB,eAAe,QAAgC;CACnE,IAAI,CAAC,eAAe;CACpB,cAAc;EAAE,YAAY;EAAe,WAAWC,sBAAAA,UAAU;EAAG;CAAO,CAAC;AAC7E;AAEA,SAAgB,cAAc,SAK3B;CACD,IAAI,OAAO,WAAW,eAAe,OAAO,UAAU,aAAa;CACnE,IAAI,QAAQ,GAAG;CAEf,MAAM,QAAQ,QAAiB;EAC7B,MAAM,SAAS,IAAI,gBAAgB;GACjC,MAAM,SAAS,QAAQ;GACvB,KAAK,QAAQ;EACf,CAAC;EACD,IAAI,KAAK,OAAO,IAAI,OAAO,GAAG;EAC9B,IAAI,MAAM,CAAC,CAAC,MAAM,4BAA4B,OAAO,SAAS;CAChE;CAEA,IAAI,CAAC,QAAQ,QAAQ;EACnB,KAAK;EACL;CACF;CACA,qBAAA,OAAO,QAAQ,MAAM,CAAC,CAAC,KAAK,YAAY,KAAK,CAAC;AAChD"}
1
+ {"version":3,"file":"microsoft-uet.cjs","names":["mapUETEvent","getFirst","visitorId"],"sources":["../../src/third-parties/microsoft-uet.ts"],"sourcesContent":["import type { UpdateVisitorDTO } from '../schema/index';\nimport type { EventName, TrackName, TrackProperties } from '../track/types';\nimport { type UETConsent, type UETQ, mapUETEvent } from '../track/uetq';\nimport { getFirst } from '../utils/field';\nimport { sha256 } from '../utils/sha256';\nimport { visitorId } from '../visitor/id';\n\ndeclare global {\n interface Window {\n /**\n * Defined by the UET snippet before `bat.js` loads (as a plain array), so pushes made early\n * are queued rather than lost. Undefined only where the snippet was never installed.\n */\n uetq?: UETQ;\n }\n}\n\nconst metrics = ['CLS', 'FCP', 'FID', 'INP', 'LCP', 'TTFB'];\n\n/** Drop `undefined` fields: bat.js validates every key it receives, and `undefined` is a value. */\n// oxlint-disable-next-line @typescript-eslint/no-explicit-any\nfunction clean(value: unknown): any {\n return JSON.parse(JSON.stringify(value));\n}\n\nfunction isLocal(): boolean {\n return window.location.host.includes('127.0.0.1') || window.location.host.includes('localhost');\n}\n\n/**\n * Forward an internal track event to the Microsoft Advertising UET tag.\n *\n * `eventId` is reused as UET's `event_id` so the browser event deduplicates against the same\n * conversion sent through the Conversions API (`sendMicrosoftEvents`). The action name is the\n * internal name on both channels — see `mapUETEvent`.\n *\n * `page_view` is not forwarded: bat.js turns that action into its page-load beacon, which the\n * snippet already fires on load and, with `enableAutoSpaTracking: true` (the setting in\n * Microsoft's generated snippet), on every history change — forwarding the SDK's own page views\n * would report each page twice. The tag owns page loads; this tracker owns everything else.\n */\nexport function sendUETEvent<T extends EventName>(\n name: TrackName<T>,\n properties?: TrackProperties<T>,\n eventId?: string\n) {\n if (typeof window === 'undefined' || !window.uetq) {\n console.warn('uetq has not been initialized');\n return;\n }\n if (metrics.includes(name) || name === 'page_view') return;\n if (isLocal()) return;\n\n const [action, params] = mapUETEvent(name, properties, eventId);\n window.uetq.push('event', action, clean(params));\n}\n\n/**\n * Enhanced conversions: hand the tag the visitor's email and phone so Microsoft can match a\n * conversion to an ad click without a third-party cookie. A `set` applies to every event fired\n * from the page afterwards, `pageLoad` included, and the tag fires a `pid` beacon on its own\n * when the page load already went out. Values are sent raw — the tag normalizes and SHA-256\n * hashes them in the browser (bat.js `validatePid`) before anything leaves the page.\n *\n * A setter itself, like `setGAUser`, rather than a factory like `setFBUser(pixelId)`: UET is\n * bound to its tag by the snippet and `set` addresses whichever tag drains `uetq`, so there is\n * nothing to bind first.\n */\nexport function setUETUser({ user_id, user_data }: UpdateVisitorDTO) {\n if (typeof window === 'undefined' || !window.uetq) {\n console.warn('uetq has not been initialized');\n return;\n }\n\n // A signed-in user is also the moment the ID Sync pixel can carry a `UID` — see `syncUETVisitor`.\n if (user_id) void syncUETVisitor(user_id);\n\n const em = getFirst(user_data?.email);\n const ph = getFirst(user_data?.phone_number);\n if (!em && !ph) return;\n\n window.uetq.push('set', { pid: clean({ em, ph }) });\n}\n\n/**\n * UET consent mode. Push `default` before the tag loads (the tag processes `consent` early, ahead\n * of the queued events) and `update` when the visitor decides. With `ad_storage: 'denied'` the\n * tag neither writes its cookies nor reads the `msclkid` from the URL.\n * https://help.ads.microsoft.com/#apex/ads/en/60119/1\n */\nexport function setUETConsent(mode: 'default' | 'update', consent: UETConsent) {\n if (typeof window === 'undefined') return;\n window.uetq ??= [] as unknown as UETQ;\n window.uetq.push('consent', mode, consent);\n}\n\n/**\n * The Conversions API's client-side ID Sync pixel: ties the visitor id sent server-side as\n * `anonymousId` to Microsoft's own identifier, so server events can be attributed across\n * contexts (view-through, audiences). Microsoft requires it for remarketing built from CAPI\n * events and recommends it for measurement quality; fire it at least once per session, ideally\n * on the first page view. A pixel rather than a fetch on purpose: Microsoft must observe the\n * browser context of the sync, which a server-side call cannot carry.\n *\n * `customerId` is the Microsoft Advertising customer id (`cid` in the UI's URLs) — not the UET\n * tag id. `visitorId` is the SDK visitor id (`visitorId()`), which is what\n * `sendMicrosoftEvents` sends as `anonymousId`; Microsoft requires the two to be equal.\n * `userId` is the raw signed-in user id: it is SHA-256 hashed here, exactly as the server sender\n * hashes it into `externalId`, so the pair matches as well. Hashing is asynchronous, so the\n * pixel fires once the digest resolves; where SubtleCrypto is unavailable (an insecure context)\n * the pixel goes out without `UID` rather than with a raw id.\n * https://learn.microsoft.com/en-us/advertising/guides/uet-conversion-api-integration#id-sync-and-why-it-matters\n */\n/**\n * The Microsoft Advertising customer id the ID Sync pixel reports to, set once by the framework\n * `Analytics` component's `uetCustomerId` prop (or `configureUET`). Unset, nothing syncs.\n */\nlet uetCustomerId: string | undefined;\n\nexport function configureUET(options: { customerId?: string | number }) {\n uetCustomerId = options.customerId === undefined ? undefined : String(options.customerId);\n}\n\n/**\n * Sync the SDK visitor — the `anonymousId` of the events the server sends to the Conversions\n * API — with Microsoft, plus the user's `UID` when one is known. The `Analytics` components run\n * it on mount so every visit syncs at least once, anonymous visitors included (they are who\n * remarketing audiences are built from), and `setUETUser` runs it again on sign-in.\n */\nexport async function syncUETVisitor(userId?: string): Promise<void> {\n if (!uetCustomerId) return;\n sendUETIdSync({ customerId: uetCustomerId, visitorId: visitorId(), userId });\n}\n\nexport function sendUETIdSync(options: {\n customerId: string | number;\n visitorId: string;\n /** The signed-in user's id, raw; hashed before it leaves the page. */\n userId?: string;\n}) {\n if (typeof window === 'undefined' || typeof Image === 'undefined') return;\n if (isLocal()) return;\n\n const fire = (uid?: string) => {\n const params = new URLSearchParams({\n Red3: `BACID_${options.customerId}`,\n VID: options.visitorId,\n });\n if (uid) params.set('UID', uid);\n new Image().src = `https://c.bing.com/c.gif?${params.toString()}`;\n };\n\n if (!options.userId) {\n fire();\n return;\n }\n sha256(options.userId).then(fire, () => fire());\n}\n"],"mappings":";;;;;;AAiBA,MAAM,UAAU;CAAC;CAAO;CAAO;CAAO;CAAO;CAAO;AAAM;;AAI1D,SAAS,MAAM,OAAqB;CAClC,OAAO,KAAK,MAAM,KAAK,UAAU,KAAK,CAAC;AACzC;AAEA,SAAS,UAAmB;CAC1B,OAAO,OAAO,SAAS,KAAK,SAAS,WAAW,KAAK,OAAO,SAAS,KAAK,SAAS,WAAW;AAChG;;;;;;;;;;;;;AAcA,SAAgB,aACd,MACA,YACA,SACA;CACA,IAAI,OAAO,WAAW,eAAe,CAAC,OAAO,MAAM;EACjD,QAAQ,KAAK,+BAA+B;EAC5C;CACF;CACA,IAAI,QAAQ,SAAS,IAAI,KAAK,SAAS,aAAa;CACpD,IAAI,QAAQ,GAAG;CAEf,MAAM,CAAC,QAAQ,UAAUA,mBAAAA,YAAY,MAAM,YAAY,OAAO;CAC9D,OAAO,KAAK,KAAK,SAAS,QAAQ,MAAM,MAAM,CAAC;AACjD;;;;;;;;;;;;AAaA,SAAgB,WAAW,EAAE,SAAS,aAA+B;CACnE,IAAI,OAAO,WAAW,eAAe,CAAC,OAAO,MAAM;EACjD,QAAQ,KAAK,+BAA+B;EAC5C;CACF;CAGA,IAAI,SAAS,eAAoB,OAAO;CAExC,MAAM,KAAKC,oBAAAA,SAAS,WAAW,KAAK;CACpC,MAAM,KAAKA,oBAAAA,SAAS,WAAW,YAAY;CAC3C,IAAI,CAAC,MAAM,CAAC,IAAI;CAEhB,OAAO,KAAK,KAAK,OAAO,EAAE,KAAK,MAAM;EAAE;EAAI;CAAG,CAAC,EAAE,CAAC;AACpD;;;;;;;AAQA,SAAgB,cAAc,MAA4B,SAAqB;CAC7E,IAAI,OAAO,WAAW,aAAa;CACnC,OAAO,SAAS,CAAC;CACjB,OAAO,KAAK,KAAK,WAAW,MAAM,OAAO;AAC3C;;;;;;;;;;;;;;;;;;;;;;AAuBA,IAAI;AAEJ,SAAgB,aAAa,SAA2C;CACtE,gBAAgB,QAAQ,eAAe,KAAA,IAAY,KAAA,IAAY,OAAO,QAAQ,UAAU;AAC1F;;;;;;;AAQA,eAAsB,eAAe,QAAgC;CACnE,IAAI,CAAC,eAAe;CACpB,cAAc;EAAE,YAAY;EAAe,WAAWC,mBAAAA,UAAU;EAAG;CAAO,CAAC;AAC7E;AAEA,SAAgB,cAAc,SAK3B;CACD,IAAI,OAAO,WAAW,eAAe,OAAO,UAAU,aAAa;CACnE,IAAI,QAAQ,GAAG;CAEf,MAAM,QAAQ,QAAiB;EAC7B,MAAM,SAAS,IAAI,gBAAgB;GACjC,MAAM,SAAS,QAAQ;GACvB,KAAK,QAAQ;EACf,CAAC;EACD,IAAI,KAAK,OAAO,IAAI,OAAO,GAAG;EAC9B,IAAI,MAAM,CAAC,CAAC,MAAM,4BAA4B,OAAO,SAAS;CAChE;CAEA,IAAI,CAAC,QAAQ,QAAQ;EACnB,KAAK;EACL;CACF;CACA,qBAAA,OAAO,QAAQ,MAAM,CAAC,CAAC,KAAK,YAAY,KAAK,CAAC;AAChD"}
@@ -1,4 +1,4 @@
1
- import { visitorId } from "../visitor/index.mjs";
1
+ import { visitorId } from "../visitor/id.mjs";
2
2
  import { mapUETEvent } from "../track/uetq.mjs";
3
3
  import { getFirst } from "../utils/field.mjs";
4
4
  import { sha256 } from "../utils/sha256.mjs";
@@ -1 +1 @@
1
- {"version":3,"file":"microsoft-uet.mjs","names":[],"sources":["../../src/third-parties/microsoft-uet.ts"],"sourcesContent":["import type { UpdateVisitorDTO } from '../schema/index';\nimport type { EventName, TrackName, TrackProperties } from '../track/types';\nimport { type UETConsent, type UETQ, mapUETEvent } from '../track/uetq';\nimport { getFirst } from '../utils/field';\nimport { sha256 } from '../utils/sha256';\nimport { visitorId } from '../visitor/index';\n\ndeclare global {\n interface Window {\n /**\n * Defined by the UET snippet before `bat.js` loads (as a plain array), so pushes made early\n * are queued rather than lost. Undefined only where the snippet was never installed.\n */\n uetq?: UETQ;\n }\n}\n\nconst metrics = ['CLS', 'FCP', 'FID', 'INP', 'LCP', 'TTFB'];\n\n/** Drop `undefined` fields: bat.js validates every key it receives, and `undefined` is a value. */\n// oxlint-disable-next-line @typescript-eslint/no-explicit-any\nfunction clean(value: unknown): any {\n return JSON.parse(JSON.stringify(value));\n}\n\nfunction isLocal(): boolean {\n return window.location.host.includes('127.0.0.1') || window.location.host.includes('localhost');\n}\n\n/**\n * Forward an internal track event to the Microsoft Advertising UET tag.\n *\n * `eventId` is reused as UET's `event_id` so the browser event deduplicates against the same\n * conversion sent through the Conversions API (`sendMicrosoftEvents`). The action name is the\n * internal name on both channels — see `mapUETEvent`.\n *\n * `page_view` is not forwarded: bat.js turns that action into its page-load beacon, which the\n * snippet already fires on load and, with `enableAutoSpaTracking: true` (the setting in\n * Microsoft's generated snippet), on every history change — forwarding the SDK's own page views\n * would report each page twice. The tag owns page loads; this tracker owns everything else.\n */\nexport function sendUETEvent<T extends EventName>(\n name: TrackName<T>,\n properties?: TrackProperties<T>,\n eventId?: string\n) {\n if (typeof window === 'undefined' || !window.uetq) {\n console.warn('uetq has not been initialized');\n return;\n }\n if (metrics.includes(name) || name === 'page_view') return;\n if (isLocal()) return;\n\n const [action, params] = mapUETEvent(name, properties, eventId);\n window.uetq.push('event', action, clean(params));\n}\n\n/**\n * Enhanced conversions: hand the tag the visitor's email and phone so Microsoft can match a\n * conversion to an ad click without a third-party cookie. A `set` applies to every event fired\n * from the page afterwards, `pageLoad` included, and the tag fires a `pid` beacon on its own\n * when the page load already went out. Values are sent raw — the tag normalizes and SHA-256\n * hashes them in the browser (bat.js `validatePid`) before anything leaves the page.\n *\n * A setter itself, like `setGAUser`, rather than a factory like `setFBUser(pixelId)`: UET is\n * bound to its tag by the snippet and `set` addresses whichever tag drains `uetq`, so there is\n * nothing to bind first.\n */\nexport function setUETUser({ user_id, user_data }: UpdateVisitorDTO) {\n if (typeof window === 'undefined' || !window.uetq) {\n console.warn('uetq has not been initialized');\n return;\n }\n\n // A signed-in user is also the moment the ID Sync pixel can carry a `UID` — see `syncUETVisitor`.\n if (user_id) void syncUETVisitor(user_id);\n\n const em = getFirst(user_data?.email);\n const ph = getFirst(user_data?.phone_number);\n if (!em && !ph) return;\n\n window.uetq.push('set', { pid: clean({ em, ph }) });\n}\n\n/**\n * UET consent mode. Push `default` before the tag loads (the tag processes `consent` early, ahead\n * of the queued events) and `update` when the visitor decides. With `ad_storage: 'denied'` the\n * tag neither writes its cookies nor reads the `msclkid` from the URL.\n * https://help.ads.microsoft.com/#apex/ads/en/60119/1\n */\nexport function setUETConsent(mode: 'default' | 'update', consent: UETConsent) {\n if (typeof window === 'undefined') return;\n window.uetq ??= [] as unknown as UETQ;\n window.uetq.push('consent', mode, consent);\n}\n\n/**\n * The Conversions API's client-side ID Sync pixel: ties the visitor id sent server-side as\n * `anonymousId` to Microsoft's own identifier, so server events can be attributed across\n * contexts (view-through, audiences). Microsoft requires it for remarketing built from CAPI\n * events and recommends it for measurement quality; fire it at least once per session, ideally\n * on the first page view. A pixel rather than a fetch on purpose: Microsoft must observe the\n * browser context of the sync, which a server-side call cannot carry.\n *\n * `customerId` is the Microsoft Advertising customer id (`cid` in the UI's URLs) — not the UET\n * tag id. `visitorId` is the SDK visitor id (`visitorId()`), which is what\n * `sendMicrosoftEvents` sends as `anonymousId`; Microsoft requires the two to be equal.\n * `userId` is the raw signed-in user id: it is SHA-256 hashed here, exactly as the server sender\n * hashes it into `externalId`, so the pair matches as well. Hashing is asynchronous, so the\n * pixel fires once the digest resolves; where SubtleCrypto is unavailable (an insecure context)\n * the pixel goes out without `UID` rather than with a raw id.\n * https://learn.microsoft.com/en-us/advertising/guides/uet-conversion-api-integration#id-sync-and-why-it-matters\n */\n/**\n * The Microsoft Advertising customer id the ID Sync pixel reports to, set once by the framework\n * `Analytics` component's `uetCustomerId` prop (or `configureUET`). Unset, nothing syncs.\n */\nlet uetCustomerId: string | undefined;\n\nexport function configureUET(options: { customerId?: string | number }) {\n uetCustomerId = options.customerId === undefined ? undefined : String(options.customerId);\n}\n\n/**\n * Sync the SDK visitor — the `anonymousId` of the events the server sends to the Conversions\n * API — with Microsoft, plus the user's `UID` when one is known. The `Analytics` components run\n * it on mount so every visit syncs at least once, anonymous visitors included (they are who\n * remarketing audiences are built from), and `setUETUser` runs it again on sign-in.\n */\nexport async function syncUETVisitor(userId?: string): Promise<void> {\n if (!uetCustomerId) return;\n sendUETIdSync({ customerId: uetCustomerId, visitorId: visitorId(), userId });\n}\n\nexport function sendUETIdSync(options: {\n customerId: string | number;\n visitorId: string;\n /** The signed-in user's id, raw; hashed before it leaves the page. */\n userId?: string;\n}) {\n if (typeof window === 'undefined' || typeof Image === 'undefined') return;\n if (isLocal()) return;\n\n const fire = (uid?: string) => {\n const params = new URLSearchParams({\n Red3: `BACID_${options.customerId}`,\n VID: options.visitorId,\n });\n if (uid) params.set('UID', uid);\n new Image().src = `https://c.bing.com/c.gif?${params.toString()}`;\n };\n\n if (!options.userId) {\n fire();\n return;\n }\n sha256(options.userId).then(fire, () => fire());\n}\n"],"mappings":";;;;;AAiBA,MAAM,UAAU;CAAC;CAAO;CAAO;CAAO;CAAO;CAAO;AAAM;;AAI1D,SAAS,MAAM,OAAqB;CAClC,OAAO,KAAK,MAAM,KAAK,UAAU,KAAK,CAAC;AACzC;AAEA,SAAS,UAAmB;CAC1B,OAAO,OAAO,SAAS,KAAK,SAAS,WAAW,KAAK,OAAO,SAAS,KAAK,SAAS,WAAW;AAChG;;;;;;;;;;;;;AAcA,SAAgB,aACd,MACA,YACA,SACA;CACA,IAAI,OAAO,WAAW,eAAe,CAAC,OAAO,MAAM;EACjD,QAAQ,KAAK,+BAA+B;EAC5C;CACF;CACA,IAAI,QAAQ,SAAS,IAAI,KAAK,SAAS,aAAa;CACpD,IAAI,QAAQ,GAAG;CAEf,MAAM,CAAC,QAAQ,UAAU,YAAY,MAAM,YAAY,OAAO;CAC9D,OAAO,KAAK,KAAK,SAAS,QAAQ,MAAM,MAAM,CAAC;AACjD;;;;;;;;;;;;AAaA,SAAgB,WAAW,EAAE,SAAS,aAA+B;CACnE,IAAI,OAAO,WAAW,eAAe,CAAC,OAAO,MAAM;EACjD,QAAQ,KAAK,+BAA+B;EAC5C;CACF;CAGA,IAAI,SAAS,eAAoB,OAAO;CAExC,MAAM,KAAK,SAAS,WAAW,KAAK;CACpC,MAAM,KAAK,SAAS,WAAW,YAAY;CAC3C,IAAI,CAAC,MAAM,CAAC,IAAI;CAEhB,OAAO,KAAK,KAAK,OAAO,EAAE,KAAK,MAAM;EAAE;EAAI;CAAG,CAAC,EAAE,CAAC;AACpD;;;;;;;AAQA,SAAgB,cAAc,MAA4B,SAAqB;CAC7E,IAAI,OAAO,WAAW,aAAa;CACnC,OAAO,SAAS,CAAC;CACjB,OAAO,KAAK,KAAK,WAAW,MAAM,OAAO;AAC3C;;;;;;;;;;;;;;;;;;;;;;AAuBA,IAAI;AAEJ,SAAgB,aAAa,SAA2C;CACtE,gBAAgB,QAAQ,eAAe,KAAA,IAAY,KAAA,IAAY,OAAO,QAAQ,UAAU;AAC1F;;;;;;;AAQA,eAAsB,eAAe,QAAgC;CACnE,IAAI,CAAC,eAAe;CACpB,cAAc;EAAE,YAAY;EAAe,WAAW,UAAU;EAAG;CAAO,CAAC;AAC7E;AAEA,SAAgB,cAAc,SAK3B;CACD,IAAI,OAAO,WAAW,eAAe,OAAO,UAAU,aAAa;CACnE,IAAI,QAAQ,GAAG;CAEf,MAAM,QAAQ,QAAiB;EAC7B,MAAM,SAAS,IAAI,gBAAgB;GACjC,MAAM,SAAS,QAAQ;GACvB,KAAK,QAAQ;EACf,CAAC;EACD,IAAI,KAAK,OAAO,IAAI,OAAO,GAAG;EAC9B,IAAI,MAAM,CAAC,CAAC,MAAM,4BAA4B,OAAO,SAAS;CAChE;CAEA,IAAI,CAAC,QAAQ,QAAQ;EACnB,KAAK;EACL;CACF;CACA,OAAO,QAAQ,MAAM,CAAC,CAAC,KAAK,YAAY,KAAK,CAAC;AAChD"}
1
+ {"version":3,"file":"microsoft-uet.mjs","names":[],"sources":["../../src/third-parties/microsoft-uet.ts"],"sourcesContent":["import type { UpdateVisitorDTO } from '../schema/index';\nimport type { EventName, TrackName, TrackProperties } from '../track/types';\nimport { type UETConsent, type UETQ, mapUETEvent } from '../track/uetq';\nimport { getFirst } from '../utils/field';\nimport { sha256 } from '../utils/sha256';\nimport { visitorId } from '../visitor/id';\n\ndeclare global {\n interface Window {\n /**\n * Defined by the UET snippet before `bat.js` loads (as a plain array), so pushes made early\n * are queued rather than lost. Undefined only where the snippet was never installed.\n */\n uetq?: UETQ;\n }\n}\n\nconst metrics = ['CLS', 'FCP', 'FID', 'INP', 'LCP', 'TTFB'];\n\n/** Drop `undefined` fields: bat.js validates every key it receives, and `undefined` is a value. */\n// oxlint-disable-next-line @typescript-eslint/no-explicit-any\nfunction clean(value: unknown): any {\n return JSON.parse(JSON.stringify(value));\n}\n\nfunction isLocal(): boolean {\n return window.location.host.includes('127.0.0.1') || window.location.host.includes('localhost');\n}\n\n/**\n * Forward an internal track event to the Microsoft Advertising UET tag.\n *\n * `eventId` is reused as UET's `event_id` so the browser event deduplicates against the same\n * conversion sent through the Conversions API (`sendMicrosoftEvents`). The action name is the\n * internal name on both channels — see `mapUETEvent`.\n *\n * `page_view` is not forwarded: bat.js turns that action into its page-load beacon, which the\n * snippet already fires on load and, with `enableAutoSpaTracking: true` (the setting in\n * Microsoft's generated snippet), on every history change — forwarding the SDK's own page views\n * would report each page twice. The tag owns page loads; this tracker owns everything else.\n */\nexport function sendUETEvent<T extends EventName>(\n name: TrackName<T>,\n properties?: TrackProperties<T>,\n eventId?: string\n) {\n if (typeof window === 'undefined' || !window.uetq) {\n console.warn('uetq has not been initialized');\n return;\n }\n if (metrics.includes(name) || name === 'page_view') return;\n if (isLocal()) return;\n\n const [action, params] = mapUETEvent(name, properties, eventId);\n window.uetq.push('event', action, clean(params));\n}\n\n/**\n * Enhanced conversions: hand the tag the visitor's email and phone so Microsoft can match a\n * conversion to an ad click without a third-party cookie. A `set` applies to every event fired\n * from the page afterwards, `pageLoad` included, and the tag fires a `pid` beacon on its own\n * when the page load already went out. Values are sent raw — the tag normalizes and SHA-256\n * hashes them in the browser (bat.js `validatePid`) before anything leaves the page.\n *\n * A setter itself, like `setGAUser`, rather than a factory like `setFBUser(pixelId)`: UET is\n * bound to its tag by the snippet and `set` addresses whichever tag drains `uetq`, so there is\n * nothing to bind first.\n */\nexport function setUETUser({ user_id, user_data }: UpdateVisitorDTO) {\n if (typeof window === 'undefined' || !window.uetq) {\n console.warn('uetq has not been initialized');\n return;\n }\n\n // A signed-in user is also the moment the ID Sync pixel can carry a `UID` — see `syncUETVisitor`.\n if (user_id) void syncUETVisitor(user_id);\n\n const em = getFirst(user_data?.email);\n const ph = getFirst(user_data?.phone_number);\n if (!em && !ph) return;\n\n window.uetq.push('set', { pid: clean({ em, ph }) });\n}\n\n/**\n * UET consent mode. Push `default` before the tag loads (the tag processes `consent` early, ahead\n * of the queued events) and `update` when the visitor decides. With `ad_storage: 'denied'` the\n * tag neither writes its cookies nor reads the `msclkid` from the URL.\n * https://help.ads.microsoft.com/#apex/ads/en/60119/1\n */\nexport function setUETConsent(mode: 'default' | 'update', consent: UETConsent) {\n if (typeof window === 'undefined') return;\n window.uetq ??= [] as unknown as UETQ;\n window.uetq.push('consent', mode, consent);\n}\n\n/**\n * The Conversions API's client-side ID Sync pixel: ties the visitor id sent server-side as\n * `anonymousId` to Microsoft's own identifier, so server events can be attributed across\n * contexts (view-through, audiences). Microsoft requires it for remarketing built from CAPI\n * events and recommends it for measurement quality; fire it at least once per session, ideally\n * on the first page view. A pixel rather than a fetch on purpose: Microsoft must observe the\n * browser context of the sync, which a server-side call cannot carry.\n *\n * `customerId` is the Microsoft Advertising customer id (`cid` in the UI's URLs) — not the UET\n * tag id. `visitorId` is the SDK visitor id (`visitorId()`), which is what\n * `sendMicrosoftEvents` sends as `anonymousId`; Microsoft requires the two to be equal.\n * `userId` is the raw signed-in user id: it is SHA-256 hashed here, exactly as the server sender\n * hashes it into `externalId`, so the pair matches as well. Hashing is asynchronous, so the\n * pixel fires once the digest resolves; where SubtleCrypto is unavailable (an insecure context)\n * the pixel goes out without `UID` rather than with a raw id.\n * https://learn.microsoft.com/en-us/advertising/guides/uet-conversion-api-integration#id-sync-and-why-it-matters\n */\n/**\n * The Microsoft Advertising customer id the ID Sync pixel reports to, set once by the framework\n * `Analytics` component's `uetCustomerId` prop (or `configureUET`). Unset, nothing syncs.\n */\nlet uetCustomerId: string | undefined;\n\nexport function configureUET(options: { customerId?: string | number }) {\n uetCustomerId = options.customerId === undefined ? undefined : String(options.customerId);\n}\n\n/**\n * Sync the SDK visitor — the `anonymousId` of the events the server sends to the Conversions\n * API — with Microsoft, plus the user's `UID` when one is known. The `Analytics` components run\n * it on mount so every visit syncs at least once, anonymous visitors included (they are who\n * remarketing audiences are built from), and `setUETUser` runs it again on sign-in.\n */\nexport async function syncUETVisitor(userId?: string): Promise<void> {\n if (!uetCustomerId) return;\n sendUETIdSync({ customerId: uetCustomerId, visitorId: visitorId(), userId });\n}\n\nexport function sendUETIdSync(options: {\n customerId: string | number;\n visitorId: string;\n /** The signed-in user's id, raw; hashed before it leaves the page. */\n userId?: string;\n}) {\n if (typeof window === 'undefined' || typeof Image === 'undefined') return;\n if (isLocal()) return;\n\n const fire = (uid?: string) => {\n const params = new URLSearchParams({\n Red3: `BACID_${options.customerId}`,\n VID: options.visitorId,\n });\n if (uid) params.set('UID', uid);\n new Image().src = `https://c.bing.com/c.gif?${params.toString()}`;\n };\n\n if (!options.userId) {\n fire();\n return;\n }\n sha256(options.userId).then(fire, () => fire());\n}\n"],"mappings":";;;;;AAiBA,MAAM,UAAU;CAAC;CAAO;CAAO;CAAO;CAAO;CAAO;AAAM;;AAI1D,SAAS,MAAM,OAAqB;CAClC,OAAO,KAAK,MAAM,KAAK,UAAU,KAAK,CAAC;AACzC;AAEA,SAAS,UAAmB;CAC1B,OAAO,OAAO,SAAS,KAAK,SAAS,WAAW,KAAK,OAAO,SAAS,KAAK,SAAS,WAAW;AAChG;;;;;;;;;;;;;AAcA,SAAgB,aACd,MACA,YACA,SACA;CACA,IAAI,OAAO,WAAW,eAAe,CAAC,OAAO,MAAM;EACjD,QAAQ,KAAK,+BAA+B;EAC5C;CACF;CACA,IAAI,QAAQ,SAAS,IAAI,KAAK,SAAS,aAAa;CACpD,IAAI,QAAQ,GAAG;CAEf,MAAM,CAAC,QAAQ,UAAU,YAAY,MAAM,YAAY,OAAO;CAC9D,OAAO,KAAK,KAAK,SAAS,QAAQ,MAAM,MAAM,CAAC;AACjD;;;;;;;;;;;;AAaA,SAAgB,WAAW,EAAE,SAAS,aAA+B;CACnE,IAAI,OAAO,WAAW,eAAe,CAAC,OAAO,MAAM;EACjD,QAAQ,KAAK,+BAA+B;EAC5C;CACF;CAGA,IAAI,SAAS,eAAoB,OAAO;CAExC,MAAM,KAAK,SAAS,WAAW,KAAK;CACpC,MAAM,KAAK,SAAS,WAAW,YAAY;CAC3C,IAAI,CAAC,MAAM,CAAC,IAAI;CAEhB,OAAO,KAAK,KAAK,OAAO,EAAE,KAAK,MAAM;EAAE;EAAI;CAAG,CAAC,EAAE,CAAC;AACpD;;;;;;;AAQA,SAAgB,cAAc,MAA4B,SAAqB;CAC7E,IAAI,OAAO,WAAW,aAAa;CACnC,OAAO,SAAS,CAAC;CACjB,OAAO,KAAK,KAAK,WAAW,MAAM,OAAO;AAC3C;;;;;;;;;;;;;;;;;;;;;;AAuBA,IAAI;AAEJ,SAAgB,aAAa,SAA2C;CACtE,gBAAgB,QAAQ,eAAe,KAAA,IAAY,KAAA,IAAY,OAAO,QAAQ,UAAU;AAC1F;;;;;;;AAQA,eAAsB,eAAe,QAAgC;CACnE,IAAI,CAAC,eAAe;CACpB,cAAc;EAAE,YAAY;EAAe,WAAW,UAAU;EAAG;CAAO,CAAC;AAC7E;AAEA,SAAgB,cAAc,SAK3B;CACD,IAAI,OAAO,WAAW,eAAe,OAAO,UAAU,aAAa;CACnE,IAAI,QAAQ,GAAG;CAEf,MAAM,QAAQ,QAAiB;EAC7B,MAAM,SAAS,IAAI,gBAAgB;GACjC,MAAM,SAAS,QAAQ;GACvB,KAAK,QAAQ;EACf,CAAC;EACD,IAAI,KAAK,OAAO,IAAI,OAAO,GAAG;EAC9B,IAAI,MAAM,CAAC,CAAC,MAAM,4BAA4B,OAAO,SAAS;CAChE;CAEA,IAAI,CAAC,QAAQ,QAAQ;EACnB,KAAK;EACL;CACF;CACA,OAAO,QAAQ,MAAM,CAAC,CAAC,KAAK,YAAY,KAAK,CAAC;AAChD"}
@@ -2,7 +2,7 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_setup_index = require("../setup/index.cjs");
3
3
  const require_setup_session = require("../setup/session.cjs");
4
4
  const require_third_parties_ignored_events = require("../third-parties/ignored-events.cjs");
5
- const require_visitor_index = require("../visitor/index.cjs");
5
+ const require_visitor_id = require("../visitor/id.cjs");
6
6
  let _shware_utils = require("@shware/utils");
7
7
  //#region src/track/index.ts
8
8
  const defaultOptions = {};
@@ -97,7 +97,7 @@ async function flush() {
97
97
  let events = [];
98
98
  try {
99
99
  await getTokenBucket().removeTokens();
100
- const visitor_id = require_visitor_index.visitorId();
100
+ const visitor_id = require_visitor_id.visitorId();
101
101
  const headers = await require_setup_index.config.getHeaders();
102
102
  await Promise.all(list.map((event) => event.tags));
103
103
  events = list.splice(0);
@@ -193,7 +193,7 @@ function beacon(dto) {
193
193
  */
194
194
  function sendPendingEvents() {
195
195
  if (list.length === 0) return;
196
- const visitor_id = require_visitor_index.visitorId();
196
+ const visitor_id = require_visitor_id.visitorId();
197
197
  if (!beacon(list.map((event) => ({
198
198
  name: event.name,
199
199
  properties: event.properties,
@@ -214,7 +214,7 @@ function sendPendingEvents() {
214
214
  list.splice(0).forEach((event) => event.options.onSucceed?.(void 0));
215
215
  }
216
216
  function sendBeacon(name, properties) {
217
- const visitor_id = require_visitor_index.visitorId();
217
+ const visitor_id = require_visitor_id.visitorId();
218
218
  const session_id = require_setup_session.getSession().extend();
219
219
  if (!session_id) return;
220
220
  if (beacon([{
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","names":["TokenBucket","config","cache","getSession","visitorId","fetch","IGNORED_EVENTS"],"sources":["../../src/track/index.ts"],"sourcesContent":["import { TokenBucket, fetch } from '@shware/utils';\nimport type { CreateTrackEventDTO } from '../schema/index';\nimport { cache, config } from '../setup/index';\nimport { getSession } from '../setup/session';\nimport { IGNORED_EVENTS } from '../third-parties/ignored-events';\nimport { visitorId } from '../visitor/index';\nimport type { EventName, TrackEventResponse, TrackName, TrackProperties, TrackTags } from './types';\n\nexport interface TrackOptions {\n enableThirdPartyTracking?: boolean;\n onSucceed?: (response?: TrackEventResponse[number]) => void;\n onError?: (error: unknown) => void;\n}\n\nconst defaultOptions: TrackOptions = {};\n\nlet tokenBucket: TokenBucket | undefined;\n\n/**\n * The rate limiter, built on the first send rather than at module scope: its\n * constructor starts a refill `setInterval`, and Cloudflare Workers refuse to\n * set a timer outside a request handler for the same reason they refuse to\n * generate random values there — see `setup/session.ts`.\n */\nfunction getTokenBucket() {\n return (tokenBucket ??= new TokenBucket({ rate: 1, capacity: 20, requested: 2 }));\n}\n\ntype Item = {\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n name: TrackName<any>;\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n properties: TrackProperties<any>;\n tags: Promise<TrackTags>;\n /** `tags` once settled: a page being hidden can await nothing, and sends what it has. */\n settled?: TrackTags;\n /** Decided when the event happens, as GA4 and PostHog decide it, never when it is sent. */\n session_id: string;\n timestamp: string;\n options: TrackOptions;\n};\n\n/**\n * Tags belong to the moment the event happened, not to the moment its batch goes out: a queued\n * event waits up to `delay` ms, and a single page app can navigate in that window, which would\n * stamp every pending event with the URL of the page the user has already left.\n *\n * The promise then sits in the queue with nothing awaiting it, so a failure has to be absorbed\n * here — an unhandled rejection would surface as a global error long before `sendEvents` could\n * catch it. Falling back to the last built tags keeps the event, minus whatever changed since.\n */\nasync function captureTags(): Promise<TrackTags> {\n try {\n return await config.getTags();\n } catch (e: unknown) {\n if (e instanceof Error) console.log(e.message);\n return cache.tags ?? {};\n }\n}\n\n/**\n * The events not yet sent, and the only place they are: a batch leaves it at the moment it is\n * handed to `fetch` or to a beacon, never earlier. So when the page is hidden or left,\n * everything still unsent is right here for `sendPendingEvents`.\n */\nconst list: Item[] = [];\nconst batchSize = 10;\nconst delay = 2000;\nlet timer: ReturnType<typeof setTimeout> | null = null;\n\n/**\n * Queues an event, preceded by a `session_start` when it is the one that opens a session.\n *\n * The session is decided here, when the event happens, as GA4 and PostHog decide it: the\n * `session_start` then sits in the queue right before the event that opened the session, so\n * wherever that event goes — a batch, a beacon, a retry — its announcement has gone ahead of it.\n * It carries that event's tags and time, as GA4 derives it from the hit that started the\n * session: the landing page is where the session began, whatever the page has navigated to by\n * the time the batch goes out.\n */\nfunction enqueue(name: Item['name'], properties: Item['properties'], options: TrackOptions): Item {\n const timestamp = new Date().toISOString();\n const tags = captureTags();\n const { id: session_id, started } = getSession().touch(Date.parse(timestamp));\n const item = (fields: Pick<Item, 'name' | 'properties' | 'options'>): Item => {\n const result: Item = { ...fields, tags, timestamp, session_id };\n void tags.then((settled) => (result.settled = settled));\n return result;\n };\n if (started) {\n const options = { enableThirdPartyTracking: false };\n list.push(item({ name: 'session_start', properties: {}, options }));\n }\n const event = item({ name, properties, options });\n list.push(event);\n return event;\n}\n\n/**\n * Sends the queue. Everything that has to be awaited is awaited first, and only then are the\n * events taken off the queue and handed to `fetch` in the same synchronous step — so there is\n * no moment at which an event is neither queued nor sent.\n */\nasync function flush() {\n if (timer) {\n clearTimeout(timer);\n timer = null;\n }\n if (list.length === 0) return;\n\n let events: Item[] = [];\n try {\n await getTokenBucket().removeTokens();\n const visitor_id = visitorId();\n const headers = await config.getHeaders();\n // Settled before the events leave the queue; those queued meanwhile are a microtask away.\n await Promise.all(list.map((event) => event.tags));\n\n events = list.splice(0);\n if (events.length === 0) return;\n const dto: CreateTrackEventDTO = await Promise.all(\n events.map(async (event) => ({\n name: event.name,\n properties: event.properties,\n tags: await event.tags,\n visitor_id,\n session_id: event.session_id,\n platform: config.platform,\n environment: config.environment,\n timestamp: event.timestamp,\n }))\n );\n\n const body = JSON.stringify(dto);\n const response = await fetch(`${config.endpoint}/events`, {\n method: 'POST',\n credentials: 'include',\n // keepalive lets the request survive the page being unloaded mid-flight, within a 64KB\n // budget shared by every such request of the page; a body that could not fit goes as a\n // plain POST rather than failing outright.\n keepalive: body.length < 60_000,\n headers,\n body,\n });\n\n if (!response.ok) {\n throw new Error(`Failed to send track event: ${response.status} ${await response.text()}`);\n }\n\n const data = (await response.json()) as TrackEventResponse;\n\n events.forEach((event, index) => {\n const eventId = data.at(index)?.id;\n event.options.onSucceed?.(eventId ? { id: eventId } : undefined);\n // An explicit false, not falsiness: a caller passing `{ onSucceed }` replaces the options\n // object wholesale, and leaving the flag out must not silently switch forwarding off.\n if (event.options.enableThirdPartyTracking === false || IGNORED_EVENTS.includes(event.name)) {\n return;\n }\n config.thirdPartyTrackers.forEach((tracker) => {\n try {\n tracker(event.name, event.properties, eventId);\n } catch (e: unknown) {\n // A third-party script does not get to take the rest of the batch with it.\n if (e instanceof Error) console.log(e.message);\n }\n });\n });\n } catch (e: unknown) {\n if (e instanceof Error) console.log(e.message);\n // `fetch` has already retried the transient failures by the time a batch fails here; what\n // remains is a batch the server rejected outright — one invalid event fails the whole batch\n // — or a network that stayed down past the retries. The other events are reported lost;\n // `session_start` alone goes back on the queue for the next send, because it is the\n // session's only attribution record: a session without one has no channel, and its every\n // later event drops out of an attribution join.\n const starts = events.filter((event) => event.name === 'session_start');\n list.unshift(...starts);\n events\n .filter((event) => event.name !== 'session_start')\n .forEach((event) => event.options.onError?.(e));\n }\n}\n\nexport function track<T extends EventName = EventName>(\n name: TrackName<T>,\n properties?: TrackProperties<T>,\n options: TrackOptions = defaultOptions\n) {\n enqueue(name, properties, options);\n if (list.length >= batchSize) {\n void flush();\n return;\n }\n if (timer) clearTimeout(timer);\n timer = setTimeout(() => void flush(), delay);\n}\n\n/**\n * Sends the event now, with whatever is queued, and resolves once it has been sent or lost — or,\n * when the send could not start (its headers or rate limit threw), at once, the event left queued\n * for the next send. It never rejects: tracking must not fail the step that awaits it.\n */\nexport async function trackAsync<T extends EventName = EventName>(\n name: TrackName<T>,\n properties?: TrackProperties<T>,\n options: TrackOptions = defaultOptions\n) {\n let settle!: () => void;\n const settled = new Promise<void>((resolve) => {\n settle = resolve;\n });\n const event = enqueue(name, properties, {\n ...options,\n onSucceed: (response) => {\n options.onSucceed?.(response);\n settle();\n },\n onError: (error) => {\n options.onError?.(error);\n settle();\n },\n });\n await flush();\n // Still queued: this send failed before taking it. It waits for the next send, but the caller,\n // who may be holding a navigation on this, does not.\n if (list.includes(event)) return;\n await settled;\n}\n\nfunction beacon(dto: CreateTrackEventDTO): boolean {\n // Not every runtime with a `navigator` has it: React Native has neither beacons nor pages.\n if (typeof navigator === 'undefined' || !('sendBeacon' in navigator)) return false;\n // text/plain keeps a cross-origin beacon a CORS simple request: as application/json it needs a\n // preflight, which a page being closed often cannot complete, and the beacon is then dropped.\n // The server reads it as JSON (`zBeaconJson` in @shware/http).\n const blob = new Blob([JSON.stringify(dto)], { type: 'text/plain;charset=UTF-8' });\n return navigator.sendBeacon(`${config.endpoint}/events`, blob);\n}\n\n/**\n * Sends the queue by beacon, now: for when the page is hidden or left, the last moment its\n * script is sure to run — what PostHog's `requestQueue.unload()` and GA4 do on the same events.\n * Call it before the engagement beacon: a new session's `session_start` is in this queue.\n *\n * Nothing can be awaited here, so the events go with the tags they have settled on (the last\n * built ones otherwise), and without the server's ids: their third-party trackers are not fired,\n * as an id-less browser event could not be deduplicated against the server's. The visitor id is\n * local (`visitorId`), so a first visit left within a second is sent like any other; when no\n * beacon can take them — no `sendBeacon`, a refused body — they stay queued for the usual send.\n */\nexport function sendPendingEvents() {\n if (list.length === 0) return;\n const visitor_id = visitorId();\n const dto: CreateTrackEventDTO = list.map((event) => ({\n name: event.name,\n properties: event.properties,\n tags: event.settled ?? cache.tags ?? {},\n visitor_id,\n session_id: event.session_id,\n platform: config.platform,\n environment: config.environment,\n timestamp: event.timestamp,\n }));\n if (!beacon(dto)) {\n void flush();\n return;\n }\n if (timer) {\n clearTimeout(timer);\n timer = null;\n }\n list.splice(0).forEach((event) => event.options.onSucceed?.(undefined));\n}\n\nexport function sendBeacon<T extends EventName = EventName>(\n name: TrackName<T>,\n properties?: TrackProperties<T>\n) {\n const visitor_id = visitorId();\n // No stored session means no event was ever queued from this storage, so there is no session\n // to report for — and one started here could never be announced. See `Session.extend`.\n const session_id = getSession().extend();\n if (!session_id) return;\n\n const dto: CreateTrackEventDTO = [\n {\n name,\n properties,\n // Tags are worth less than the event carrying them: an empty set still reports the\n // engagement, and every field in `tagsSchema` is optional.\n tags: cache.tags ?? {},\n visitor_id,\n session_id,\n platform: config.platform,\n environment: config.environment,\n timestamp: new Date().toISOString(),\n },\n ];\n if (beacon(dto)) return;\n console.warn('Failed to send beacon', name, properties);\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.cjs","names":["TokenBucket","config","cache","getSession","visitorId","fetch","IGNORED_EVENTS"],"sources":["../../src/track/index.ts"],"sourcesContent":["import { TokenBucket, fetch } from '@shware/utils';\nimport type { CreateTrackEventDTO } from '../schema/index';\nimport { cache, config } from '../setup/index';\nimport { getSession } from '../setup/session';\nimport { IGNORED_EVENTS } from '../third-parties/ignored-events';\nimport { visitorId } from '../visitor/id';\nimport type { EventName, TrackEventResponse, TrackName, TrackProperties, TrackTags } from './types';\n\nexport interface TrackOptions {\n enableThirdPartyTracking?: boolean;\n onSucceed?: (response?: TrackEventResponse[number]) => void;\n onError?: (error: unknown) => void;\n}\n\nconst defaultOptions: TrackOptions = {};\n\nlet tokenBucket: TokenBucket | undefined;\n\n/**\n * The rate limiter, built on the first send rather than at module scope: its\n * constructor starts a refill `setInterval`, and Cloudflare Workers refuse to\n * set a timer outside a request handler for the same reason they refuse to\n * generate random values there — see `setup/session.ts`.\n */\nfunction getTokenBucket() {\n return (tokenBucket ??= new TokenBucket({ rate: 1, capacity: 20, requested: 2 }));\n}\n\ntype Item = {\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n name: TrackName<any>;\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n properties: TrackProperties<any>;\n tags: Promise<TrackTags>;\n /** `tags` once settled: a page being hidden can await nothing, and sends what it has. */\n settled?: TrackTags;\n /** Decided when the event happens, as GA4 and PostHog decide it, never when it is sent. */\n session_id: string;\n timestamp: string;\n options: TrackOptions;\n};\n\n/**\n * Tags belong to the moment the event happened, not to the moment its batch goes out: a queued\n * event waits up to `delay` ms, and a single page app can navigate in that window, which would\n * stamp every pending event with the URL of the page the user has already left.\n *\n * The promise then sits in the queue with nothing awaiting it, so a failure has to be absorbed\n * here — an unhandled rejection would surface as a global error long before `sendEvents` could\n * catch it. Falling back to the last built tags keeps the event, minus whatever changed since.\n */\nasync function captureTags(): Promise<TrackTags> {\n try {\n return await config.getTags();\n } catch (e: unknown) {\n if (e instanceof Error) console.log(e.message);\n return cache.tags ?? {};\n }\n}\n\n/**\n * The events not yet sent, and the only place they are: a batch leaves it at the moment it is\n * handed to `fetch` or to a beacon, never earlier. So when the page is hidden or left,\n * everything still unsent is right here for `sendPendingEvents`.\n */\nconst list: Item[] = [];\nconst batchSize = 10;\nconst delay = 2000;\nlet timer: ReturnType<typeof setTimeout> | null = null;\n\n/**\n * Queues an event, preceded by a `session_start` when it is the one that opens a session.\n *\n * The session is decided here, when the event happens, as GA4 and PostHog decide it: the\n * `session_start` then sits in the queue right before the event that opened the session, so\n * wherever that event goes — a batch, a beacon, a retry — its announcement has gone ahead of it.\n * It carries that event's tags and time, as GA4 derives it from the hit that started the\n * session: the landing page is where the session began, whatever the page has navigated to by\n * the time the batch goes out.\n */\nfunction enqueue(name: Item['name'], properties: Item['properties'], options: TrackOptions): Item {\n const timestamp = new Date().toISOString();\n const tags = captureTags();\n const { id: session_id, started } = getSession().touch(Date.parse(timestamp));\n const item = (fields: Pick<Item, 'name' | 'properties' | 'options'>): Item => {\n const result: Item = { ...fields, tags, timestamp, session_id };\n void tags.then((settled) => (result.settled = settled));\n return result;\n };\n if (started) {\n const options = { enableThirdPartyTracking: false };\n list.push(item({ name: 'session_start', properties: {}, options }));\n }\n const event = item({ name, properties, options });\n list.push(event);\n return event;\n}\n\n/**\n * Sends the queue. Everything that has to be awaited is awaited first, and only then are the\n * events taken off the queue and handed to `fetch` in the same synchronous step — so there is\n * no moment at which an event is neither queued nor sent.\n */\nasync function flush() {\n if (timer) {\n clearTimeout(timer);\n timer = null;\n }\n if (list.length === 0) return;\n\n let events: Item[] = [];\n try {\n await getTokenBucket().removeTokens();\n const visitor_id = visitorId();\n const headers = await config.getHeaders();\n // Settled before the events leave the queue; those queued meanwhile are a microtask away.\n await Promise.all(list.map((event) => event.tags));\n\n events = list.splice(0);\n if (events.length === 0) return;\n const dto: CreateTrackEventDTO = await Promise.all(\n events.map(async (event) => ({\n name: event.name,\n properties: event.properties,\n tags: await event.tags,\n visitor_id,\n session_id: event.session_id,\n platform: config.platform,\n environment: config.environment,\n timestamp: event.timestamp,\n }))\n );\n\n const body = JSON.stringify(dto);\n const response = await fetch(`${config.endpoint}/events`, {\n method: 'POST',\n credentials: 'include',\n // keepalive lets the request survive the page being unloaded mid-flight, within a 64KB\n // budget shared by every such request of the page; a body that could not fit goes as a\n // plain POST rather than failing outright.\n keepalive: body.length < 60_000,\n headers,\n body,\n });\n\n if (!response.ok) {\n throw new Error(`Failed to send track event: ${response.status} ${await response.text()}`);\n }\n\n const data = (await response.json()) as TrackEventResponse;\n\n events.forEach((event, index) => {\n const eventId = data.at(index)?.id;\n event.options.onSucceed?.(eventId ? { id: eventId } : undefined);\n // An explicit false, not falsiness: a caller passing `{ onSucceed }` replaces the options\n // object wholesale, and leaving the flag out must not silently switch forwarding off.\n if (event.options.enableThirdPartyTracking === false || IGNORED_EVENTS.includes(event.name)) {\n return;\n }\n config.thirdPartyTrackers.forEach((tracker) => {\n try {\n tracker(event.name, event.properties, eventId);\n } catch (e: unknown) {\n // A third-party script does not get to take the rest of the batch with it.\n if (e instanceof Error) console.log(e.message);\n }\n });\n });\n } catch (e: unknown) {\n if (e instanceof Error) console.log(e.message);\n // `fetch` has already retried the transient failures by the time a batch fails here; what\n // remains is a batch the server rejected outright — one invalid event fails the whole batch\n // — or a network that stayed down past the retries. The other events are reported lost;\n // `session_start` alone goes back on the queue for the next send, because it is the\n // session's only attribution record: a session without one has no channel, and its every\n // later event drops out of an attribution join.\n const starts = events.filter((event) => event.name === 'session_start');\n list.unshift(...starts);\n events\n .filter((event) => event.name !== 'session_start')\n .forEach((event) => event.options.onError?.(e));\n }\n}\n\nexport function track<T extends EventName = EventName>(\n name: TrackName<T>,\n properties?: TrackProperties<T>,\n options: TrackOptions = defaultOptions\n) {\n enqueue(name, properties, options);\n if (list.length >= batchSize) {\n void flush();\n return;\n }\n if (timer) clearTimeout(timer);\n timer = setTimeout(() => void flush(), delay);\n}\n\n/**\n * Sends the event now, with whatever is queued, and resolves once it has been sent or lost — or,\n * when the send could not start (its headers or rate limit threw), at once, the event left queued\n * for the next send. It never rejects: tracking must not fail the step that awaits it.\n */\nexport async function trackAsync<T extends EventName = EventName>(\n name: TrackName<T>,\n properties?: TrackProperties<T>,\n options: TrackOptions = defaultOptions\n) {\n let settle!: () => void;\n const settled = new Promise<void>((resolve) => {\n settle = resolve;\n });\n const event = enqueue(name, properties, {\n ...options,\n onSucceed: (response) => {\n options.onSucceed?.(response);\n settle();\n },\n onError: (error) => {\n options.onError?.(error);\n settle();\n },\n });\n await flush();\n // Still queued: this send failed before taking it. It waits for the next send, but the caller,\n // who may be holding a navigation on this, does not.\n if (list.includes(event)) return;\n await settled;\n}\n\nfunction beacon(dto: CreateTrackEventDTO): boolean {\n // Not every runtime with a `navigator` has it: React Native has neither beacons nor pages.\n if (typeof navigator === 'undefined' || !('sendBeacon' in navigator)) return false;\n // text/plain keeps a cross-origin beacon a CORS simple request: as application/json it needs a\n // preflight, which a page being closed often cannot complete, and the beacon is then dropped.\n // The server reads it as JSON (`zBeaconJson` in @shware/http).\n const blob = new Blob([JSON.stringify(dto)], { type: 'text/plain;charset=UTF-8' });\n return navigator.sendBeacon(`${config.endpoint}/events`, blob);\n}\n\n/**\n * Sends the queue by beacon, now: for when the page is hidden or left, the last moment its\n * script is sure to run — what PostHog's `requestQueue.unload()` and GA4 do on the same events.\n * Call it before the engagement beacon: a new session's `session_start` is in this queue.\n *\n * Nothing can be awaited here, so the events go with the tags they have settled on (the last\n * built ones otherwise), and without the server's ids: their third-party trackers are not fired,\n * as an id-less browser event could not be deduplicated against the server's. The visitor id is\n * local (`visitorId`), so a first visit left within a second is sent like any other; when no\n * beacon can take them — no `sendBeacon`, a refused body — they stay queued for the usual send.\n */\nexport function sendPendingEvents() {\n if (list.length === 0) return;\n const visitor_id = visitorId();\n const dto: CreateTrackEventDTO = list.map((event) => ({\n name: event.name,\n properties: event.properties,\n tags: event.settled ?? cache.tags ?? {},\n visitor_id,\n session_id: event.session_id,\n platform: config.platform,\n environment: config.environment,\n timestamp: event.timestamp,\n }));\n if (!beacon(dto)) {\n void flush();\n return;\n }\n if (timer) {\n clearTimeout(timer);\n timer = null;\n }\n list.splice(0).forEach((event) => event.options.onSucceed?.(undefined));\n}\n\nexport function sendBeacon<T extends EventName = EventName>(\n name: TrackName<T>,\n properties?: TrackProperties<T>\n) {\n const visitor_id = visitorId();\n // No stored session means no event was ever queued from this storage, so there is no session\n // to report for — and one started here could never be announced. See `Session.extend`.\n const session_id = getSession().extend();\n if (!session_id) return;\n\n const dto: CreateTrackEventDTO = [\n {\n name,\n properties,\n // Tags are worth less than the event carrying them: an empty set still reports the\n // engagement, and every field in `tagsSchema` is optional.\n tags: cache.tags ?? {},\n visitor_id,\n session_id,\n platform: config.platform,\n environment: config.environment,\n timestamp: new Date().toISOString(),\n },\n ];\n if (beacon(dto)) return;\n console.warn('Failed to send beacon', name, properties);\n}\n"],"mappings":"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"}
@@ -1,7 +1,7 @@
1
1
  import { cache, config } from "../setup/index.mjs";
2
2
  import { getSession } from "../setup/session.mjs";
3
3
  import { IGNORED_EVENTS } from "../third-parties/ignored-events.mjs";
4
- import { visitorId } from "../visitor/index.mjs";
4
+ import { visitorId } from "../visitor/id.mjs";
5
5
  import { TokenBucket, fetch } from "@shware/utils";
6
6
  //#region src/track/index.ts
7
7
  const defaultOptions = {};
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../../src/track/index.ts"],"sourcesContent":["import { TokenBucket, fetch } from '@shware/utils';\nimport type { CreateTrackEventDTO } from '../schema/index';\nimport { cache, config } from '../setup/index';\nimport { getSession } from '../setup/session';\nimport { IGNORED_EVENTS } from '../third-parties/ignored-events';\nimport { visitorId } from '../visitor/index';\nimport type { EventName, TrackEventResponse, TrackName, TrackProperties, TrackTags } from './types';\n\nexport interface TrackOptions {\n enableThirdPartyTracking?: boolean;\n onSucceed?: (response?: TrackEventResponse[number]) => void;\n onError?: (error: unknown) => void;\n}\n\nconst defaultOptions: TrackOptions = {};\n\nlet tokenBucket: TokenBucket | undefined;\n\n/**\n * The rate limiter, built on the first send rather than at module scope: its\n * constructor starts a refill `setInterval`, and Cloudflare Workers refuse to\n * set a timer outside a request handler for the same reason they refuse to\n * generate random values there — see `setup/session.ts`.\n */\nfunction getTokenBucket() {\n return (tokenBucket ??= new TokenBucket({ rate: 1, capacity: 20, requested: 2 }));\n}\n\ntype Item = {\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n name: TrackName<any>;\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n properties: TrackProperties<any>;\n tags: Promise<TrackTags>;\n /** `tags` once settled: a page being hidden can await nothing, and sends what it has. */\n settled?: TrackTags;\n /** Decided when the event happens, as GA4 and PostHog decide it, never when it is sent. */\n session_id: string;\n timestamp: string;\n options: TrackOptions;\n};\n\n/**\n * Tags belong to the moment the event happened, not to the moment its batch goes out: a queued\n * event waits up to `delay` ms, and a single page app can navigate in that window, which would\n * stamp every pending event with the URL of the page the user has already left.\n *\n * The promise then sits in the queue with nothing awaiting it, so a failure has to be absorbed\n * here — an unhandled rejection would surface as a global error long before `sendEvents` could\n * catch it. Falling back to the last built tags keeps the event, minus whatever changed since.\n */\nasync function captureTags(): Promise<TrackTags> {\n try {\n return await config.getTags();\n } catch (e: unknown) {\n if (e instanceof Error) console.log(e.message);\n return cache.tags ?? {};\n }\n}\n\n/**\n * The events not yet sent, and the only place they are: a batch leaves it at the moment it is\n * handed to `fetch` or to a beacon, never earlier. So when the page is hidden or left,\n * everything still unsent is right here for `sendPendingEvents`.\n */\nconst list: Item[] = [];\nconst batchSize = 10;\nconst delay = 2000;\nlet timer: ReturnType<typeof setTimeout> | null = null;\n\n/**\n * Queues an event, preceded by a `session_start` when it is the one that opens a session.\n *\n * The session is decided here, when the event happens, as GA4 and PostHog decide it: the\n * `session_start` then sits in the queue right before the event that opened the session, so\n * wherever that event goes — a batch, a beacon, a retry — its announcement has gone ahead of it.\n * It carries that event's tags and time, as GA4 derives it from the hit that started the\n * session: the landing page is where the session began, whatever the page has navigated to by\n * the time the batch goes out.\n */\nfunction enqueue(name: Item['name'], properties: Item['properties'], options: TrackOptions): Item {\n const timestamp = new Date().toISOString();\n const tags = captureTags();\n const { id: session_id, started } = getSession().touch(Date.parse(timestamp));\n const item = (fields: Pick<Item, 'name' | 'properties' | 'options'>): Item => {\n const result: Item = { ...fields, tags, timestamp, session_id };\n void tags.then((settled) => (result.settled = settled));\n return result;\n };\n if (started) {\n const options = { enableThirdPartyTracking: false };\n list.push(item({ name: 'session_start', properties: {}, options }));\n }\n const event = item({ name, properties, options });\n list.push(event);\n return event;\n}\n\n/**\n * Sends the queue. Everything that has to be awaited is awaited first, and only then are the\n * events taken off the queue and handed to `fetch` in the same synchronous step — so there is\n * no moment at which an event is neither queued nor sent.\n */\nasync function flush() {\n if (timer) {\n clearTimeout(timer);\n timer = null;\n }\n if (list.length === 0) return;\n\n let events: Item[] = [];\n try {\n await getTokenBucket().removeTokens();\n const visitor_id = visitorId();\n const headers = await config.getHeaders();\n // Settled before the events leave the queue; those queued meanwhile are a microtask away.\n await Promise.all(list.map((event) => event.tags));\n\n events = list.splice(0);\n if (events.length === 0) return;\n const dto: CreateTrackEventDTO = await Promise.all(\n events.map(async (event) => ({\n name: event.name,\n properties: event.properties,\n tags: await event.tags,\n visitor_id,\n session_id: event.session_id,\n platform: config.platform,\n environment: config.environment,\n timestamp: event.timestamp,\n }))\n );\n\n const body = JSON.stringify(dto);\n const response = await fetch(`${config.endpoint}/events`, {\n method: 'POST',\n credentials: 'include',\n // keepalive lets the request survive the page being unloaded mid-flight, within a 64KB\n // budget shared by every such request of the page; a body that could not fit goes as a\n // plain POST rather than failing outright.\n keepalive: body.length < 60_000,\n headers,\n body,\n });\n\n if (!response.ok) {\n throw new Error(`Failed to send track event: ${response.status} ${await response.text()}`);\n }\n\n const data = (await response.json()) as TrackEventResponse;\n\n events.forEach((event, index) => {\n const eventId = data.at(index)?.id;\n event.options.onSucceed?.(eventId ? { id: eventId } : undefined);\n // An explicit false, not falsiness: a caller passing `{ onSucceed }` replaces the options\n // object wholesale, and leaving the flag out must not silently switch forwarding off.\n if (event.options.enableThirdPartyTracking === false || IGNORED_EVENTS.includes(event.name)) {\n return;\n }\n config.thirdPartyTrackers.forEach((tracker) => {\n try {\n tracker(event.name, event.properties, eventId);\n } catch (e: unknown) {\n // A third-party script does not get to take the rest of the batch with it.\n if (e instanceof Error) console.log(e.message);\n }\n });\n });\n } catch (e: unknown) {\n if (e instanceof Error) console.log(e.message);\n // `fetch` has already retried the transient failures by the time a batch fails here; what\n // remains is a batch the server rejected outright — one invalid event fails the whole batch\n // — or a network that stayed down past the retries. The other events are reported lost;\n // `session_start` alone goes back on the queue for the next send, because it is the\n // session's only attribution record: a session without one has no channel, and its every\n // later event drops out of an attribution join.\n const starts = events.filter((event) => event.name === 'session_start');\n list.unshift(...starts);\n events\n .filter((event) => event.name !== 'session_start')\n .forEach((event) => event.options.onError?.(e));\n }\n}\n\nexport function track<T extends EventName = EventName>(\n name: TrackName<T>,\n properties?: TrackProperties<T>,\n options: TrackOptions = defaultOptions\n) {\n enqueue(name, properties, options);\n if (list.length >= batchSize) {\n void flush();\n return;\n }\n if (timer) clearTimeout(timer);\n timer = setTimeout(() => void flush(), delay);\n}\n\n/**\n * Sends the event now, with whatever is queued, and resolves once it has been sent or lost — or,\n * when the send could not start (its headers or rate limit threw), at once, the event left queued\n * for the next send. It never rejects: tracking must not fail the step that awaits it.\n */\nexport async function trackAsync<T extends EventName = EventName>(\n name: TrackName<T>,\n properties?: TrackProperties<T>,\n options: TrackOptions = defaultOptions\n) {\n let settle!: () => void;\n const settled = new Promise<void>((resolve) => {\n settle = resolve;\n });\n const event = enqueue(name, properties, {\n ...options,\n onSucceed: (response) => {\n options.onSucceed?.(response);\n settle();\n },\n onError: (error) => {\n options.onError?.(error);\n settle();\n },\n });\n await flush();\n // Still queued: this send failed before taking it. It waits for the next send, but the caller,\n // who may be holding a navigation on this, does not.\n if (list.includes(event)) return;\n await settled;\n}\n\nfunction beacon(dto: CreateTrackEventDTO): boolean {\n // Not every runtime with a `navigator` has it: React Native has neither beacons nor pages.\n if (typeof navigator === 'undefined' || !('sendBeacon' in navigator)) return false;\n // text/plain keeps a cross-origin beacon a CORS simple request: as application/json it needs a\n // preflight, which a page being closed often cannot complete, and the beacon is then dropped.\n // The server reads it as JSON (`zBeaconJson` in @shware/http).\n const blob = new Blob([JSON.stringify(dto)], { type: 'text/plain;charset=UTF-8' });\n return navigator.sendBeacon(`${config.endpoint}/events`, blob);\n}\n\n/**\n * Sends the queue by beacon, now: for when the page is hidden or left, the last moment its\n * script is sure to run — what PostHog's `requestQueue.unload()` and GA4 do on the same events.\n * Call it before the engagement beacon: a new session's `session_start` is in this queue.\n *\n * Nothing can be awaited here, so the events go with the tags they have settled on (the last\n * built ones otherwise), and without the server's ids: their third-party trackers are not fired,\n * as an id-less browser event could not be deduplicated against the server's. The visitor id is\n * local (`visitorId`), so a first visit left within a second is sent like any other; when no\n * beacon can take them — no `sendBeacon`, a refused body — they stay queued for the usual send.\n */\nexport function sendPendingEvents() {\n if (list.length === 0) return;\n const visitor_id = visitorId();\n const dto: CreateTrackEventDTO = list.map((event) => ({\n name: event.name,\n properties: event.properties,\n tags: event.settled ?? cache.tags ?? {},\n visitor_id,\n session_id: event.session_id,\n platform: config.platform,\n environment: config.environment,\n timestamp: event.timestamp,\n }));\n if (!beacon(dto)) {\n void flush();\n return;\n }\n if (timer) {\n clearTimeout(timer);\n timer = null;\n }\n list.splice(0).forEach((event) => event.options.onSucceed?.(undefined));\n}\n\nexport function sendBeacon<T extends EventName = EventName>(\n name: TrackName<T>,\n properties?: TrackProperties<T>\n) {\n const visitor_id = visitorId();\n // No stored session means no event was ever queued from this storage, so there is no session\n // to report for — and one started here could never be announced. See `Session.extend`.\n const session_id = getSession().extend();\n if (!session_id) return;\n\n const dto: CreateTrackEventDTO = [\n {\n name,\n properties,\n // Tags are worth less than the event carrying them: an empty set still reports the\n // engagement, and every field in `tagsSchema` is optional.\n tags: cache.tags ?? {},\n visitor_id,\n session_id,\n platform: config.platform,\n environment: config.environment,\n timestamp: new Date().toISOString(),\n },\n ];\n if (beacon(dto)) return;\n console.warn('Failed to send beacon', name, properties);\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../src/track/index.ts"],"sourcesContent":["import { TokenBucket, fetch } from '@shware/utils';\nimport type { CreateTrackEventDTO } from '../schema/index';\nimport { cache, config } from '../setup/index';\nimport { getSession } from '../setup/session';\nimport { IGNORED_EVENTS } from '../third-parties/ignored-events';\nimport { visitorId } from '../visitor/id';\nimport type { EventName, TrackEventResponse, TrackName, TrackProperties, TrackTags } from './types';\n\nexport interface TrackOptions {\n enableThirdPartyTracking?: boolean;\n onSucceed?: (response?: TrackEventResponse[number]) => void;\n onError?: (error: unknown) => void;\n}\n\nconst defaultOptions: TrackOptions = {};\n\nlet tokenBucket: TokenBucket | undefined;\n\n/**\n * The rate limiter, built on the first send rather than at module scope: its\n * constructor starts a refill `setInterval`, and Cloudflare Workers refuse to\n * set a timer outside a request handler for the same reason they refuse to\n * generate random values there — see `setup/session.ts`.\n */\nfunction getTokenBucket() {\n return (tokenBucket ??= new TokenBucket({ rate: 1, capacity: 20, requested: 2 }));\n}\n\ntype Item = {\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n name: TrackName<any>;\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n properties: TrackProperties<any>;\n tags: Promise<TrackTags>;\n /** `tags` once settled: a page being hidden can await nothing, and sends what it has. */\n settled?: TrackTags;\n /** Decided when the event happens, as GA4 and PostHog decide it, never when it is sent. */\n session_id: string;\n timestamp: string;\n options: TrackOptions;\n};\n\n/**\n * Tags belong to the moment the event happened, not to the moment its batch goes out: a queued\n * event waits up to `delay` ms, and a single page app can navigate in that window, which would\n * stamp every pending event with the URL of the page the user has already left.\n *\n * The promise then sits in the queue with nothing awaiting it, so a failure has to be absorbed\n * here — an unhandled rejection would surface as a global error long before `sendEvents` could\n * catch it. Falling back to the last built tags keeps the event, minus whatever changed since.\n */\nasync function captureTags(): Promise<TrackTags> {\n try {\n return await config.getTags();\n } catch (e: unknown) {\n if (e instanceof Error) console.log(e.message);\n return cache.tags ?? {};\n }\n}\n\n/**\n * The events not yet sent, and the only place they are: a batch leaves it at the moment it is\n * handed to `fetch` or to a beacon, never earlier. So when the page is hidden or left,\n * everything still unsent is right here for `sendPendingEvents`.\n */\nconst list: Item[] = [];\nconst batchSize = 10;\nconst delay = 2000;\nlet timer: ReturnType<typeof setTimeout> | null = null;\n\n/**\n * Queues an event, preceded by a `session_start` when it is the one that opens a session.\n *\n * The session is decided here, when the event happens, as GA4 and PostHog decide it: the\n * `session_start` then sits in the queue right before the event that opened the session, so\n * wherever that event goes — a batch, a beacon, a retry — its announcement has gone ahead of it.\n * It carries that event's tags and time, as GA4 derives it from the hit that started the\n * session: the landing page is where the session began, whatever the page has navigated to by\n * the time the batch goes out.\n */\nfunction enqueue(name: Item['name'], properties: Item['properties'], options: TrackOptions): Item {\n const timestamp = new Date().toISOString();\n const tags = captureTags();\n const { id: session_id, started } = getSession().touch(Date.parse(timestamp));\n const item = (fields: Pick<Item, 'name' | 'properties' | 'options'>): Item => {\n const result: Item = { ...fields, tags, timestamp, session_id };\n void tags.then((settled) => (result.settled = settled));\n return result;\n };\n if (started) {\n const options = { enableThirdPartyTracking: false };\n list.push(item({ name: 'session_start', properties: {}, options }));\n }\n const event = item({ name, properties, options });\n list.push(event);\n return event;\n}\n\n/**\n * Sends the queue. Everything that has to be awaited is awaited first, and only then are the\n * events taken off the queue and handed to `fetch` in the same synchronous step — so there is\n * no moment at which an event is neither queued nor sent.\n */\nasync function flush() {\n if (timer) {\n clearTimeout(timer);\n timer = null;\n }\n if (list.length === 0) return;\n\n let events: Item[] = [];\n try {\n await getTokenBucket().removeTokens();\n const visitor_id = visitorId();\n const headers = await config.getHeaders();\n // Settled before the events leave the queue; those queued meanwhile are a microtask away.\n await Promise.all(list.map((event) => event.tags));\n\n events = list.splice(0);\n if (events.length === 0) return;\n const dto: CreateTrackEventDTO = await Promise.all(\n events.map(async (event) => ({\n name: event.name,\n properties: event.properties,\n tags: await event.tags,\n visitor_id,\n session_id: event.session_id,\n platform: config.platform,\n environment: config.environment,\n timestamp: event.timestamp,\n }))\n );\n\n const body = JSON.stringify(dto);\n const response = await fetch(`${config.endpoint}/events`, {\n method: 'POST',\n credentials: 'include',\n // keepalive lets the request survive the page being unloaded mid-flight, within a 64KB\n // budget shared by every such request of the page; a body that could not fit goes as a\n // plain POST rather than failing outright.\n keepalive: body.length < 60_000,\n headers,\n body,\n });\n\n if (!response.ok) {\n throw new Error(`Failed to send track event: ${response.status} ${await response.text()}`);\n }\n\n const data = (await response.json()) as TrackEventResponse;\n\n events.forEach((event, index) => {\n const eventId = data.at(index)?.id;\n event.options.onSucceed?.(eventId ? { id: eventId } : undefined);\n // An explicit false, not falsiness: a caller passing `{ onSucceed }` replaces the options\n // object wholesale, and leaving the flag out must not silently switch forwarding off.\n if (event.options.enableThirdPartyTracking === false || IGNORED_EVENTS.includes(event.name)) {\n return;\n }\n config.thirdPartyTrackers.forEach((tracker) => {\n try {\n tracker(event.name, event.properties, eventId);\n } catch (e: unknown) {\n // A third-party script does not get to take the rest of the batch with it.\n if (e instanceof Error) console.log(e.message);\n }\n });\n });\n } catch (e: unknown) {\n if (e instanceof Error) console.log(e.message);\n // `fetch` has already retried the transient failures by the time a batch fails here; what\n // remains is a batch the server rejected outright — one invalid event fails the whole batch\n // — or a network that stayed down past the retries. The other events are reported lost;\n // `session_start` alone goes back on the queue for the next send, because it is the\n // session's only attribution record: a session without one has no channel, and its every\n // later event drops out of an attribution join.\n const starts = events.filter((event) => event.name === 'session_start');\n list.unshift(...starts);\n events\n .filter((event) => event.name !== 'session_start')\n .forEach((event) => event.options.onError?.(e));\n }\n}\n\nexport function track<T extends EventName = EventName>(\n name: TrackName<T>,\n properties?: TrackProperties<T>,\n options: TrackOptions = defaultOptions\n) {\n enqueue(name, properties, options);\n if (list.length >= batchSize) {\n void flush();\n return;\n }\n if (timer) clearTimeout(timer);\n timer = setTimeout(() => void flush(), delay);\n}\n\n/**\n * Sends the event now, with whatever is queued, and resolves once it has been sent or lost — or,\n * when the send could not start (its headers or rate limit threw), at once, the event left queued\n * for the next send. It never rejects: tracking must not fail the step that awaits it.\n */\nexport async function trackAsync<T extends EventName = EventName>(\n name: TrackName<T>,\n properties?: TrackProperties<T>,\n options: TrackOptions = defaultOptions\n) {\n let settle!: () => void;\n const settled = new Promise<void>((resolve) => {\n settle = resolve;\n });\n const event = enqueue(name, properties, {\n ...options,\n onSucceed: (response) => {\n options.onSucceed?.(response);\n settle();\n },\n onError: (error) => {\n options.onError?.(error);\n settle();\n },\n });\n await flush();\n // Still queued: this send failed before taking it. It waits for the next send, but the caller,\n // who may be holding a navigation on this, does not.\n if (list.includes(event)) return;\n await settled;\n}\n\nfunction beacon(dto: CreateTrackEventDTO): boolean {\n // Not every runtime with a `navigator` has it: React Native has neither beacons nor pages.\n if (typeof navigator === 'undefined' || !('sendBeacon' in navigator)) return false;\n // text/plain keeps a cross-origin beacon a CORS simple request: as application/json it needs a\n // preflight, which a page being closed often cannot complete, and the beacon is then dropped.\n // The server reads it as JSON (`zBeaconJson` in @shware/http).\n const blob = new Blob([JSON.stringify(dto)], { type: 'text/plain;charset=UTF-8' });\n return navigator.sendBeacon(`${config.endpoint}/events`, blob);\n}\n\n/**\n * Sends the queue by beacon, now: for when the page is hidden or left, the last moment its\n * script is sure to run — what PostHog's `requestQueue.unload()` and GA4 do on the same events.\n * Call it before the engagement beacon: a new session's `session_start` is in this queue.\n *\n * Nothing can be awaited here, so the events go with the tags they have settled on (the last\n * built ones otherwise), and without the server's ids: their third-party trackers are not fired,\n * as an id-less browser event could not be deduplicated against the server's. The visitor id is\n * local (`visitorId`), so a first visit left within a second is sent like any other; when no\n * beacon can take them — no `sendBeacon`, a refused body — they stay queued for the usual send.\n */\nexport function sendPendingEvents() {\n if (list.length === 0) return;\n const visitor_id = visitorId();\n const dto: CreateTrackEventDTO = list.map((event) => ({\n name: event.name,\n properties: event.properties,\n tags: event.settled ?? cache.tags ?? {},\n visitor_id,\n session_id: event.session_id,\n platform: config.platform,\n environment: config.environment,\n timestamp: event.timestamp,\n }));\n if (!beacon(dto)) {\n void flush();\n return;\n }\n if (timer) {\n clearTimeout(timer);\n timer = null;\n }\n list.splice(0).forEach((event) => event.options.onSucceed?.(undefined));\n}\n\nexport function sendBeacon<T extends EventName = EventName>(\n name: TrackName<T>,\n properties?: TrackProperties<T>\n) {\n const visitor_id = visitorId();\n // No stored session means no event was ever queued from this storage, so there is no session\n // to report for — and one started here could never be announced. See `Session.extend`.\n const session_id = getSession().extend();\n if (!session_id) return;\n\n const dto: CreateTrackEventDTO = [\n {\n name,\n properties,\n // Tags are worth less than the event carrying them: an empty set still reports the\n // engagement, and every field in `tagsSchema` is optional.\n tags: cache.tags ?? {},\n visitor_id,\n session_id,\n platform: config.platform,\n environment: config.environment,\n timestamp: new Date().toISOString(),\n },\n ];\n if (beacon(dto)) return;\n console.warn('Failed to send beacon', name, properties);\n}\n"],"mappings":"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"}
@@ -0,0 +1,25 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_setup_index = require("../setup/index.cjs");
3
+ const require_constants_storage = require("../constants/storage.cjs");
4
+ let uuid = require("uuid");
5
+ //#region src/visitor/id.ts
6
+ /**
7
+ * This visitor's id: generated here on the first visit (uuidv7) and kept in `config.storage`, so
8
+ * it exists the moment the page loads. The first events, and the beacon of a visit left within a
9
+ * second, go out without waiting for a round trip; the server creates the visitor from its first
10
+ * events. An id a server issued to an older client is kept as it is: the visitor continues.
11
+ *
12
+ * Needs a server that creates visitors from events (see the README, "Visitors"); one that still
13
+ * only creates them on `POST /visitors` rejects the events of a new visitor.
14
+ */
15
+ function visitorId() {
16
+ const stored = require_setup_index.config.storage.getItem(require_constants_storage.keys.visitor_id);
17
+ if (stored && stored !== "undefined") return stored;
18
+ const id = (0, uuid.v7)();
19
+ require_setup_index.config.storage.setItem(require_constants_storage.keys.visitor_id, id);
20
+ return id;
21
+ }
22
+ //#endregion
23
+ exports.visitorId = visitorId;
24
+
25
+ //# sourceMappingURL=id.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"id.cjs","names":["config","keys","uuidv7"],"sources":["../../src/visitor/id.ts"],"sourcesContent":["import { v7 as uuidv7 } from 'uuid';\nimport { keys } from '../constants/storage';\nimport { config } from '../setup/index';\n\n/**\n * This visitor's id: generated here on the first visit (uuidv7) and kept in `config.storage`, so\n * it exists the moment the page loads. The first events, and the beacon of a visit left within a\n * second, go out without waiting for a round trip; the server creates the visitor from its first\n * events. An id a server issued to an older client is kept as it is: the visitor continues.\n *\n * Needs a server that creates visitors from events (see the README, \"Visitors\"); one that still\n * only creates them on `POST /visitors` rejects the events of a new visitor.\n */\nexport function visitorId(): string {\n const stored = config.storage.getItem(keys.visitor_id);\n if (stored && stored !== 'undefined') return stored;\n const id = uuidv7();\n config.storage.setItem(keys.visitor_id, id);\n return id;\n}\n"],"mappings":";;;;;;;;;;;;;;AAaA,SAAgB,YAAoB;CAClC,MAAM,SAASA,oBAAAA,OAAO,QAAQ,QAAQC,0BAAAA,KAAK,UAAU;CACrD,IAAI,UAAU,WAAW,aAAa,OAAO;CAC7C,MAAM,MAAA,GAAKC,KAAAA,GAAAA,CAAO;CAClB,oBAAA,OAAO,QAAQ,QAAQD,0BAAAA,KAAK,YAAY,EAAE;CAC1C,OAAO;AACT"}
@@ -0,0 +1,13 @@
1
+ //#region src/visitor/id.d.ts
2
+ /**
3
+ * This visitor's id: generated here on the first visit (uuidv7) and kept in `config.storage`, so
4
+ * it exists the moment the page loads. The first events, and the beacon of a visit left within a
5
+ * second, go out without waiting for a round trip; the server creates the visitor from its first
6
+ * events. An id a server issued to an older client is kept as it is: the visitor continues.
7
+ *
8
+ * Needs a server that creates visitors from events (see the README, "Visitors"); one that still
9
+ * only creates them on `POST /visitors` rejects the events of a new visitor.
10
+ */
11
+ export declare function visitorId(): string;
12
+ //#endregion
13
+ //# sourceMappingURL=id.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"id.d.cts","names":[],"sources":["../../src/visitor/id.ts"],"mappings":";;;;;;;;;;wBAagB"}
@@ -0,0 +1,13 @@
1
+ //#region src/visitor/id.d.ts
2
+ /**
3
+ * This visitor's id: generated here on the first visit (uuidv7) and kept in `config.storage`, so
4
+ * it exists the moment the page loads. The first events, and the beacon of a visit left within a
5
+ * second, go out without waiting for a round trip; the server creates the visitor from its first
6
+ * events. An id a server issued to an older client is kept as it is: the visitor continues.
7
+ *
8
+ * Needs a server that creates visitors from events (see the README, "Visitors"); one that still
9
+ * only creates them on `POST /visitors` rejects the events of a new visitor.
10
+ */
11
+ export declare function visitorId(): string;
12
+ //#endregion
13
+ //# sourceMappingURL=id.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"id.d.mts","names":[],"sources":["../../src/visitor/id.ts"],"mappings":";;;;;;;;;;wBAagB"}
@@ -0,0 +1,24 @@
1
+ import { config } from "../setup/index.mjs";
2
+ import { keys } from "../constants/storage.mjs";
3
+ import { v7 } from "uuid";
4
+ //#region src/visitor/id.ts
5
+ /**
6
+ * This visitor's id: generated here on the first visit (uuidv7) and kept in `config.storage`, so
7
+ * it exists the moment the page loads. The first events, and the beacon of a visit left within a
8
+ * second, go out without waiting for a round trip; the server creates the visitor from its first
9
+ * events. An id a server issued to an older client is kept as it is: the visitor continues.
10
+ *
11
+ * Needs a server that creates visitors from events (see the README, "Visitors"); one that still
12
+ * only creates them on `POST /visitors` rejects the events of a new visitor.
13
+ */
14
+ function visitorId() {
15
+ const stored = config.storage.getItem(keys.visitor_id);
16
+ if (stored && stored !== "undefined") return stored;
17
+ const id = v7();
18
+ config.storage.setItem(keys.visitor_id, id);
19
+ return id;
20
+ }
21
+ //#endregion
22
+ export { visitorId };
23
+
24
+ //# sourceMappingURL=id.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"id.mjs","names":["uuidv7"],"sources":["../../src/visitor/id.ts"],"sourcesContent":["import { v7 as uuidv7 } from 'uuid';\nimport { keys } from '../constants/storage';\nimport { config } from '../setup/index';\n\n/**\n * This visitor's id: generated here on the first visit (uuidv7) and kept in `config.storage`, so\n * it exists the moment the page loads. The first events, and the beacon of a visit left within a\n * second, go out without waiting for a round trip; the server creates the visitor from its first\n * events. An id a server issued to an older client is kept as it is: the visitor continues.\n *\n * Needs a server that creates visitors from events (see the README, \"Visitors\"); one that still\n * only creates them on `POST /visitors` rejects the events of a new visitor.\n */\nexport function visitorId(): string {\n const stored = config.storage.getItem(keys.visitor_id);\n if (stored && stored !== 'undefined') return stored;\n const id = uuidv7();\n config.storage.setItem(keys.visitor_id, id);\n return id;\n}\n"],"mappings":";;;;;;;;;;;;;AAaA,SAAgB,YAAoB;CAClC,MAAM,SAAS,OAAO,QAAQ,QAAQ,KAAK,UAAU;CACrD,IAAI,UAAU,WAAW,aAAa,OAAO;CAC7C,MAAM,KAAKA,GAAO;CAClB,OAAO,QAAQ,QAAQ,KAAK,YAAY,EAAE;CAC1C,OAAO;AACT"}
@@ -1,38 +1,33 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_setup_index = require("../setup/index.cjs");
3
- const require_constants_storage = require("../constants/storage.cjs");
4
- let _shware_utils = require("@shware/utils");
5
- let uuid = require("uuid");
3
+ const require_visitor_id = require("./id.cjs");
4
+ const require_track_index = require("../track/index.cjs");
6
5
  //#region src/visitor/index.ts
7
6
  /**
8
- * This visitor's id: generated here on the first visit (uuidv7) and kept in `config.storage`, so
9
- * it exists the moment the page loads. The first events, and the beacon of a visit left within a
10
- * second, go out without waiting for a round trip; the server creates the visitor from its first
11
- * events. An id a server issued to an older client is kept as it is: the visitor continues.
7
+ * The event that binds the visitor to the user who signed in on it: the server sets the visitor's
8
+ * `user_id` and `distinct_id` from its `user_id` property, in the transaction that stores the
9
+ * batch — which also creates the visitor when it is new, so the binding can never arrive before it.
10
+ */
11
+ const IDENTIFY_EVENT = "identify";
12
+ /** The user this page has identified the visitor as: identify once per user, not per call. */
13
+ let identified;
14
+ /**
15
+ * Hands the signed-in user to the analytics: the third-party user setters (gtag, the pixels) now,
16
+ * and the server through an `identify` event, sent when the user differs from the one this page
17
+ * last identified — a host calls this on every page load once someone is signed in. Like any
18
+ * event it goes with the next batch or the page-hide beacon; `user_data` is handed to the setters
19
+ * only, never stored with the event.
12
20
  *
13
- * Needs a server that creates visitors from events (see the README, "Visitors"); one that still
14
- * only creates them on `POST /visitors` rejects the events of a new visitor.
21
+ * Synchronous and never throws: sign-in must not fail on analytics.
15
22
  */
16
- function visitorId() {
17
- const stored = require_setup_index.config.storage.getItem(require_constants_storage.keys.visitor_id);
18
- if (stored && stored !== "undefined") return stored;
19
- const id = (0, uuid.v7)();
20
- require_setup_index.config.storage.setItem(require_constants_storage.keys.visitor_id, id);
21
- return id;
22
- }
23
- async function setVisitor(dto) {
24
- const id = visitorId();
25
- const response = await (0, _shware_utils.fetch)(`${require_setup_index.config.endpoint}/visitors/${id}`, {
26
- method: "PATCH",
27
- credentials: "include",
28
- headers: await require_setup_index.config.getHeaders(),
29
- body: JSON.stringify(dto)
30
- });
31
- if (!response.ok) throw new Error("Failed to set visitor");
32
- const data = await response.json();
23
+ function setVisitor(dto) {
24
+ if (dto.user_id && dto.user_id !== identified) {
25
+ identified = dto.user_id;
26
+ require_track_index.track(IDENTIFY_EVENT, { user_id: dto.user_id }, { enableThirdPartyTracking: false });
27
+ }
33
28
  const identity = {
34
29
  ...dto,
35
- distinct_id: data.distinct_id ?? null
30
+ distinct_id: dto.user_id ?? null
36
31
  };
37
32
  require_setup_index.config.thirdPartyUserSetters.forEach((setter) => {
38
33
  try {
@@ -41,10 +36,10 @@ async function setVisitor(dto) {
41
36
  if (e instanceof Error) console.log(e.message);
42
37
  }
43
38
  });
44
- return data;
45
39
  }
46
40
  //#endregion
41
+ exports.IDENTIFY_EVENT = IDENTIFY_EVENT;
47
42
  exports.setVisitor = setVisitor;
48
- exports.visitorId = visitorId;
43
+ exports.visitorId = require_visitor_id.visitorId;
49
44
 
50
45
  //# sourceMappingURL=index.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","names":["config","keys","uuidv7","fetch"],"sources":["../../src/visitor/index.ts"],"sourcesContent":["import { fetch } from '@shware/utils';\nimport { v7 as uuidv7 } from 'uuid';\nimport { keys } from '../constants/storage';\nimport type { UpdateVisitorDTO } from '../schema/index';\nimport { config } from '../setup/index';\nimport type { Visitor } from './types';\n\n/**\n * This visitor's id: generated here on the first visit (uuidv7) and kept in `config.storage`, so\n * it exists the moment the page loads. The first events, and the beacon of a visit left within a\n * second, go out without waiting for a round trip; the server creates the visitor from its first\n * events. An id a server issued to an older client is kept as it is: the visitor continues.\n *\n * Needs a server that creates visitors from events (see the README, \"Visitors\"); one that still\n * only creates them on `POST /visitors` rejects the events of a new visitor.\n */\nexport function visitorId(): string {\n const stored = config.storage.getItem(keys.visitor_id);\n if (stored && stored !== 'undefined') return stored;\n const id = uuidv7();\n config.storage.setItem(keys.visitor_id, id);\n return id;\n}\n\nexport async function setVisitor(dto: Omit<UpdateVisitorDTO, 'tags'>) {\n const id = visitorId();\n const response = await fetch(`${config.endpoint}/visitors/${id}`, {\n method: 'PATCH',\n credentials: 'include',\n headers: await config.getHeaders(),\n body: JSON.stringify(dto),\n });\n\n if (!response.ok) throw new Error('Failed to set visitor');\n const data = (await response.json()) as Visitor;\n\n // Setters get the server's distinct_id — the person the visitor now belongs to — not anything\n // the client could have said about it.\n const identity = { ...dto, distinct_id: data.distinct_id ?? null };\n config.thirdPartyUserSetters.forEach((setter) => {\n try {\n setter(identity);\n } catch (e: unknown) {\n // The visitor was updated before this ran, so a third-party setter throwing must not reject\n // a call that already succeeded.\n if (e instanceof Error) console.log(e.message);\n }\n });\n return data;\n}\n"],"mappings":";;;;;;;;;;;;;;;AAgBA,SAAgB,YAAoB;CAClC,MAAM,SAASA,oBAAAA,OAAO,QAAQ,QAAQC,0BAAAA,KAAK,UAAU;CACrD,IAAI,UAAU,WAAW,aAAa,OAAO;CAC7C,MAAM,MAAA,GAAKC,KAAAA,GAAAA,CAAO;CAClB,oBAAA,OAAO,QAAQ,QAAQD,0BAAAA,KAAK,YAAY,EAAE;CAC1C,OAAO;AACT;AAEA,eAAsB,WAAW,KAAqC;CACpE,MAAM,KAAK,UAAU;CACrB,MAAM,WAAW,OAAA,GAAME,cAAAA,MAAAA,CAAM,GAAGH,oBAAAA,OAAO,SAAS,YAAY,MAAM;EAChE,QAAQ;EACR,aAAa;EACb,SAAS,MAAMA,oBAAAA,OAAO,WAAW;EACjC,MAAM,KAAK,UAAU,GAAG;CAC1B,CAAC;CAED,IAAI,CAAC,SAAS,IAAI,MAAM,IAAI,MAAM,uBAAuB;CACzD,MAAM,OAAQ,MAAM,SAAS,KAAK;CAIlC,MAAM,WAAW;EAAE,GAAG;EAAK,aAAa,KAAK,eAAe;CAAK;CACjE,oBAAA,OAAO,sBAAsB,SAAS,WAAW;EAC/C,IAAI;GACF,OAAO,QAAQ;EACjB,SAAS,GAAY;GAGnB,IAAI,aAAa,OAAO,QAAQ,IAAI,EAAE,OAAO;EAC/C;CACF,CAAC;CACD,OAAO;AACT"}
1
+ {"version":3,"file":"index.cjs","names":[],"sources":["../../src/visitor/index.ts"],"sourcesContent":["import type { UpdateVisitorDTO } from '../schema/index';\nimport { config } from '../setup/index';\nimport { track } from '../track/index';\n\nexport { visitorId } from './id';\n\n/**\n * The event that binds the visitor to the user who signed in on it: the server sets the visitor's\n * `user_id` and `distinct_id` from its `user_id` property, in the transaction that stores the\n * batch — which also creates the visitor when it is new, so the binding can never arrive before it.\n */\nexport const IDENTIFY_EVENT = 'identify';\n\n/** The user this page has identified the visitor as: identify once per user, not per call. */\nlet identified: string | undefined;\n\n/**\n * Hands the signed-in user to the analytics: the third-party user setters (gtag, the pixels) now,\n * and the server through an `identify` event, sent when the user differs from the one this page\n * last identified — a host calls this on every page load once someone is signed in. Like any\n * event it goes with the next batch or the page-hide beacon; `user_data` is handed to the setters\n * only, never stored with the event.\n *\n * Synchronous and never throws: sign-in must not fail on analytics.\n */\nexport function setVisitor(dto: Omit<UpdateVisitorDTO, 'tags'>) {\n if (dto.user_id && dto.user_id !== identified) {\n identified = dto.user_id;\n track(IDENTIFY_EVENT, { user_id: dto.user_id }, { enableThirdPartyTracking: false });\n }\n\n // Once bound, the server's person key for the visitor is the user's id, so the setters are told\n // that rather than waiting for the server to say it.\n const identity = { ...dto, distinct_id: dto.user_id ?? null };\n config.thirdPartyUserSetters.forEach((setter) => {\n try {\n setter(identity);\n } catch (e: unknown) {\n // One third-party script does not get to stop the others.\n if (e instanceof Error) console.log(e.message);\n }\n });\n}\n"],"mappings":";;;;;;;;;;AAWA,MAAa,iBAAiB;;AAG9B,IAAI;;;;;;;;;;AAWJ,SAAgB,WAAW,KAAqC;CAC9D,IAAI,IAAI,WAAW,IAAI,YAAY,YAAY;EAC7C,aAAa,IAAI;EACjB,oBAAA,MAAM,gBAAgB,EAAE,SAAS,IAAI,QAAQ,GAAG,EAAE,0BAA0B,MAAM,CAAC;CACrF;CAIA,MAAM,WAAW;EAAE,GAAG;EAAK,aAAa,IAAI,WAAW;CAAK;CAC5D,oBAAA,OAAO,sBAAsB,SAAS,WAAW;EAC/C,IAAI;GACF,OAAO,QAAQ;EACjB,SAAS,GAAY;GAEnB,IAAI,aAAa,OAAO,QAAQ,IAAI,EAAE,OAAO;EAC/C;CACF,CAAC;AACH"}
@@ -1,16 +1,22 @@
1
1
  import { UpdateVisitorDTO } from "../schema/index.cjs";
2
- import { Visitor } from "./types.cjs";
2
+ import { visitorId } from "./id.cjs";
3
3
  //#region src/visitor/index.d.ts
4
4
  /**
5
- * This visitor's id: generated here on the first visit (uuidv7) and kept in `config.storage`, so
6
- * it exists the moment the page loads. The first events, and the beacon of a visit left within a
7
- * second, go out without waiting for a round trip; the server creates the visitor from its first
8
- * events. An id a server issued to an older client is kept as it is: the visitor continues.
5
+ * The event that binds the visitor to the user who signed in on it: the server sets the visitor's
6
+ * `user_id` and `distinct_id` from its `user_id` property, in the transaction that stores the
7
+ * batch — which also creates the visitor when it is new, so the binding can never arrive before it.
8
+ */
9
+ export declare const IDENTIFY_EVENT = "identify";
10
+ /**
11
+ * Hands the signed-in user to the analytics: the third-party user setters (gtag, the pixels) now,
12
+ * and the server through an `identify` event, sent when the user differs from the one this page
13
+ * last identified — a host calls this on every page load once someone is signed in. Like any
14
+ * event it goes with the next batch or the page-hide beacon; `user_data` is handed to the setters
15
+ * only, never stored with the event.
9
16
  *
10
- * Needs a server that creates visitors from events (see the README, "Visitors"); one that still
11
- * only creates them on `POST /visitors` rejects the events of a new visitor.
17
+ * Synchronous and never throws: sign-in must not fail on analytics.
12
18
  */
13
- export declare function visitorId(): string;
14
- export declare function setVisitor(dto: Omit<UpdateVisitorDTO, 'tags'>): Promise<Visitor>;
19
+ export declare function setVisitor(dto: Omit<UpdateVisitorDTO, 'tags'>): void;
15
20
  //#endregion
21
+ export { visitorId };
16
22
  //# sourceMappingURL=index.d.cts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.cts","names":[],"sources":["../../src/visitor/index.ts"],"mappings":";;;;;;;;;;;;wBAgBgB;wBAQM,WAAW,KAAK,KAAK,4BAAyB,QAAA"}
1
+ {"version":3,"file":"index.d.cts","names":[],"sources":["../../src/visitor/index.ts"],"mappings":";;;;;;;;qBAWa;;;;;;;;;;wBAcG,WAAW,KAAK,KAAK"}
@@ -1,16 +1,22 @@
1
1
  import { UpdateVisitorDTO } from "../schema/index.mjs";
2
- import { Visitor } from "./types.mjs";
2
+ import { visitorId } from "./id.mjs";
3
3
  //#region src/visitor/index.d.ts
4
4
  /**
5
- * This visitor's id: generated here on the first visit (uuidv7) and kept in `config.storage`, so
6
- * it exists the moment the page loads. The first events, and the beacon of a visit left within a
7
- * second, go out without waiting for a round trip; the server creates the visitor from its first
8
- * events. An id a server issued to an older client is kept as it is: the visitor continues.
5
+ * The event that binds the visitor to the user who signed in on it: the server sets the visitor's
6
+ * `user_id` and `distinct_id` from its `user_id` property, in the transaction that stores the
7
+ * batch — which also creates the visitor when it is new, so the binding can never arrive before it.
8
+ */
9
+ export declare const IDENTIFY_EVENT = "identify";
10
+ /**
11
+ * Hands the signed-in user to the analytics: the third-party user setters (gtag, the pixels) now,
12
+ * and the server through an `identify` event, sent when the user differs from the one this page
13
+ * last identified — a host calls this on every page load once someone is signed in. Like any
14
+ * event it goes with the next batch or the page-hide beacon; `user_data` is handed to the setters
15
+ * only, never stored with the event.
9
16
  *
10
- * Needs a server that creates visitors from events (see the README, "Visitors"); one that still
11
- * only creates them on `POST /visitors` rejects the events of a new visitor.
17
+ * Synchronous and never throws: sign-in must not fail on analytics.
12
18
  */
13
- export declare function visitorId(): string;
14
- export declare function setVisitor(dto: Omit<UpdateVisitorDTO, 'tags'>): Promise<Visitor>;
19
+ export declare function setVisitor(dto: Omit<UpdateVisitorDTO, 'tags'>): void;
15
20
  //#endregion
21
+ export { visitorId };
16
22
  //# sourceMappingURL=index.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/visitor/index.ts"],"mappings":";;;;;;;;;;;;wBAgBgB;wBAQM,WAAW,KAAK,KAAK,4BAAyB,QAAA"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/visitor/index.ts"],"mappings":";;;;;;;;qBAWa;;;;;;;;;;wBAcG,WAAW,KAAK,KAAK"}
@@ -1,37 +1,32 @@
1
1
  import { config } from "../setup/index.mjs";
2
- import { keys } from "../constants/storage.mjs";
3
- import { fetch } from "@shware/utils";
4
- import { v7 } from "uuid";
2
+ import { visitorId } from "./id.mjs";
3
+ import { track } from "../track/index.mjs";
5
4
  //#region src/visitor/index.ts
6
5
  /**
7
- * This visitor's id: generated here on the first visit (uuidv7) and kept in `config.storage`, so
8
- * it exists the moment the page loads. The first events, and the beacon of a visit left within a
9
- * second, go out without waiting for a round trip; the server creates the visitor from its first
10
- * events. An id a server issued to an older client is kept as it is: the visitor continues.
6
+ * The event that binds the visitor to the user who signed in on it: the server sets the visitor's
7
+ * `user_id` and `distinct_id` from its `user_id` property, in the transaction that stores the
8
+ * batch — which also creates the visitor when it is new, so the binding can never arrive before it.
9
+ */
10
+ const IDENTIFY_EVENT = "identify";
11
+ /** The user this page has identified the visitor as: identify once per user, not per call. */
12
+ let identified;
13
+ /**
14
+ * Hands the signed-in user to the analytics: the third-party user setters (gtag, the pixels) now,
15
+ * and the server through an `identify` event, sent when the user differs from the one this page
16
+ * last identified — a host calls this on every page load once someone is signed in. Like any
17
+ * event it goes with the next batch or the page-hide beacon; `user_data` is handed to the setters
18
+ * only, never stored with the event.
11
19
  *
12
- * Needs a server that creates visitors from events (see the README, "Visitors"); one that still
13
- * only creates them on `POST /visitors` rejects the events of a new visitor.
20
+ * Synchronous and never throws: sign-in must not fail on analytics.
14
21
  */
15
- function visitorId() {
16
- const stored = config.storage.getItem(keys.visitor_id);
17
- if (stored && stored !== "undefined") return stored;
18
- const id = v7();
19
- config.storage.setItem(keys.visitor_id, id);
20
- return id;
21
- }
22
- async function setVisitor(dto) {
23
- const id = visitorId();
24
- const response = await fetch(`${config.endpoint}/visitors/${id}`, {
25
- method: "PATCH",
26
- credentials: "include",
27
- headers: await config.getHeaders(),
28
- body: JSON.stringify(dto)
29
- });
30
- if (!response.ok) throw new Error("Failed to set visitor");
31
- const data = await response.json();
22
+ function setVisitor(dto) {
23
+ if (dto.user_id && dto.user_id !== identified) {
24
+ identified = dto.user_id;
25
+ track(IDENTIFY_EVENT, { user_id: dto.user_id }, { enableThirdPartyTracking: false });
26
+ }
32
27
  const identity = {
33
28
  ...dto,
34
- distinct_id: data.distinct_id ?? null
29
+ distinct_id: dto.user_id ?? null
35
30
  };
36
31
  config.thirdPartyUserSetters.forEach((setter) => {
37
32
  try {
@@ -40,9 +35,8 @@ async function setVisitor(dto) {
40
35
  if (e instanceof Error) console.log(e.message);
41
36
  }
42
37
  });
43
- return data;
44
38
  }
45
39
  //#endregion
46
- export { setVisitor, visitorId };
40
+ export { IDENTIFY_EVENT, setVisitor, visitorId };
47
41
 
48
42
  //# sourceMappingURL=index.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":["uuidv7"],"sources":["../../src/visitor/index.ts"],"sourcesContent":["import { fetch } from '@shware/utils';\nimport { v7 as uuidv7 } from 'uuid';\nimport { keys } from '../constants/storage';\nimport type { UpdateVisitorDTO } from '../schema/index';\nimport { config } from '../setup/index';\nimport type { Visitor } from './types';\n\n/**\n * This visitor's id: generated here on the first visit (uuidv7) and kept in `config.storage`, so\n * it exists the moment the page loads. The first events, and the beacon of a visit left within a\n * second, go out without waiting for a round trip; the server creates the visitor from its first\n * events. An id a server issued to an older client is kept as it is: the visitor continues.\n *\n * Needs a server that creates visitors from events (see the README, \"Visitors\"); one that still\n * only creates them on `POST /visitors` rejects the events of a new visitor.\n */\nexport function visitorId(): string {\n const stored = config.storage.getItem(keys.visitor_id);\n if (stored && stored !== 'undefined') return stored;\n const id = uuidv7();\n config.storage.setItem(keys.visitor_id, id);\n return id;\n}\n\nexport async function setVisitor(dto: Omit<UpdateVisitorDTO, 'tags'>) {\n const id = visitorId();\n const response = await fetch(`${config.endpoint}/visitors/${id}`, {\n method: 'PATCH',\n credentials: 'include',\n headers: await config.getHeaders(),\n body: JSON.stringify(dto),\n });\n\n if (!response.ok) throw new Error('Failed to set visitor');\n const data = (await response.json()) as Visitor;\n\n // Setters get the server's distinct_id — the person the visitor now belongs to — not anything\n // the client could have said about it.\n const identity = { ...dto, distinct_id: data.distinct_id ?? null };\n config.thirdPartyUserSetters.forEach((setter) => {\n try {\n setter(identity);\n } catch (e: unknown) {\n // The visitor was updated before this ran, so a third-party setter throwing must not reject\n // a call that already succeeded.\n if (e instanceof Error) console.log(e.message);\n }\n });\n return data;\n}\n"],"mappings":";;;;;;;;;;;;;;AAgBA,SAAgB,YAAoB;CAClC,MAAM,SAAS,OAAO,QAAQ,QAAQ,KAAK,UAAU;CACrD,IAAI,UAAU,WAAW,aAAa,OAAO;CAC7C,MAAM,KAAKA,GAAO;CAClB,OAAO,QAAQ,QAAQ,KAAK,YAAY,EAAE;CAC1C,OAAO;AACT;AAEA,eAAsB,WAAW,KAAqC;CACpE,MAAM,KAAK,UAAU;CACrB,MAAM,WAAW,MAAM,MAAM,GAAG,OAAO,SAAS,YAAY,MAAM;EAChE,QAAQ;EACR,aAAa;EACb,SAAS,MAAM,OAAO,WAAW;EACjC,MAAM,KAAK,UAAU,GAAG;CAC1B,CAAC;CAED,IAAI,CAAC,SAAS,IAAI,MAAM,IAAI,MAAM,uBAAuB;CACzD,MAAM,OAAQ,MAAM,SAAS,KAAK;CAIlC,MAAM,WAAW;EAAE,GAAG;EAAK,aAAa,KAAK,eAAe;CAAK;CACjE,OAAO,sBAAsB,SAAS,WAAW;EAC/C,IAAI;GACF,OAAO,QAAQ;EACjB,SAAS,GAAY;GAGnB,IAAI,aAAa,OAAO,QAAQ,IAAI,EAAE,OAAO;EAC/C;CACF,CAAC;CACD,OAAO;AACT"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../src/visitor/index.ts"],"sourcesContent":["import type { UpdateVisitorDTO } from '../schema/index';\nimport { config } from '../setup/index';\nimport { track } from '../track/index';\n\nexport { visitorId } from './id';\n\n/**\n * The event that binds the visitor to the user who signed in on it: the server sets the visitor's\n * `user_id` and `distinct_id` from its `user_id` property, in the transaction that stores the\n * batch — which also creates the visitor when it is new, so the binding can never arrive before it.\n */\nexport const IDENTIFY_EVENT = 'identify';\n\n/** The user this page has identified the visitor as: identify once per user, not per call. */\nlet identified: string | undefined;\n\n/**\n * Hands the signed-in user to the analytics: the third-party user setters (gtag, the pixels) now,\n * and the server through an `identify` event, sent when the user differs from the one this page\n * last identified — a host calls this on every page load once someone is signed in. Like any\n * event it goes with the next batch or the page-hide beacon; `user_data` is handed to the setters\n * only, never stored with the event.\n *\n * Synchronous and never throws: sign-in must not fail on analytics.\n */\nexport function setVisitor(dto: Omit<UpdateVisitorDTO, 'tags'>) {\n if (dto.user_id && dto.user_id !== identified) {\n identified = dto.user_id;\n track(IDENTIFY_EVENT, { user_id: dto.user_id }, { enableThirdPartyTracking: false });\n }\n\n // Once bound, the server's person key for the visitor is the user's id, so the setters are told\n // that rather than waiting for the server to say it.\n const identity = { ...dto, distinct_id: dto.user_id ?? null };\n config.thirdPartyUserSetters.forEach((setter) => {\n try {\n setter(identity);\n } catch (e: unknown) {\n // One third-party script does not get to stop the others.\n if (e instanceof Error) console.log(e.message);\n }\n });\n}\n"],"mappings":";;;;;;;;;AAWA,MAAa,iBAAiB;;AAG9B,IAAI;;;;;;;;;;AAWJ,SAAgB,WAAW,KAAqC;CAC9D,IAAI,IAAI,WAAW,IAAI,YAAY,YAAY;EAC7C,aAAa,IAAI;EACjB,MAAM,gBAAgB,EAAE,SAAS,IAAI,QAAQ,GAAG,EAAE,0BAA0B,MAAM,CAAC;CACrF;CAIA,MAAM,WAAW;EAAE,GAAG;EAAK,aAAa,IAAI,WAAW;CAAK;CAC5D,OAAO,sBAAsB,SAAS,WAAW;EAC/C,IAAI;GACF,OAAO,QAAQ;EACjB,SAAS,GAAY;GAEnB,IAAI,aAAa,OAAO,QAAQ,IAAI,EAAE,OAAO;EAC/C;CACF,CAAC;AACH"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shware/analytics",
3
- "version": "11.1.0",
3
+ "version": "11.2.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",