@shware/analytics 7.2.1 → 7.3.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 (43) hide show
  1. package/dist/click-id/index.cjs.map +1 -1
  2. package/dist/click-id/index.d.cts +1 -1
  3. package/dist/click-id/index.d.mts +1 -1
  4. package/dist/click-id/index.mjs.map +1 -1
  5. package/dist/constants/storage.cjs +2 -1
  6. package/dist/constants/storage.cjs.map +1 -1
  7. package/dist/constants/storage.d.cts +1 -0
  8. package/dist/constants/storage.d.cts.map +1 -1
  9. package/dist/constants/storage.d.mts +1 -0
  10. package/dist/constants/storage.d.mts.map +1 -1
  11. package/dist/constants/storage.mjs +2 -1
  12. package/dist/constants/storage.mjs.map +1 -1
  13. package/dist/hooks/use-app-analytics.cjs +1 -2
  14. package/dist/hooks/use-app-analytics.cjs.map +1 -1
  15. package/dist/hooks/use-app-analytics.mjs +1 -2
  16. package/dist/hooks/use-app-analytics.mjs.map +1 -1
  17. package/dist/hooks/use-web-analytics.cjs +1 -2
  18. package/dist/hooks/use-web-analytics.cjs.map +1 -1
  19. package/dist/hooks/use-web-analytics.mjs +1 -2
  20. package/dist/hooks/use-web-analytics.mjs.map +1 -1
  21. package/dist/next/index.cjs +1 -1
  22. package/dist/next/index.mjs +1 -1
  23. package/dist/react-router/index.cjs +1 -1
  24. package/dist/react-router/index.mjs +1 -1
  25. package/dist/setup/session.cjs +61 -14
  26. package/dist/setup/session.cjs.map +1 -1
  27. package/dist/setup/session.d.cts +24 -6
  28. package/dist/setup/session.d.cts.map +1 -1
  29. package/dist/setup/session.d.mts +24 -6
  30. package/dist/setup/session.d.mts.map +1 -1
  31. package/dist/setup/session.mjs +61 -14
  32. package/dist/setup/session.mjs.map +1 -1
  33. package/dist/tanstack/middleware.cjs.map +1 -1
  34. package/dist/tanstack/middleware.d.cts +1 -1
  35. package/dist/tanstack/middleware.d.mts +1 -1
  36. package/dist/tanstack/middleware.mjs.map +1 -1
  37. package/dist/track/index.cjs +17 -18
  38. package/dist/track/index.cjs.map +1 -1
  39. package/dist/track/index.d.cts.map +1 -1
  40. package/dist/track/index.d.mts.map +1 -1
  41. package/dist/track/index.mjs +17 -18
  42. package/dist/track/index.mjs.map +1 -1
  43. package/package.json +1 -1
@@ -1,22 +1,40 @@
1
1
  //#region src/setup/session.d.ts
2
2
  declare const SESSION_TIMEOUT: number;
3
+ /** The session an event belongs to, and whether that event is the one that started it. */
4
+ interface SessionForEvent {
5
+ id: string;
6
+ started: boolean;
7
+ }
3
8
  declare class Session {
4
- private id;
9
+ /**
10
+ * Engagement is deliberately not stored: it is the time this page has accrued and not yet
11
+ * reported, so it belongs to the page, not to the session. GA4 draws the same line — its cookie
12
+ * carries the session, while the engagement timer lives and dies with the document.
13
+ */
5
14
  private startTime;
6
- private lastActiveTime;
7
15
  private accumulatedTime;
8
16
  private active;
9
17
  private visible;
10
18
  private focused;
11
19
  constructor();
12
- getId: () => string;
20
+ /**
21
+ * The session a batch of events belongs to: read the stored one, start a new one if it has
22
+ * timed out or there is none, stamp it with when those events happened and write it back. Every
23
+ * event goes through here, exactly as GA4 rereads and rewrites its cookie per event — caching
24
+ * the session in memory would put the tabs back out of step with each other.
25
+ */
26
+ touch: (eventTime: number, lastEventTime?: number) => SessionForEvent;
27
+ /**
28
+ * The id for an event that must not start a session — the `pagehide` beacon, which reports what
29
+ * the session now ending accrued. A live session is extended, as any event extends it; one
30
+ * already past its timeout still owns that engagement, so its id comes back without being
31
+ * revived into a session no `session_start` ever announced.
32
+ */
33
+ extend: () => string;
13
34
  isActive: () => boolean;
14
35
  isVisible: () => boolean;
15
36
  isFocused: () => boolean;
16
- isExpired: () => boolean;
17
- updateLastActiveTime: () => void;
18
37
  updateActive: (active: boolean) => void;
19
- refresh: () => void;
20
38
  updateAccumulator: () => void;
21
39
  focus: () => void;
22
40
  blur: () => void;
@@ -1 +1 @@
1
- {"version":3,"file":"session.d.cts","names":[],"sources":["../../src/setup/session.ts"],"mappings":";cAEa;cAEP;UACI;UACA;UACA;UACA;UAEA;UACA;UACA;EAER;EAWA;EAEA;EACA;EACA;EACA;EAEA;EAIA,eAAY;EAIZ;EAKA;EAWA;EAMA;EAKA;EAMA;EAKA,mBAAgB,OAAW;EAQ3B;;;;;;;;;;;;;;;;;;;;;;;;;iBAiCc,cAAU"}
1
+ {"version":3,"file":"session.d.cts","names":[],"sources":["../../src/setup/session.ts"],"mappings":";cAIa;;UAiBH;EACR;EACA;;cA8BI;;;;;;UAMI;UACA;UAEA;UACA;UACA;EAER;;;;;;;EAeA,QAAK,mBAAqB,2BAA8B;;;;;;;EA4BxD;EAWA;EACA;EACA;EAEA,eAAY;EAIZ;EAWA;EAKA;EAKA;EAKA;EAKA,mBAAgB,OAAW;EAK3B;;;;;;;;;;;;;;;;;;;;;;;;;iBAiCc,cAAU"}
@@ -1,22 +1,40 @@
1
1
  //#region src/setup/session.d.ts
2
2
  declare const SESSION_TIMEOUT: number;
3
+ /** The session an event belongs to, and whether that event is the one that started it. */
4
+ interface SessionForEvent {
5
+ id: string;
6
+ started: boolean;
7
+ }
3
8
  declare class Session {
4
- private id;
9
+ /**
10
+ * Engagement is deliberately not stored: it is the time this page has accrued and not yet
11
+ * reported, so it belongs to the page, not to the session. GA4 draws the same line — its cookie
12
+ * carries the session, while the engagement timer lives and dies with the document.
13
+ */
5
14
  private startTime;
6
- private lastActiveTime;
7
15
  private accumulatedTime;
8
16
  private active;
9
17
  private visible;
10
18
  private focused;
11
19
  constructor();
12
- getId: () => string;
20
+ /**
21
+ * The session a batch of events belongs to: read the stored one, start a new one if it has
22
+ * timed out or there is none, stamp it with when those events happened and write it back. Every
23
+ * event goes through here, exactly as GA4 rereads and rewrites its cookie per event — caching
24
+ * the session in memory would put the tabs back out of step with each other.
25
+ */
26
+ touch: (eventTime: number, lastEventTime?: number) => SessionForEvent;
27
+ /**
28
+ * The id for an event that must not start a session — the `pagehide` beacon, which reports what
29
+ * the session now ending accrued. A live session is extended, as any event extends it; one
30
+ * already past its timeout still owns that engagement, so its id comes back without being
31
+ * revived into a session no `session_start` ever announced.
32
+ */
33
+ extend: () => string;
13
34
  isActive: () => boolean;
14
35
  isVisible: () => boolean;
15
36
  isFocused: () => boolean;
16
- isExpired: () => boolean;
17
- updateLastActiveTime: () => void;
18
37
  updateActive: (active: boolean) => void;
19
- refresh: () => void;
20
38
  updateAccumulator: () => void;
21
39
  focus: () => void;
22
40
  blur: () => void;
@@ -1 +1 @@
1
- {"version":3,"file":"session.d.mts","names":[],"sources":["../../src/setup/session.ts"],"mappings":";cAEa;cAEP;UACI;UACA;UACA;UACA;UAEA;UACA;UACA;EAER;EAWA;EAEA;EACA;EACA;EACA;EAEA;EAIA,eAAY;EAIZ;EAKA;EAWA;EAMA;EAKA;EAMA;EAKA,mBAAgB,OAAW;EAQ3B;;;;;;;;;;;;;;;;;;;;;;;;;iBAiCc,cAAU"}
1
+ {"version":3,"file":"session.d.mts","names":[],"sources":["../../src/setup/session.ts"],"mappings":";cAIa;;UAiBH;EACR;EACA;;cA8BI;;;;;;UAMI;UACA;UAEA;UACA;UACA;EAER;;;;;;;EAeA,QAAK,mBAAqB,2BAA8B;;;;;;;EA4BxD;EAWA;EACA;EACA;EAEA,eAAY;EAIZ;EAWA;EAKA;EAKA;EAKA;EAKA,mBAAgB,OAAW;EAK3B;;;;;;;;;;;;;;;;;;;;;;;;;iBAiCc,cAAU"}
@@ -1,23 +1,75 @@
1
+ import { config } from "./index.mjs";
2
+ import { keys } from "../constants/storage.mjs";
1
3
  import { v7 } from "uuid";
2
4
  //#region src/setup/session.ts
3
5
  const SESSION_TIMEOUT = 1800 * 1e3;
6
+ /**
7
+ * `<version>.<id>.<lastEventTime>`, compact and cookie-safe rather than JSON: `{`, `"`
8
+ * and `,` all have to be percent-encoded in a cookie, and a host that wants one session across
9
+ * its subdomains will hand `setupAnalytics` a cookie-backed `storage`, where this has to survive
10
+ * unchanged. A uuidv7 contains no dots, so the record splits cleanly.
11
+ *
12
+ * The version guards a change the parser could not otherwise survive. A field appended to the end
13
+ * does not need one — a short record simply leaves it undefined.
14
+ */
15
+ const VERSION = "1";
16
+ function readSession() {
17
+ const raw = config.storage.getItem(keys.session);
18
+ if (!raw) return void 0;
19
+ const [version, id, lastEventTime] = raw.split(".");
20
+ if (version !== VERSION || !id) return void 0;
21
+ const parsed = {
22
+ id,
23
+ lastEventTime: Number(lastEventTime)
24
+ };
25
+ if (!Number.isFinite(parsed.lastEventTime)) return void 0;
26
+ return parsed;
27
+ }
28
+ function writeSession({ id, lastEventTime }) {
29
+ config.storage.setItem(keys.session, `${VERSION}.${id}.${lastEventTime}`);
30
+ }
4
31
  var Session = class {
5
32
  constructor() {
6
- this.getId = () => this.id;
33
+ this.touch = (eventTime, lastEventTime = eventTime) => {
34
+ const stored = readSession();
35
+ if (stored && eventTime - stored.lastEventTime <= 18e5) {
36
+ writeSession({
37
+ ...stored,
38
+ lastEventTime: Math.max(stored.lastEventTime, lastEventTime)
39
+ });
40
+ return {
41
+ id: stored.id,
42
+ started: false
43
+ };
44
+ }
45
+ this.accumulatedTime = 0;
46
+ this.startTime = Date.now();
47
+ const session = {
48
+ id: v7(),
49
+ lastEventTime
50
+ };
51
+ writeSession(session);
52
+ return {
53
+ id: session.id,
54
+ started: true
55
+ };
56
+ };
57
+ this.extend = () => {
58
+ const stored = readSession();
59
+ if (!stored) return this.touch(Date.now()).id;
60
+ const now = Date.now();
61
+ if (now - stored.lastEventTime <= 18e5) writeSession({
62
+ ...stored,
63
+ lastEventTime: now
64
+ });
65
+ return stored.id;
66
+ };
7
67
  this.isActive = () => this.active;
8
68
  this.isVisible = () => this.visible;
9
69
  this.isFocused = () => this.focused;
10
- this.isExpired = () => Date.now() - this.lastActiveTime > SESSION_TIMEOUT;
11
- this.updateLastActiveTime = () => {
12
- this.lastActiveTime = Date.now();
13
- };
14
70
  this.updateActive = (active) => {
15
71
  this.active = active;
16
72
  };
17
- this.refresh = () => {
18
- this.id = v7();
19
- this.lastActiveTime = Date.now();
20
- };
21
73
  this.updateAccumulator = () => {
22
74
  const now = Date.now();
23
75
  if (this.focused && this.visible && this.active) {
@@ -28,7 +80,6 @@ var Session = class {
28
80
  };
29
81
  this.focus = () => {
30
82
  this.updateAccumulator();
31
- this.updateLastActiveTime();
32
83
  this.focused = true;
33
84
  };
34
85
  this.blur = () => {
@@ -37,7 +88,6 @@ var Session = class {
37
88
  };
38
89
  this.pageshow = () => {
39
90
  this.updateAccumulator();
40
- this.updateLastActiveTime();
41
91
  this.active = true;
42
92
  };
43
93
  this.pagehide = () => {
@@ -46,7 +96,6 @@ var Session = class {
46
96
  };
47
97
  this.visibilitychange = (state) => {
48
98
  this.updateAccumulator();
49
- if (state === "visible") this.updateLastActiveTime();
50
99
  this.visible = state === "visible";
51
100
  };
52
101
  this.flush = () => {
@@ -55,9 +104,7 @@ var Session = class {
55
104
  this.accumulatedTime = 0;
56
105
  return engagementTime;
57
106
  };
58
- this.id = v7();
59
107
  this.startTime = Date.now();
60
- this.lastActiveTime = Date.now();
61
108
  this.accumulatedTime = 0;
62
109
  this.active = true;
63
110
  this.visible = typeof document !== "undefined" ? document.visibilityState === "visible" : true;
@@ -1 +1 @@
1
- {"version":3,"file":"session.mjs","names":["uuidv7"],"sources":["../../src/setup/session.ts"],"sourcesContent":["import { v7 as uuidv7 } from 'uuid';\n\nexport const SESSION_TIMEOUT = 30 * 60 * 1000;\n\nclass Session {\n private id: string;\n private startTime: number;\n private lastActiveTime: number;\n private accumulatedTime: number;\n\n private active: boolean;\n private visible: boolean;\n private focused: boolean;\n\n constructor() {\n this.id = uuidv7();\n this.startTime = Date.now();\n this.lastActiveTime = Date.now();\n this.accumulatedTime = 0;\n\n this.active = true;\n this.visible = typeof document !== 'undefined' ? document.visibilityState === 'visible' : true;\n this.focused = typeof document !== 'undefined' ? document.hasFocus() : true;\n }\n\n getId = () => this.id;\n\n isActive = () => this.active;\n isVisible = () => this.visible;\n isFocused = () => this.focused;\n isExpired = () => Date.now() - this.lastActiveTime > SESSION_TIMEOUT;\n\n updateLastActiveTime = () => {\n this.lastActiveTime = Date.now();\n };\n\n updateActive = (active: boolean) => {\n this.active = active;\n };\n\n refresh = () => {\n this.id = uuidv7();\n this.lastActiveTime = Date.now();\n };\n\n updateAccumulator = () => {\n const now = Date.now();\n if (this.focused && this.visible && this.active) {\n const delta = now - this.startTime;\n if (delta > 0 && delta < SESSION_TIMEOUT) {\n this.accumulatedTime += delta;\n }\n }\n this.startTime = now;\n };\n\n focus = () => {\n this.updateAccumulator();\n this.updateLastActiveTime();\n this.focused = true;\n };\n\n blur = () => {\n this.updateAccumulator();\n this.focused = false;\n };\n\n pageshow = () => {\n this.updateAccumulator();\n this.updateLastActiveTime();\n this.active = true;\n };\n\n pagehide = () => {\n this.updateAccumulator();\n this.active = false;\n };\n\n visibilitychange = (state: DocumentVisibilityState) => {\n this.updateAccumulator();\n if (state === 'visible') {\n this.updateLastActiveTime();\n }\n this.visible = state === 'visible';\n };\n\n flush = () => {\n this.updateAccumulator();\n const engagementTime = this.accumulatedTime;\n this.accumulatedTime = 0;\n return engagementTime;\n };\n}\n\nlet session: Session | undefined;\n\n/**\n * The session, built the first time something asks for it.\n *\n * Deliberately not a module-scope `new Session()`. The constructor calls\n * `uuidv7()`, `uuid` draws its bytes from `crypto.getRandomValues`, and\n * Cloudflare Workers reject that outside a request handler:\n *\n * Disallowed operation called within global scope. Asynchronous I/O\n * (ex: fetch() or connect()), setting a timeout, and generating random\n * values are not allowed within global scope.\n *\n * Module scope in a Worker is evaluated once when the isolate boots and is\n * then shared by every request that isolate serves, so a random value drawn\n * there would be the same for all of them — which is why the runtime refuses\n * to produce one. A host that server-renders on Workers reaches this module\n * through `track()` on the server as well, and the throw happened as the\n * isolate booted, taking down every route before a component rendered.\n *\n * Everything here is per-visitor browser or app state, so deferring the\n * construction costs nothing and buys two things: the server bundle can be\n * evaluated, and `startTime` marks when the session actually began rather\n * than when the isolate happened to start.\n */\nexport function getSession() {\n return (session ??= new Session());\n}\n"],"mappings":";;AAEA,MAAa,kBAAkB,OAAU;AAEzC,IAAM,UAAN,MAAc;CAUZ,cAAc;EAWA,KAAA,cAAA,KAAK;EAEF,KAAA,iBAAA,KAAK;EACJ,KAAA,kBAAA,KAAK;EACL,KAAA,kBAAA,KAAK;EACL,KAAA,kBAAA,KAAK,IAAI,IAAI,KAAK,iBAAiB;EAExB,KAAA,6BAAA;GAC3B,KAAK,iBAAiB,KAAK,IAAI;EACjC;EAEgB,KAAA,gBAAA,WAAoB;GAClC,KAAK,SAAS;EAChB;EAEgB,KAAA,gBAAA;GACd,KAAK,KAAKA,GAAO;GACjB,KAAK,iBAAiB,KAAK,IAAI;EACjC;EAE0B,KAAA,0BAAA;GACxB,MAAM,MAAM,KAAK,IAAI;GACrB,IAAI,KAAK,WAAW,KAAK,WAAW,KAAK,QAAQ;IAC/C,MAAM,QAAQ,MAAM,KAAK;IACzB,IAAI,QAAQ,KAAK,QAAA,MACf,KAAK,mBAAmB;GAE5B;GACA,KAAK,YAAY;EACnB;EAEc,KAAA,cAAA;GACZ,KAAK,kBAAkB;GACvB,KAAK,qBAAqB;GAC1B,KAAK,UAAU;EACjB;EAEa,KAAA,aAAA;GACX,KAAK,kBAAkB;GACvB,KAAK,UAAU;EACjB;EAEiB,KAAA,iBAAA;GACf,KAAK,kBAAkB;GACvB,KAAK,qBAAqB;GAC1B,KAAK,SAAS;EAChB;EAEiB,KAAA,iBAAA;GACf,KAAK,kBAAkB;GACvB,KAAK,SAAS;EAChB;EAEoB,KAAA,oBAAA,UAAmC;GACrD,KAAK,kBAAkB;GACvB,IAAI,UAAU,WACZ,KAAK,qBAAqB;GAE5B,KAAK,UAAU,UAAU;EAC3B;EAEc,KAAA,cAAA;GACZ,KAAK,kBAAkB;GACvB,MAAM,iBAAiB,KAAK;GAC5B,KAAK,kBAAkB;GACvB,OAAO;EACT;EA5EE,KAAK,KAAKA,GAAO;EACjB,KAAK,YAAY,KAAK,IAAI;EAC1B,KAAK,iBAAiB,KAAK,IAAI;EAC/B,KAAK,kBAAkB;EAEvB,KAAK,SAAS;EACd,KAAK,UAAU,OAAO,aAAa,cAAc,SAAS,oBAAoB,YAAY;EAC1F,KAAK,UAAU,OAAO,aAAa,cAAc,SAAS,SAAS,IAAI;CACzE;AAqEF;AAEA,IAAI;;;;;;;;;;;;;;;;;;;;;;;;AAyBJ,SAAgB,aAAa;CAC3B,OAAQ,YAAY,IAAI,QAAQ;AAClC"}
1
+ {"version":3,"file":"session.mjs","names":["uuidv7"],"sources":["../../src/setup/session.ts"],"sourcesContent":["import { v7 as uuidv7 } from 'uuid';\nimport { keys } from '../constants/storage';\nimport { config } from './index';\n\nexport const SESSION_TIMEOUT = 30 * 60 * 1000;\n\n/**\n * The part of a session that outlives the page: its identity, and the clock the timeout is\n * measured against. GA4 keeps these in its `_ga_<container>` cookie — alongside the engaged flag,\n * which arrives with `session_engaged` later — and rereads them for every event, which is what\n * makes a session survive a reload, span a whole multipage visit, and stay shared between two\n * tabs of the same site. Holding them in memory instead, as this did, starts a new session on\n * every full navigation and gives every tab one of its own.\n */\ninterface StoredSession {\n id: string;\n /** When the last event was sent. The timeout is measured from here, and only from here. */\n lastEventTime: number;\n}\n\n/** The session an event belongs to, and whether that event is the one that started it. */\ninterface SessionForEvent {\n id: string;\n started: boolean;\n}\n\n/**\n * `<version>.<id>.<lastEventTime>`, compact and cookie-safe rather than JSON: `{`, `\"`\n * and `,` all have to be percent-encoded in a cookie, and a host that wants one session across\n * its subdomains will hand `setupAnalytics` a cookie-backed `storage`, where this has to survive\n * unchanged. A uuidv7 contains no dots, so the record splits cleanly.\n *\n * The version guards a change the parser could not otherwise survive. A field appended to the end\n * does not need one — a short record simply leaves it undefined.\n */\nconst VERSION = '1';\n\nfunction readSession(): StoredSession | undefined {\n const raw = config.storage.getItem(keys.session);\n if (!raw) return undefined;\n\n const [version, id, lastEventTime] = raw.split('.');\n if (version !== VERSION || !id) return undefined;\n\n const parsed = { id, lastEventTime: Number(lastEventTime) };\n if (!Number.isFinite(parsed.lastEventTime)) return undefined;\n return parsed;\n}\n\nfunction writeSession({ id, lastEventTime }: StoredSession) {\n config.storage.setItem(keys.session, `${VERSION}.${id}.${lastEventTime}`);\n}\n\nclass Session {\n /**\n * Engagement is deliberately not stored: it is the time this page has accrued and not yet\n * reported, so it belongs to the page, not to the session. GA4 draws the same line — its cookie\n * carries the session, while the engagement timer lives and dies with the document.\n */\n private startTime: number;\n private accumulatedTime: number;\n\n private active: boolean;\n private visible: boolean;\n private focused: boolean;\n\n constructor() {\n this.startTime = Date.now();\n this.accumulatedTime = 0;\n\n this.active = true;\n this.visible = typeof document !== 'undefined' ? document.visibilityState === 'visible' : true;\n this.focused = typeof document !== 'undefined' ? document.hasFocus() : true;\n }\n\n /**\n * The session a batch of events belongs to: read the stored one, start a new one if it has\n * timed out or there is none, stamp it with when those events happened and write it back. Every\n * event goes through here, exactly as GA4 rereads and rewrites its cookie per event — caching\n * the session in memory would put the tabs back out of step with each other.\n */\n touch = (eventTime: number, lastEventTime = eventTime): SessionForEvent => {\n const stored = readSession();\n\n if (stored && eventTime - stored.lastEventTime <= SESSION_TIMEOUT) {\n // `Math.max`, because a batch that waited in a frozen tab can be older than what another\n // tab has since written, and a session must never be shortened by a late arrival.\n writeSession({ ...stored, lastEventTime: Math.max(stored.lastEventTime, lastEventTime) });\n return { id: stored.id, started: false };\n }\n\n // Engagement the previous session accrued but never reported dies with it rather than being\n // handed to its successor. GA4 does the same on `session_start`.\n this.accumulatedTime = 0;\n // Wall clock, not `eventTime`: this anchors the engagement timer for the page in front of the\n // visitor now, which a batch describing something that happened an hour ago says nothing about.\n this.startTime = Date.now();\n\n const session: StoredSession = { id: uuidv7(), lastEventTime };\n writeSession(session);\n return { id: session.id, started: true };\n };\n\n /**\n * The id for an event that must not start a session — the `pagehide` beacon, which reports what\n * the session now ending accrued. A live session is extended, as any event extends it; one\n * already past its timeout still owns that engagement, so its id comes back without being\n * revived into a session no `session_start` ever announced.\n */\n extend = (): string => {\n const stored = readSession();\n if (!stored) return this.touch(Date.now()).id;\n\n const now = Date.now();\n if (now - stored.lastEventTime <= SESSION_TIMEOUT) {\n writeSession({ ...stored, lastEventTime: now });\n }\n return stored.id;\n };\n\n isActive = () => this.active;\n isVisible = () => this.visible;\n isFocused = () => this.focused;\n\n updateActive = (active: boolean) => {\n this.active = active;\n };\n\n updateAccumulator = () => {\n const now = Date.now();\n if (this.focused && this.visible && this.active) {\n const delta = now - this.startTime;\n if (delta > 0 && delta < SESSION_TIMEOUT) {\n this.accumulatedTime += delta;\n }\n }\n this.startTime = now;\n };\n\n focus = () => {\n this.updateAccumulator();\n this.focused = true;\n };\n\n blur = () => {\n this.updateAccumulator();\n this.focused = false;\n };\n\n pageshow = () => {\n this.updateAccumulator();\n this.active = true;\n };\n\n pagehide = () => {\n this.updateAccumulator();\n this.active = false;\n };\n\n visibilitychange = (state: DocumentVisibilityState) => {\n this.updateAccumulator();\n this.visible = state === 'visible';\n };\n\n flush = () => {\n this.updateAccumulator();\n const engagementTime = this.accumulatedTime;\n this.accumulatedTime = 0;\n return engagementTime;\n };\n}\n\nlet session: Session | undefined;\n\n/**\n * The session, built the first time something asks for it.\n *\n * Deliberately not a module-scope `new Session()`. The constructor calls\n * `uuidv7()`, `uuid` draws its bytes from `crypto.getRandomValues`, and\n * Cloudflare Workers reject that outside a request handler:\n *\n * Disallowed operation called within global scope. Asynchronous I/O\n * (ex: fetch() or connect()), setting a timeout, and generating random\n * values are not allowed within global scope.\n *\n * Module scope in a Worker is evaluated once when the isolate boots and is\n * then shared by every request that isolate serves, so a random value drawn\n * there would be the same for all of them — which is why the runtime refuses\n * to produce one. A host that server-renders on Workers reaches this module\n * through `track()` on the server as well, and the throw happened as the\n * isolate booted, taking down every route before a component rendered.\n *\n * Everything here is per-visitor browser or app state, so deferring the\n * construction costs nothing and buys two things: the server bundle can be\n * evaluated, and `startTime` marks when the session actually began rather\n * than when the isolate happened to start.\n */\nexport function getSession() {\n return (session ??= new Session());\n}\n"],"mappings":";;;;AAIA,MAAa,kBAAkB,OAAU;;;;;;;;;;AA+BzC,MAAM,UAAU;AAEhB,SAAS,cAAyC;CAChD,MAAM,MAAM,OAAO,QAAQ,QAAQ,KAAK,OAAO;CAC/C,IAAI,CAAC,KAAK,OAAO,KAAA;CAEjB,MAAM,CAAC,SAAS,IAAI,iBAAiB,IAAI,MAAM,GAAG;CAClD,IAAI,YAAY,WAAW,CAAC,IAAI,OAAO,KAAA;CAEvC,MAAM,SAAS;EAAE;EAAI,eAAe,OAAO,aAAa;CAAE;CAC1D,IAAI,CAAC,OAAO,SAAS,OAAO,aAAa,GAAG,OAAO,KAAA;CACnD,OAAO;AACT;AAEA,SAAS,aAAa,EAAE,IAAI,iBAAgC;CAC1D,OAAO,QAAQ,QAAQ,KAAK,SAAS,GAAG,QAAQ,GAAG,GAAG,GAAG,eAAe;AAC1E;AAEA,IAAM,UAAN,MAAc;CAaZ,cAAc;EAeL,KAAA,SAAA,WAAmB,gBAAgB,cAA+B;GACzE,MAAM,SAAS,YAAY;GAE3B,IAAI,UAAU,YAAY,OAAO,iBAAA,MAAkC;IAGjE,aAAa;KAAE,GAAG;KAAQ,eAAe,KAAK,IAAI,OAAO,eAAe,aAAa;IAAE,CAAC;IACxF,OAAO;KAAE,IAAI,OAAO;KAAI,SAAS;IAAM;GACzC;GAIA,KAAK,kBAAkB;GAGvB,KAAK,YAAY,KAAK,IAAI;GAE1B,MAAM,UAAyB;IAAE,IAAIA,GAAO;IAAG;GAAc;GAC7D,aAAa,OAAO;GACpB,OAAO;IAAE,IAAI,QAAQ;IAAI,SAAS;GAAK;EACzC;EAQuB,KAAA,eAAA;GACrB,MAAM,SAAS,YAAY;GAC3B,IAAI,CAAC,QAAQ,OAAO,KAAK,MAAM,KAAK,IAAI,CAAC,CAAC,CAAC;GAE3C,MAAM,MAAM,KAAK,IAAI;GACrB,IAAI,MAAM,OAAO,iBAAA,MACf,aAAa;IAAE,GAAG;IAAQ,eAAe;GAAI,CAAC;GAEhD,OAAO,OAAO;EAChB;EAEiB,KAAA,iBAAA,KAAK;EACJ,KAAA,kBAAA,KAAK;EACL,KAAA,kBAAA,KAAK;EAEP,KAAA,gBAAA,WAAoB;GAClC,KAAK,SAAS;EAChB;EAE0B,KAAA,0BAAA;GACxB,MAAM,MAAM,KAAK,IAAI;GACrB,IAAI,KAAK,WAAW,KAAK,WAAW,KAAK,QAAQ;IAC/C,MAAM,QAAQ,MAAM,KAAK;IACzB,IAAI,QAAQ,KAAK,QAAA,MACf,KAAK,mBAAmB;GAE5B;GACA,KAAK,YAAY;EACnB;EAEc,KAAA,cAAA;GACZ,KAAK,kBAAkB;GACvB,KAAK,UAAU;EACjB;EAEa,KAAA,aAAA;GACX,KAAK,kBAAkB;GACvB,KAAK,UAAU;EACjB;EAEiB,KAAA,iBAAA;GACf,KAAK,kBAAkB;GACvB,KAAK,SAAS;EAChB;EAEiB,KAAA,iBAAA;GACf,KAAK,kBAAkB;GACvB,KAAK,SAAS;EAChB;EAEoB,KAAA,oBAAA,UAAmC;GACrD,KAAK,kBAAkB;GACvB,KAAK,UAAU,UAAU;EAC3B;EAEc,KAAA,cAAA;GACZ,KAAK,kBAAkB;GACvB,MAAM,iBAAiB,KAAK;GAC5B,KAAK,kBAAkB;GACvB,OAAO;EACT;EAtGE,KAAK,YAAY,KAAK,IAAI;EAC1B,KAAK,kBAAkB;EAEvB,KAAK,SAAS;EACd,KAAK,UAAU,OAAO,aAAa,cAAc,SAAS,oBAAoB,YAAY;EAC1F,KAAK,UAAU,OAAO,aAAa,cAAc,SAAS,SAAS,IAAI;CACzE;AAiGF;AAEA,IAAI;;;;;;;;;;;;;;;;;;;;;;;;AAyBJ,SAAgB,aAAa;CAC3B,OAAQ,YAAY,IAAI,QAAQ;AAClC"}
@@ -1 +1 @@
1
- {"version":3,"file":"middleware.cjs","names":["resolveClickIdCookies","toSetCookieHeaders"],"sources":["../../src/tanstack/middleware.ts"],"sourcesContent":["import { createMiddleware } from '@tanstack/react-start';\nimport { resolveClickIdCookies, toSetCookieHeaders } from '../click-id/index';\n\nexport interface ClickIdMiddlewareOptions {\n /** `Domain` attribute for the cookies, e.g. `.edensign.io`. Omit for a host-only cookie. */\n domain?: string;\n /** `Secure` attribute, default true. Set false only for local http testing. */\n secure?: boolean;\n /** subdomainIndex for a freshly built `_fbc` (com=0, example.com=1, www.example.com=2). Default 1. */\n subdomainIndex?: number;\n /**\n * Re-issue a still-valid `_fbc` on every request as an ITP self-heal (restores the long-lived\n * HTTP cookie if the Meta Pixel's `document.cookie` write re-capped it to 24h in Safari). On by\n * default. Note it attaches a per-user `Set-Cookie` — and thus `no-store` — to every page\n * response carrying an `_fbc`, defeating CDN caching of those pages; set false to strictly follow\n * Meta's conditional-write rule and keep them cacheable. See {@link resolveClickIdCookies}.\n */\n refresh?: boolean;\n /**\n * Override the `Cache-Control` of a response we attach cookies to (default `private, no-store`).\n * A per-user `Set-Cookie` must never end up on a shared-cache entry, or one visitor's `_fbc` would\n * be served to everyone. Only set this false if you guarantee these responses are never cached.\n */\n cacheControl?: string | false;\n /**\n * Consent gate. Return false to skip setting cookies for this request (e.g. before the visitor has\n * granted consent where required). Runs per request with the incoming `Request`.\n */\n shouldPersist?: (request: Request) => boolean;\n}\n\n/**\n * TanStack Start request middleware that persists ad click-id cookies (`_fbc`, `_rdt_cid`) on the\n * document response.\n *\n * Setting `_fbc` here — on the top document via an HTTP `Set-Cookie` header, before any client JS\n * runs — is what Meta officially recommends and the only reliable way to keep the cookie alive for\n * 90 days in Safari: ITP caps JavaScript-set cookies on a fbclid-decorated landing page to 24\n * hours, and a document response is never classified as CNAME/IP cloaking (it is the reference the\n * browser measures cloaking against).\n *\n * Register it as a global request middleware:\n * ```ts\n * // start.ts\n * import { createStart } from '@tanstack/react-start'\n * import { clickIdMiddleware } from '@shware/analytics/tanstack'\n * export const startInstance = createStart(() => ({ requestMiddleware: [clickIdMiddleware] }))\n * ```\n *\n * reference: https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/fbp-and-fbc\n */\nexport function createClickIdMiddleware(options: ClickIdMiddlewareOptions = {}) {\n const { cacheControl = 'private, no-store' } = options;\n\n return createMiddleware({ type: 'request' }).server(async ({ request, next, handlerType }) => {\n const result = await next();\n\n // Skip serverFn RPC responses. 'router' covers SSR document requests *and* custom server\n // routes (API endpoints) — those also get cookies when the URL carries a click id.\n if (handlerType !== 'router') return result;\n if (options.shouldPersist && !options.shouldPersist(request)) return result;\n\n const { cookies } = resolveClickIdCookies({\n url: request.url,\n cookieHeader: request.headers.get('cookie'),\n domain: options.domain,\n secure: options.secure,\n subdomainIndex: options.subdomainIndex,\n refresh: options.refresh ?? true,\n });\n\n if (cookies.length > 0) {\n for (const header of toSetCookieHeaders(cookies)) {\n result.response.headers.append('set-cookie', header);\n }\n if (cacheControl !== false) {\n result.response.headers.set('cache-control', cacheControl);\n }\n }\n\n return result;\n });\n}\n\n/** Ready-to-register middleware with default options. */\nexport const clickIdMiddleware = createClickIdMiddleware();\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;AAmDA,SAAgB,wBAAwB,UAAoC,CAAC,GAAG;CAC9E,MAAM,EAAE,eAAe,wBAAwB;CAE/C,QAAA,GAAA,sBAAA,iBAAA,CAAwB,EAAE,MAAM,UAAU,CAAC,CAAC,CAAC,OAAO,OAAO,EAAE,SAAS,MAAM,kBAAkB;EAC5F,MAAM,SAAS,MAAM,KAAK;EAI1B,IAAI,gBAAgB,UAAU,OAAO;EACrC,IAAI,QAAQ,iBAAiB,CAAC,QAAQ,cAAc,OAAO,GAAG,OAAO;EAErE,MAAM,EAAE,YAAYA,uBAAAA,sBAAsB;GACxC,KAAK,QAAQ;GACb,cAAc,QAAQ,QAAQ,IAAI,QAAQ;GAC1C,QAAQ,QAAQ;GAChB,QAAQ,QAAQ;GAChB,gBAAgB,QAAQ;GACxB,SAAS,QAAQ,WAAW;EAC9B,CAAC;EAED,IAAI,QAAQ,SAAS,GAAG;GACtB,KAAK,MAAM,UAAUC,uBAAAA,mBAAmB,OAAO,GAC7C,OAAO,SAAS,QAAQ,OAAO,cAAc,MAAM;GAErD,IAAI,iBAAiB,OACnB,OAAO,SAAS,QAAQ,IAAI,iBAAiB,YAAY;EAE7D;EAEA,OAAO;CACT,CAAC;AACH;;AAGA,MAAa,oBAAoB,wBAAwB"}
1
+ {"version":3,"file":"middleware.cjs","names":["resolveClickIdCookies","toSetCookieHeaders"],"sources":["../../src/tanstack/middleware.ts"],"sourcesContent":["import { createMiddleware } from '@tanstack/react-start';\nimport { resolveClickIdCookies, toSetCookieHeaders } from '../click-id/index';\n\nexport interface ClickIdMiddlewareOptions {\n /** `Domain` attribute for the cookies, e.g. `.shware.io`. Omit for a host-only cookie. */\n domain?: string;\n /** `Secure` attribute, default true. Set false only for local http testing. */\n secure?: boolean;\n /** subdomainIndex for a freshly built `_fbc` (com=0, example.com=1, www.example.com=2). Default 1. */\n subdomainIndex?: number;\n /**\n * Re-issue a still-valid `_fbc` on every request as an ITP self-heal (restores the long-lived\n * HTTP cookie if the Meta Pixel's `document.cookie` write re-capped it to 24h in Safari). On by\n * default. Note it attaches a per-user `Set-Cookie` — and thus `no-store` — to every page\n * response carrying an `_fbc`, defeating CDN caching of those pages; set false to strictly follow\n * Meta's conditional-write rule and keep them cacheable. See {@link resolveClickIdCookies}.\n */\n refresh?: boolean;\n /**\n * Override the `Cache-Control` of a response we attach cookies to (default `private, no-store`).\n * A per-user `Set-Cookie` must never end up on a shared-cache entry, or one visitor's `_fbc` would\n * be served to everyone. Only set this false if you guarantee these responses are never cached.\n */\n cacheControl?: string | false;\n /**\n * Consent gate. Return false to skip setting cookies for this request (e.g. before the visitor has\n * granted consent where required). Runs per request with the incoming `Request`.\n */\n shouldPersist?: (request: Request) => boolean;\n}\n\n/**\n * TanStack Start request middleware that persists ad click-id cookies (`_fbc`, `_rdt_cid`) on the\n * document response.\n *\n * Setting `_fbc` here — on the top document via an HTTP `Set-Cookie` header, before any client JS\n * runs — is what Meta officially recommends and the only reliable way to keep the cookie alive for\n * 90 days in Safari: ITP caps JavaScript-set cookies on a fbclid-decorated landing page to 24\n * hours, and a document response is never classified as CNAME/IP cloaking (it is the reference the\n * browser measures cloaking against).\n *\n * Register it as a global request middleware:\n * ```ts\n * // start.ts\n * import { createStart } from '@tanstack/react-start'\n * import { clickIdMiddleware } from '@shware/analytics/tanstack'\n * export const startInstance = createStart(() => ({ requestMiddleware: [clickIdMiddleware] }))\n * ```\n *\n * reference: https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/fbp-and-fbc\n */\nexport function createClickIdMiddleware(options: ClickIdMiddlewareOptions = {}) {\n const { cacheControl = 'private, no-store' } = options;\n\n return createMiddleware({ type: 'request' }).server(async ({ request, next, handlerType }) => {\n const result = await next();\n\n // Skip serverFn RPC responses. 'router' covers SSR document requests *and* custom server\n // routes (API endpoints) — those also get cookies when the URL carries a click id.\n if (handlerType !== 'router') return result;\n if (options.shouldPersist && !options.shouldPersist(request)) return result;\n\n const { cookies } = resolveClickIdCookies({\n url: request.url,\n cookieHeader: request.headers.get('cookie'),\n domain: options.domain,\n secure: options.secure,\n subdomainIndex: options.subdomainIndex,\n refresh: options.refresh ?? true,\n });\n\n if (cookies.length > 0) {\n for (const header of toSetCookieHeaders(cookies)) {\n result.response.headers.append('set-cookie', header);\n }\n if (cacheControl !== false) {\n result.response.headers.set('cache-control', cacheControl);\n }\n }\n\n return result;\n });\n}\n\n/** Ready-to-register middleware with default options. */\nexport const clickIdMiddleware = createClickIdMiddleware();\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;AAmDA,SAAgB,wBAAwB,UAAoC,CAAC,GAAG;CAC9E,MAAM,EAAE,eAAe,wBAAwB;CAE/C,QAAA,GAAA,sBAAA,iBAAA,CAAwB,EAAE,MAAM,UAAU,CAAC,CAAC,CAAC,OAAO,OAAO,EAAE,SAAS,MAAM,kBAAkB;EAC5F,MAAM,SAAS,MAAM,KAAK;EAI1B,IAAI,gBAAgB,UAAU,OAAO;EACrC,IAAI,QAAQ,iBAAiB,CAAC,QAAQ,cAAc,OAAO,GAAG,OAAO;EAErE,MAAM,EAAE,YAAYA,uBAAAA,sBAAsB;GACxC,KAAK,QAAQ;GACb,cAAc,QAAQ,QAAQ,IAAI,QAAQ;GAC1C,QAAQ,QAAQ;GAChB,QAAQ,QAAQ;GAChB,gBAAgB,QAAQ;GACxB,SAAS,QAAQ,WAAW;EAC9B,CAAC;EAED,IAAI,QAAQ,SAAS,GAAG;GACtB,KAAK,MAAM,UAAUC,uBAAAA,mBAAmB,OAAO,GAC7C,OAAO,SAAS,QAAQ,OAAO,cAAc,MAAM;GAErD,IAAI,iBAAiB,OACnB,OAAO,SAAS,QAAQ,IAAI,iBAAiB,YAAY;EAE7D;EAEA,OAAO;CACT,CAAC;AACH;;AAGA,MAAa,oBAAoB,wBAAwB"}
@@ -1,6 +1,6 @@
1
1
  //#region src/tanstack/middleware.d.ts
2
2
  interface ClickIdMiddlewareOptions {
3
- /** `Domain` attribute for the cookies, e.g. `.edensign.io`. Omit for a host-only cookie. */
3
+ /** `Domain` attribute for the cookies, e.g. `.shware.io`. Omit for a host-only cookie. */
4
4
  domain?: string;
5
5
  /** `Secure` attribute, default true. Set false only for local http testing. */
6
6
  secure?: boolean;
@@ -1,6 +1,6 @@
1
1
  //#region src/tanstack/middleware.d.ts
2
2
  interface ClickIdMiddlewareOptions {
3
- /** `Domain` attribute for the cookies, e.g. `.edensign.io`. Omit for a host-only cookie. */
3
+ /** `Domain` attribute for the cookies, e.g. `.shware.io`. Omit for a host-only cookie. */
4
4
  domain?: string;
5
5
  /** `Secure` attribute, default true. Set false only for local http testing. */
6
6
  secure?: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"middleware.mjs","names":[],"sources":["../../src/tanstack/middleware.ts"],"sourcesContent":["import { createMiddleware } from '@tanstack/react-start';\nimport { resolveClickIdCookies, toSetCookieHeaders } from '../click-id/index';\n\nexport interface ClickIdMiddlewareOptions {\n /** `Domain` attribute for the cookies, e.g. `.edensign.io`. Omit for a host-only cookie. */\n domain?: string;\n /** `Secure` attribute, default true. Set false only for local http testing. */\n secure?: boolean;\n /** subdomainIndex for a freshly built `_fbc` (com=0, example.com=1, www.example.com=2). Default 1. */\n subdomainIndex?: number;\n /**\n * Re-issue a still-valid `_fbc` on every request as an ITP self-heal (restores the long-lived\n * HTTP cookie if the Meta Pixel's `document.cookie` write re-capped it to 24h in Safari). On by\n * default. Note it attaches a per-user `Set-Cookie` — and thus `no-store` — to every page\n * response carrying an `_fbc`, defeating CDN caching of those pages; set false to strictly follow\n * Meta's conditional-write rule and keep them cacheable. See {@link resolveClickIdCookies}.\n */\n refresh?: boolean;\n /**\n * Override the `Cache-Control` of a response we attach cookies to (default `private, no-store`).\n * A per-user `Set-Cookie` must never end up on a shared-cache entry, or one visitor's `_fbc` would\n * be served to everyone. Only set this false if you guarantee these responses are never cached.\n */\n cacheControl?: string | false;\n /**\n * Consent gate. Return false to skip setting cookies for this request (e.g. before the visitor has\n * granted consent where required). Runs per request with the incoming `Request`.\n */\n shouldPersist?: (request: Request) => boolean;\n}\n\n/**\n * TanStack Start request middleware that persists ad click-id cookies (`_fbc`, `_rdt_cid`) on the\n * document response.\n *\n * Setting `_fbc` here — on the top document via an HTTP `Set-Cookie` header, before any client JS\n * runs — is what Meta officially recommends and the only reliable way to keep the cookie alive for\n * 90 days in Safari: ITP caps JavaScript-set cookies on a fbclid-decorated landing page to 24\n * hours, and a document response is never classified as CNAME/IP cloaking (it is the reference the\n * browser measures cloaking against).\n *\n * Register it as a global request middleware:\n * ```ts\n * // start.ts\n * import { createStart } from '@tanstack/react-start'\n * import { clickIdMiddleware } from '@shware/analytics/tanstack'\n * export const startInstance = createStart(() => ({ requestMiddleware: [clickIdMiddleware] }))\n * ```\n *\n * reference: https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/fbp-and-fbc\n */\nexport function createClickIdMiddleware(options: ClickIdMiddlewareOptions = {}) {\n const { cacheControl = 'private, no-store' } = options;\n\n return createMiddleware({ type: 'request' }).server(async ({ request, next, handlerType }) => {\n const result = await next();\n\n // Skip serverFn RPC responses. 'router' covers SSR document requests *and* custom server\n // routes (API endpoints) — those also get cookies when the URL carries a click id.\n if (handlerType !== 'router') return result;\n if (options.shouldPersist && !options.shouldPersist(request)) return result;\n\n const { cookies } = resolveClickIdCookies({\n url: request.url,\n cookieHeader: request.headers.get('cookie'),\n domain: options.domain,\n secure: options.secure,\n subdomainIndex: options.subdomainIndex,\n refresh: options.refresh ?? true,\n });\n\n if (cookies.length > 0) {\n for (const header of toSetCookieHeaders(cookies)) {\n result.response.headers.append('set-cookie', header);\n }\n if (cacheControl !== false) {\n result.response.headers.set('cache-control', cacheControl);\n }\n }\n\n return result;\n });\n}\n\n/** Ready-to-register middleware with default options. */\nexport const clickIdMiddleware = createClickIdMiddleware();\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;AAmDA,SAAgB,wBAAwB,UAAoC,CAAC,GAAG;CAC9E,MAAM,EAAE,eAAe,wBAAwB;CAE/C,OAAO,iBAAiB,EAAE,MAAM,UAAU,CAAC,CAAC,CAAC,OAAO,OAAO,EAAE,SAAS,MAAM,kBAAkB;EAC5F,MAAM,SAAS,MAAM,KAAK;EAI1B,IAAI,gBAAgB,UAAU,OAAO;EACrC,IAAI,QAAQ,iBAAiB,CAAC,QAAQ,cAAc,OAAO,GAAG,OAAO;EAErE,MAAM,EAAE,YAAY,sBAAsB;GACxC,KAAK,QAAQ;GACb,cAAc,QAAQ,QAAQ,IAAI,QAAQ;GAC1C,QAAQ,QAAQ;GAChB,QAAQ,QAAQ;GAChB,gBAAgB,QAAQ;GACxB,SAAS,QAAQ,WAAW;EAC9B,CAAC;EAED,IAAI,QAAQ,SAAS,GAAG;GACtB,KAAK,MAAM,UAAU,mBAAmB,OAAO,GAC7C,OAAO,SAAS,QAAQ,OAAO,cAAc,MAAM;GAErD,IAAI,iBAAiB,OACnB,OAAO,SAAS,QAAQ,IAAI,iBAAiB,YAAY;EAE7D;EAEA,OAAO;CACT,CAAC;AACH;;AAGA,MAAa,oBAAoB,wBAAwB"}
1
+ {"version":3,"file":"middleware.mjs","names":[],"sources":["../../src/tanstack/middleware.ts"],"sourcesContent":["import { createMiddleware } from '@tanstack/react-start';\nimport { resolveClickIdCookies, toSetCookieHeaders } from '../click-id/index';\n\nexport interface ClickIdMiddlewareOptions {\n /** `Domain` attribute for the cookies, e.g. `.shware.io`. Omit for a host-only cookie. */\n domain?: string;\n /** `Secure` attribute, default true. Set false only for local http testing. */\n secure?: boolean;\n /** subdomainIndex for a freshly built `_fbc` (com=0, example.com=1, www.example.com=2). Default 1. */\n subdomainIndex?: number;\n /**\n * Re-issue a still-valid `_fbc` on every request as an ITP self-heal (restores the long-lived\n * HTTP cookie if the Meta Pixel's `document.cookie` write re-capped it to 24h in Safari). On by\n * default. Note it attaches a per-user `Set-Cookie` — and thus `no-store` — to every page\n * response carrying an `_fbc`, defeating CDN caching of those pages; set false to strictly follow\n * Meta's conditional-write rule and keep them cacheable. See {@link resolveClickIdCookies}.\n */\n refresh?: boolean;\n /**\n * Override the `Cache-Control` of a response we attach cookies to (default `private, no-store`).\n * A per-user `Set-Cookie` must never end up on a shared-cache entry, or one visitor's `_fbc` would\n * be served to everyone. Only set this false if you guarantee these responses are never cached.\n */\n cacheControl?: string | false;\n /**\n * Consent gate. Return false to skip setting cookies for this request (e.g. before the visitor has\n * granted consent where required). Runs per request with the incoming `Request`.\n */\n shouldPersist?: (request: Request) => boolean;\n}\n\n/**\n * TanStack Start request middleware that persists ad click-id cookies (`_fbc`, `_rdt_cid`) on the\n * document response.\n *\n * Setting `_fbc` here — on the top document via an HTTP `Set-Cookie` header, before any client JS\n * runs — is what Meta officially recommends and the only reliable way to keep the cookie alive for\n * 90 days in Safari: ITP caps JavaScript-set cookies on a fbclid-decorated landing page to 24\n * hours, and a document response is never classified as CNAME/IP cloaking (it is the reference the\n * browser measures cloaking against).\n *\n * Register it as a global request middleware:\n * ```ts\n * // start.ts\n * import { createStart } from '@tanstack/react-start'\n * import { clickIdMiddleware } from '@shware/analytics/tanstack'\n * export const startInstance = createStart(() => ({ requestMiddleware: [clickIdMiddleware] }))\n * ```\n *\n * reference: https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/fbp-and-fbc\n */\nexport function createClickIdMiddleware(options: ClickIdMiddlewareOptions = {}) {\n const { cacheControl = 'private, no-store' } = options;\n\n return createMiddleware({ type: 'request' }).server(async ({ request, next, handlerType }) => {\n const result = await next();\n\n // Skip serverFn RPC responses. 'router' covers SSR document requests *and* custom server\n // routes (API endpoints) — those also get cookies when the URL carries a click id.\n if (handlerType !== 'router') return result;\n if (options.shouldPersist && !options.shouldPersist(request)) return result;\n\n const { cookies } = resolveClickIdCookies({\n url: request.url,\n cookieHeader: request.headers.get('cookie'),\n domain: options.domain,\n secure: options.secure,\n subdomainIndex: options.subdomainIndex,\n refresh: options.refresh ?? true,\n });\n\n if (cookies.length > 0) {\n for (const header of toSetCookieHeaders(cookies)) {\n result.response.headers.append('set-cookie', header);\n }\n if (cacheControl !== false) {\n result.response.headers.set('cache-control', cacheControl);\n }\n }\n\n return result;\n });\n}\n\n/** Ready-to-register middleware with default options. */\nexport const clickIdMiddleware = createClickIdMiddleware();\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;AAmDA,SAAgB,wBAAwB,UAAoC,CAAC,GAAG;CAC9E,MAAM,EAAE,eAAe,wBAAwB;CAE/C,OAAO,iBAAiB,EAAE,MAAM,UAAU,CAAC,CAAC,CAAC,OAAO,OAAO,EAAE,SAAS,MAAM,kBAAkB;EAC5F,MAAM,SAAS,MAAM,KAAK;EAI1B,IAAI,gBAAgB,UAAU,OAAO;EACrC,IAAI,QAAQ,iBAAiB,CAAC,QAAQ,cAAc,OAAO,GAAG,OAAO;EAErE,MAAM,EAAE,YAAY,sBAAsB;GACxC,KAAK,QAAQ;GACb,cAAc,QAAQ,QAAQ,IAAI,QAAQ;GAC1C,QAAQ,QAAQ;GAChB,QAAQ,QAAQ;GAChB,gBAAgB,QAAQ;GACxB,SAAS,QAAQ,WAAW;EAC9B,CAAC;EAED,IAAI,QAAQ,SAAS,GAAG;GACtB,KAAK,MAAM,UAAU,mBAAmB,OAAO,GAC7C,OAAO,SAAS,QAAQ,OAAO,cAAc,MAAM;GAErD,IAAI,iBAAiB,OACnB,OAAO,SAAS,QAAQ,IAAI,iBAAiB,YAAY;EAE7D;EAEA,OAAO;CACT,CAAC;AACH;;AAGA,MAAa,oBAAoB,wBAAwB"}
@@ -1,5 +1,6 @@
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");
3
4
  const require_setup_session = require("../setup/session.cjs");
4
5
  const require_third_parties_ignored_events = require("../third-parties/ignored-events.cjs");
5
6
  const require_visitor_index = require("../visitor/index.cjs");
@@ -40,17 +41,15 @@ async function captureTags() {
40
41
  async function sendEvents(events) {
41
42
  try {
42
43
  if (events.length === 0) return;
43
- const session = require_setup_session.getSession();
44
- if (session.isExpired()) {
45
- session.refresh();
46
- events.unshift({
47
- name: "session_start",
48
- properties: {},
49
- options: { enableThirdPartyTracking: false },
50
- tags: captureTags(),
51
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
52
- });
53
- } else session.updateLastActiveTime();
44
+ const firstTimestamp = events[0].timestamp;
45
+ const { id: session_id, started } = require_setup_session.getSession().touch(Date.parse(firstTimestamp), Date.parse(events[events.length - 1].timestamp));
46
+ if (started) events.unshift({
47
+ name: "session_start",
48
+ properties: {},
49
+ options: { enableThirdPartyTracking: false },
50
+ tags: captureTags(),
51
+ timestamp: firstTimestamp
52
+ });
54
53
  await getTokenBucket().removeTokens();
55
54
  const visitor_id = (await require_visitor_index.getVisitor()).id;
56
55
  const dto = await Promise.all(events.map(async (event) => ({
@@ -58,7 +57,7 @@ async function sendEvents(events) {
58
57
  properties: event.properties,
59
58
  tags: await event.tags,
60
59
  visitor_id,
61
- session_id: session.getId(),
60
+ session_id,
62
61
  platform: require_setup_index.config.platform,
63
62
  environment: require_setup_index.config.environment,
64
63
  timestamp: event.timestamp
@@ -139,15 +138,15 @@ async function trackAsync(name, properties, options = defaultOptions) {
139
138
  }]);
140
139
  }
141
140
  function sendBeacon(name, properties) {
142
- if (!require_setup_index.cache.tags || !require_setup_index.cache.visitor) return;
143
- const session = require_setup_session.getSession();
144
- session.updateLastActiveTime();
141
+ const stored = require_setup_index.config.storage.getItem(require_constants_storage.keys.visitor_id);
142
+ const visitor_id = require_setup_index.cache.visitor?.id ?? (stored && stored !== "undefined" ? stored : void 0);
143
+ if (!visitor_id) return;
145
144
  const dto = [{
146
145
  name,
147
146
  properties,
148
- tags: require_setup_index.cache.tags,
149
- visitor_id: require_setup_index.cache.visitor.id,
150
- session_id: session.getId(),
147
+ tags: require_setup_index.cache.tags ?? {},
148
+ visitor_id,
149
+ session_id: require_setup_session.getSession().extend(),
151
150
  platform: require_setup_index.config.platform,
152
151
  environment: require_setup_index.config.environment,
153
152
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","names":["TokenBucket","config","cache","getSession","getVisitor","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 { getVisitor } 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 = { enableThirdPartyTracking: true };\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 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\nasync function sendEvents(events: Item[]) {\n try {\n if (events.length === 0) return;\n\n const session = getSession();\n if (session.isExpired()) {\n session.refresh();\n events.unshift({\n name: 'session_start',\n properties: {},\n options: { enableThirdPartyTracking: false },\n tags: captureTags(),\n timestamp: new Date().toISOString(),\n });\n } else {\n session.updateLastActiveTime();\n }\n\n await getTokenBucket().removeTokens();\n\n const visitor_id = (await getVisitor()).id;\n\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: session.getId(),\n platform: config.platform,\n environment: config.environment,\n timestamp: event.timestamp,\n }))\n );\n\n const response = await fetch(`${config.endpoint}/events`, {\n method: 'POST',\n credentials: 'include',\n headers: await config.getHeaders(),\n body: JSON.stringify(dto),\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 let index = 0;\n while (events.length > 0) {\n const event = events.shift();\n if (!event) {\n index++;\n continue;\n }\n const { options, name, properties } = event;\n const eventId = data.at(index)?.id;\n options.onSucceed?.(eventId ? { id: eventId } : undefined);\n index++;\n if (!options.enableThirdPartyTracking || IGNORED_EVENTS.includes(name)) {\n continue;\n }\n config.thirdPartyTrackers.forEach((tracker) => {\n try {\n tracker(name, properties, eventId);\n } catch (e: unknown) {\n // A third-party script does not get to take the rest of the batch with it. This loop is\n // still draining `events` with `shift`, so a throw would escape to the catch below and\n // report failure to whatever is left in the queue — for events the server has already\n // accepted, and after the ones ahead of them were told they succeeded.\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 events.forEach((event) => event.options.onError?.(e));\n }\n}\n\nconst batch = 10;\nconst delay = 2000;\nconst list: Item[] = [];\nlet timer: ReturnType<typeof setTimeout> | null = null;\n\n/**\n * Both paths into a send go through here, so a batch that fills up cancels the timer the\n * previous push armed rather than leaving it to wake up on its own with nothing to send.\n */\nfunction flush() {\n if (timer) {\n clearTimeout(timer);\n timer = null;\n }\n if (list.length === 0) return;\n const copy = [...list];\n list.length = 0;\n void sendEvents(copy);\n}\n\nexport function track<T extends EventName = EventName>(\n name: TrackName<T>,\n properties?: TrackProperties<T>,\n options: TrackOptions = defaultOptions\n) {\n list.push({\n name,\n properties,\n options,\n tags: captureTags(),\n timestamp: new Date().toISOString(),\n });\n if (list.length >= batch) {\n flush();\n return;\n }\n if (timer) clearTimeout(timer);\n timer = setTimeout(flush, delay);\n}\n\nexport async function trackAsync<T extends EventName = EventName>(\n name: TrackName<T>,\n properties?: TrackProperties<T>,\n options: TrackOptions = defaultOptions\n) {\n await sendEvents([\n { name, properties, options, tags: captureTags(), timestamp: new Date().toISOString() },\n ]);\n}\n\nexport function sendBeacon<T extends EventName = EventName>(\n name: TrackName<T>,\n properties?: TrackProperties<T>\n) {\n if (!cache.tags || !cache.visitor) return;\n\n const session = getSession();\n session.updateLastActiveTime();\n\n const dto: CreateTrackEventDTO = [\n {\n name,\n properties,\n tags: cache.tags,\n visitor_id: cache.visitor.id,\n session_id: session.getId(),\n platform: config.platform,\n environment: config.environment,\n timestamp: new Date().toISOString(),\n },\n ];\n const blob = new Blob([JSON.stringify(dto)], { type: 'application/json' });\n const success = navigator.sendBeacon(`${config.endpoint}/events`, blob);\n if (success) 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","getVisitor","IGNORED_EVENTS","keys"],"sources":["../../src/track/index.ts"],"sourcesContent":["import { TokenBucket, fetch } from '@shware/utils';\nimport { keys } from '../constants/storage';\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 { getVisitor } 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 = { enableThirdPartyTracking: true };\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 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\nasync function sendEvents(events: Item[]) {\n try {\n if (events.length === 0) return;\n\n // One read-modify-write of the stored session for the whole batch: it answers which session\n // these events belong to and whether this batch is the one that started it. Timed by the\n // events themselves rather than by this moment — a tab frozen in the background can hold a\n // batch for far longer than `delay`, and those events belong to the session they happened in.\n const firstTimestamp = events[0].timestamp;\n const { id: session_id, started } = getSession().touch(\n Date.parse(firstTimestamp),\n Date.parse(events[events.length - 1].timestamp)\n );\n if (started) {\n events.unshift({\n name: 'session_start',\n properties: {},\n options: { enableThirdPartyTracking: false },\n tags: captureTags(),\n // The session began with the event that opened it, not at this moment: a batch held in a\n // frozen tab would otherwise announce its session later than the events inside it.\n timestamp: firstTimestamp,\n });\n }\n\n await getTokenBucket().removeTokens();\n\n const visitor_id = (await getVisitor()).id;\n\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,\n platform: config.platform,\n environment: config.environment,\n timestamp: event.timestamp,\n }))\n );\n\n const response = await fetch(`${config.endpoint}/events`, {\n method: 'POST',\n credentials: 'include',\n headers: await config.getHeaders(),\n body: JSON.stringify(dto),\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 let index = 0;\n while (events.length > 0) {\n const event = events.shift();\n if (!event) {\n index++;\n continue;\n }\n const { options, name, properties } = event;\n const eventId = data.at(index)?.id;\n options.onSucceed?.(eventId ? { id: eventId } : undefined);\n index++;\n if (!options.enableThirdPartyTracking || IGNORED_EVENTS.includes(name)) {\n continue;\n }\n config.thirdPartyTrackers.forEach((tracker) => {\n try {\n tracker(name, properties, eventId);\n } catch (e: unknown) {\n // A third-party script does not get to take the rest of the batch with it. This loop is\n // still draining `events` with `shift`, so a throw would escape to the catch below and\n // report failure to whatever is left in the queue — for events the server has already\n // accepted, and after the ones ahead of them were told they succeeded.\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 events.forEach((event) => event.options.onError?.(e));\n }\n}\n\nconst batch = 10;\nconst delay = 2000;\nconst list: Item[] = [];\nlet timer: ReturnType<typeof setTimeout> | null = null;\n\n/**\n * Both paths into a send go through here, so a batch that fills up cancels the timer the\n * previous push armed rather than leaving it to wake up on its own with nothing to send.\n */\nfunction flush() {\n if (timer) {\n clearTimeout(timer);\n timer = null;\n }\n if (list.length === 0) return;\n const copy = [...list];\n list.length = 0;\n void sendEvents(copy);\n}\n\nexport function track<T extends EventName = EventName>(\n name: TrackName<T>,\n properties?: TrackProperties<T>,\n options: TrackOptions = defaultOptions\n) {\n list.push({\n name,\n properties,\n options,\n tags: captureTags(),\n timestamp: new Date().toISOString(),\n });\n if (list.length >= batch) {\n flush();\n return;\n }\n if (timer) clearTimeout(timer);\n timer = setTimeout(flush, delay);\n}\n\nexport async function trackAsync<T extends EventName = EventName>(\n name: TrackName<T>,\n properties?: TrackProperties<T>,\n options: TrackOptions = defaultOptions\n) {\n await sendEvents([\n { name, properties, options, tags: captureTags(), timestamp: new Date().toISOString() },\n ]);\n}\n\nexport function sendBeacon<T extends EventName = EventName>(\n name: TrackName<T>,\n properties?: TrackProperties<T>\n) {\n // The visitor id is persisted, so a returning visitor already has one before `getVisitor` has\n // finished its round trip for this page. Requiring the in-memory copy threw away exactly the\n // events this function exists for: everything a visit accrues before its first batch comes\n // back, which for a short visit is the whole of it.\n const stored = config.storage.getItem(keys.visitor_id);\n const visitor_id = cache.visitor?.id ?? (stored && stored !== 'undefined' ? stored : undefined);\n if (!visitor_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: getSession().extend(),\n platform: config.platform,\n environment: config.environment,\n timestamp: new Date().toISOString(),\n },\n ];\n const blob = new Blob([JSON.stringify(dto)], { type: 'application/json' });\n const success = navigator.sendBeacon(`${config.endpoint}/events`, blob);\n if (success) return;\n console.warn('Failed to send beacon', name, properties);\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.cts","names":[],"sources":["../../src/track/index.ts"],"mappings":";;UAQiB;EACf;EACA,aAAa,WAAW;EACxB,WAAW;;iBAiJG,MAAM,UAAU,YAAY,WAC1C,MAAM,UAAU,IAChB,aAAa,gBAAgB,IAC7B,UAAS;iBAiBW,WAAW,UAAU,YAAY,WACrD,MAAM,UAAU,IAChB,aAAa,gBAAgB,IAC7B,UAAS,eAA6B;iBAOxB,WAAW,UAAU,YAAY,WAC/C,MAAM,UAAU,IAChB,aAAa,gBAAgB"}
1
+ {"version":3,"file":"index.d.cts","names":[],"sources":["../../src/track/index.ts"],"mappings":";;UASiB;EACf;EACA,aAAa,WAAW;EACxB,WAAW;;iBAwJG,MAAM,UAAU,YAAY,WAC1C,MAAM,UAAU,IAChB,aAAa,gBAAgB,IAC7B,UAAS;iBAiBW,WAAW,UAAU,YAAY,WACrD,MAAM,UAAU,IAChB,aAAa,gBAAgB,IAC7B,UAAS,eAA6B;iBAOxB,WAAW,UAAU,YAAY,WAC/C,MAAM,UAAU,IAChB,aAAa,gBAAgB"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/track/index.ts"],"mappings":";;UAQiB;EACf;EACA,aAAa,WAAW;EACxB,WAAW;;iBAiJG,MAAM,UAAU,YAAY,WAC1C,MAAM,UAAU,IAChB,aAAa,gBAAgB,IAC7B,UAAS;iBAiBW,WAAW,UAAU,YAAY,WACrD,MAAM,UAAU,IAChB,aAAa,gBAAgB,IAC7B,UAAS,eAA6B;iBAOxB,WAAW,UAAU,YAAY,WAC/C,MAAM,UAAU,IAChB,aAAa,gBAAgB"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/track/index.ts"],"mappings":";;UASiB;EACf;EACA,aAAa,WAAW;EACxB,WAAW;;iBAwJG,MAAM,UAAU,YAAY,WAC1C,MAAM,UAAU,IAChB,aAAa,gBAAgB,IAC7B,UAAS;iBAiBW,WAAW,UAAU,YAAY,WACrD,MAAM,UAAU,IAChB,aAAa,gBAAgB,IAC7B,UAAS,eAA6B;iBAOxB,WAAW,UAAU,YAAY,WAC/C,MAAM,UAAU,IAChB,aAAa,gBAAgB"}
@@ -1,4 +1,5 @@
1
1
  import { cache, config } from "../setup/index.mjs";
2
+ import { keys } from "../constants/storage.mjs";
2
3
  import { getSession } from "../setup/session.mjs";
3
4
  import { IGNORED_EVENTS } from "../third-parties/ignored-events.mjs";
4
5
  import { getVisitor } from "../visitor/index.mjs";
@@ -39,17 +40,15 @@ async function captureTags() {
39
40
  async function sendEvents(events) {
40
41
  try {
41
42
  if (events.length === 0) return;
42
- const session = getSession();
43
- if (session.isExpired()) {
44
- session.refresh();
45
- events.unshift({
46
- name: "session_start",
47
- properties: {},
48
- options: { enableThirdPartyTracking: false },
49
- tags: captureTags(),
50
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
51
- });
52
- } else session.updateLastActiveTime();
43
+ const firstTimestamp = events[0].timestamp;
44
+ const { id: session_id, started } = getSession().touch(Date.parse(firstTimestamp), Date.parse(events[events.length - 1].timestamp));
45
+ if (started) events.unshift({
46
+ name: "session_start",
47
+ properties: {},
48
+ options: { enableThirdPartyTracking: false },
49
+ tags: captureTags(),
50
+ timestamp: firstTimestamp
51
+ });
53
52
  await getTokenBucket().removeTokens();
54
53
  const visitor_id = (await getVisitor()).id;
55
54
  const dto = await Promise.all(events.map(async (event) => ({
@@ -57,7 +56,7 @@ async function sendEvents(events) {
57
56
  properties: event.properties,
58
57
  tags: await event.tags,
59
58
  visitor_id,
60
- session_id: session.getId(),
59
+ session_id,
61
60
  platform: config.platform,
62
61
  environment: config.environment,
63
62
  timestamp: event.timestamp
@@ -138,15 +137,15 @@ async function trackAsync(name, properties, options = defaultOptions) {
138
137
  }]);
139
138
  }
140
139
  function sendBeacon(name, properties) {
141
- if (!cache.tags || !cache.visitor) return;
142
- const session = getSession();
143
- session.updateLastActiveTime();
140
+ const stored = config.storage.getItem(keys.visitor_id);
141
+ const visitor_id = cache.visitor?.id ?? (stored && stored !== "undefined" ? stored : void 0);
142
+ if (!visitor_id) return;
144
143
  const dto = [{
145
144
  name,
146
145
  properties,
147
- tags: cache.tags,
148
- visitor_id: cache.visitor.id,
149
- session_id: session.getId(),
146
+ tags: cache.tags ?? {},
147
+ visitor_id,
148
+ session_id: getSession().extend(),
150
149
  platform: config.platform,
151
150
  environment: config.environment,
152
151
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
@@ -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 { getVisitor } 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 = { enableThirdPartyTracking: true };\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 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\nasync function sendEvents(events: Item[]) {\n try {\n if (events.length === 0) return;\n\n const session = getSession();\n if (session.isExpired()) {\n session.refresh();\n events.unshift({\n name: 'session_start',\n properties: {},\n options: { enableThirdPartyTracking: false },\n tags: captureTags(),\n timestamp: new Date().toISOString(),\n });\n } else {\n session.updateLastActiveTime();\n }\n\n await getTokenBucket().removeTokens();\n\n const visitor_id = (await getVisitor()).id;\n\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: session.getId(),\n platform: config.platform,\n environment: config.environment,\n timestamp: event.timestamp,\n }))\n );\n\n const response = await fetch(`${config.endpoint}/events`, {\n method: 'POST',\n credentials: 'include',\n headers: await config.getHeaders(),\n body: JSON.stringify(dto),\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 let index = 0;\n while (events.length > 0) {\n const event = events.shift();\n if (!event) {\n index++;\n continue;\n }\n const { options, name, properties } = event;\n const eventId = data.at(index)?.id;\n options.onSucceed?.(eventId ? { id: eventId } : undefined);\n index++;\n if (!options.enableThirdPartyTracking || IGNORED_EVENTS.includes(name)) {\n continue;\n }\n config.thirdPartyTrackers.forEach((tracker) => {\n try {\n tracker(name, properties, eventId);\n } catch (e: unknown) {\n // A third-party script does not get to take the rest of the batch with it. This loop is\n // still draining `events` with `shift`, so a throw would escape to the catch below and\n // report failure to whatever is left in the queue — for events the server has already\n // accepted, and after the ones ahead of them were told they succeeded.\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 events.forEach((event) => event.options.onError?.(e));\n }\n}\n\nconst batch = 10;\nconst delay = 2000;\nconst list: Item[] = [];\nlet timer: ReturnType<typeof setTimeout> | null = null;\n\n/**\n * Both paths into a send go through here, so a batch that fills up cancels the timer the\n * previous push armed rather than leaving it to wake up on its own with nothing to send.\n */\nfunction flush() {\n if (timer) {\n clearTimeout(timer);\n timer = null;\n }\n if (list.length === 0) return;\n const copy = [...list];\n list.length = 0;\n void sendEvents(copy);\n}\n\nexport function track<T extends EventName = EventName>(\n name: TrackName<T>,\n properties?: TrackProperties<T>,\n options: TrackOptions = defaultOptions\n) {\n list.push({\n name,\n properties,\n options,\n tags: captureTags(),\n timestamp: new Date().toISOString(),\n });\n if (list.length >= batch) {\n flush();\n return;\n }\n if (timer) clearTimeout(timer);\n timer = setTimeout(flush, delay);\n}\n\nexport async function trackAsync<T extends EventName = EventName>(\n name: TrackName<T>,\n properties?: TrackProperties<T>,\n options: TrackOptions = defaultOptions\n) {\n await sendEvents([\n { name, properties, options, tags: captureTags(), timestamp: new Date().toISOString() },\n ]);\n}\n\nexport function sendBeacon<T extends EventName = EventName>(\n name: TrackName<T>,\n properties?: TrackProperties<T>\n) {\n if (!cache.tags || !cache.visitor) return;\n\n const session = getSession();\n session.updateLastActiveTime();\n\n const dto: CreateTrackEventDTO = [\n {\n name,\n properties,\n tags: cache.tags,\n visitor_id: cache.visitor.id,\n session_id: session.getId(),\n platform: config.platform,\n environment: config.environment,\n timestamp: new Date().toISOString(),\n },\n ];\n const blob = new Blob([JSON.stringify(dto)], { type: 'application/json' });\n const success = navigator.sendBeacon(`${config.endpoint}/events`, blob);\n if (success) return;\n console.warn('Failed to send beacon', name, properties);\n}\n"],"mappings":";;;;;;AAcA,MAAM,iBAA+B,EAAE,0BAA0B,KAAK;AAEtE,IAAI;;;;;;;AAQJ,SAAS,iBAAiB;CACxB,OAAQ,gBAAgB,IAAI,YAAY;EAAE,MAAM;EAAG,UAAU;EAAI,WAAW;CAAE,CAAC;AACjF;;;;;;;;;;AAqBA,eAAe,cAAkC;CAC/C,IAAI;EACF,OAAO,MAAM,OAAO,QAAQ;CAC9B,SAAS,GAAY;EACnB,IAAI,aAAa,OAAO,QAAQ,IAAI,EAAE,OAAO;EAC7C,OAAO,MAAM,QAAQ,CAAC;CACxB;AACF;AAEA,eAAe,WAAW,QAAgB;CACxC,IAAI;EACF,IAAI,OAAO,WAAW,GAAG;EAEzB,MAAM,UAAU,WAAW;EAC3B,IAAI,QAAQ,UAAU,GAAG;GACvB,QAAQ,QAAQ;GAChB,OAAO,QAAQ;IACb,MAAM;IACN,YAAY,CAAC;IACb,SAAS,EAAE,0BAA0B,MAAM;IAC3C,MAAM,YAAY;IAClB,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;GACpC,CAAC;EACH,OACE,QAAQ,qBAAqB;EAG/B,MAAM,eAAe,CAAC,CAAC,aAAa;EAEpC,MAAM,cAAc,MAAM,WAAW,EAAA,CAAG;EAExC,MAAM,MAA2B,MAAM,QAAQ,IAC7C,OAAO,IAAI,OAAO,WAAW;GAC3B,MAAM,MAAM;GACZ,YAAY,MAAM;GAClB,MAAM,MAAM,MAAM;GAClB;GACA,YAAY,QAAQ,MAAM;GAC1B,UAAU,OAAO;GACjB,aAAa,OAAO;GACpB,WAAW,MAAM;EACnB,EAAE,CACJ;EAEA,MAAM,WAAW,MAAM,MAAM,GAAG,OAAO,SAAS,UAAU;GACxD,QAAQ;GACR,aAAa;GACb,SAAS,MAAM,OAAO,WAAW;GACjC,MAAM,KAAK,UAAU,GAAG;EAC1B,CAAC;EAED,IAAI,CAAC,SAAS,IACZ,MAAM,IAAI,MAAM,+BAA+B,SAAS,OAAO,GAAG,MAAM,SAAS,KAAK,GAAG;EAG3F,MAAM,OAAQ,MAAM,SAAS,KAAK;EAElC,IAAI,QAAQ;EACZ,OAAO,OAAO,SAAS,GAAG;GACxB,MAAM,QAAQ,OAAO,MAAM;GAC3B,IAAI,CAAC,OAAO;IACV;IACA;GACF;GACA,MAAM,EAAE,SAAS,MAAM,eAAe;GACtC,MAAM,UAAU,KAAK,GAAG,KAAK,CAAC,EAAE;GAChC,QAAQ,YAAY,UAAU,EAAE,IAAI,QAAQ,IAAI,KAAA,CAAS;GACzD;GACA,IAAI,CAAC,QAAQ,4BAA4B,eAAe,SAAS,IAAI,GACnE;GAEF,OAAO,mBAAmB,SAAS,YAAY;IAC7C,IAAI;KACF,QAAQ,MAAM,YAAY,OAAO;IACnC,SAAS,GAAY;KAKnB,IAAI,aAAa,OAAO,QAAQ,IAAI,EAAE,OAAO;IAC/C;GACF,CAAC;EACH;CACF,SAAS,GAAY;EACnB,IAAI,aAAa,OAAO,QAAQ,IAAI,EAAE,OAAO;EAC7C,OAAO,SAAS,UAAU,MAAM,QAAQ,UAAU,CAAC,CAAC;CACtD;AACF;AAEA,MAAM,QAAQ;AACd,MAAM,QAAQ;AACd,MAAM,OAAe,CAAC;AACtB,IAAI,QAA8C;;;;;AAMlD,SAAS,QAAQ;CACf,IAAI,OAAO;EACT,aAAa,KAAK;EAClB,QAAQ;CACV;CACA,IAAI,KAAK,WAAW,GAAG;CACvB,MAAM,OAAO,CAAC,GAAG,IAAI;CACrB,KAAK,SAAS;CACd,WAAgB,IAAI;AACtB;AAEA,SAAgB,MACd,MACA,YACA,UAAwB,gBACxB;CACA,KAAK,KAAK;EACR;EACA;EACA;EACA,MAAM,YAAY;EAClB,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;CACpC,CAAC;CACD,IAAI,KAAK,UAAU,OAAO;EACxB,MAAM;EACN;CACF;CACA,IAAI,OAAO,aAAa,KAAK;CAC7B,QAAQ,WAAW,OAAO,KAAK;AACjC;AAEA,eAAsB,WACpB,MACA,YACA,UAAwB,gBACxB;CACA,MAAM,WAAW,CACf;EAAE;EAAM;EAAY;EAAS,MAAM,YAAY;EAAG,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;CAAE,CACxF,CAAC;AACH;AAEA,SAAgB,WACd,MACA,YACA;CACA,IAAI,CAAC,MAAM,QAAQ,CAAC,MAAM,SAAS;CAEnC,MAAM,UAAU,WAAW;CAC3B,QAAQ,qBAAqB;CAE7B,MAAM,MAA2B,CAC/B;EACE;EACA;EACA,MAAM,MAAM;EACZ,YAAY,MAAM,QAAQ;EAC1B,YAAY,QAAQ,MAAM;EAC1B,UAAU,OAAO;EACjB,aAAa,OAAO;EACpB,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;CACpC,CACF;CACA,MAAM,OAAO,IAAI,KAAK,CAAC,KAAK,UAAU,GAAG,CAAC,GAAG,EAAE,MAAM,mBAAmB,CAAC;CAEzE,IADgB,UAAU,WAAW,GAAG,OAAO,SAAS,UAAU,IACxD,GAAG;CACb,QAAQ,KAAK,yBAAyB,MAAM,UAAU;AACxD"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../src/track/index.ts"],"sourcesContent":["import { TokenBucket, fetch } from '@shware/utils';\nimport { keys } from '../constants/storage';\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 { getVisitor } 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 = { enableThirdPartyTracking: true };\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 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\nasync function sendEvents(events: Item[]) {\n try {\n if (events.length === 0) return;\n\n // One read-modify-write of the stored session for the whole batch: it answers which session\n // these events belong to and whether this batch is the one that started it. Timed by the\n // events themselves rather than by this moment — a tab frozen in the background can hold a\n // batch for far longer than `delay`, and those events belong to the session they happened in.\n const firstTimestamp = events[0].timestamp;\n const { id: session_id, started } = getSession().touch(\n Date.parse(firstTimestamp),\n Date.parse(events[events.length - 1].timestamp)\n );\n if (started) {\n events.unshift({\n name: 'session_start',\n properties: {},\n options: { enableThirdPartyTracking: false },\n tags: captureTags(),\n // The session began with the event that opened it, not at this moment: a batch held in a\n // frozen tab would otherwise announce its session later than the events inside it.\n timestamp: firstTimestamp,\n });\n }\n\n await getTokenBucket().removeTokens();\n\n const visitor_id = (await getVisitor()).id;\n\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,\n platform: config.platform,\n environment: config.environment,\n timestamp: event.timestamp,\n }))\n );\n\n const response = await fetch(`${config.endpoint}/events`, {\n method: 'POST',\n credentials: 'include',\n headers: await config.getHeaders(),\n body: JSON.stringify(dto),\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 let index = 0;\n while (events.length > 0) {\n const event = events.shift();\n if (!event) {\n index++;\n continue;\n }\n const { options, name, properties } = event;\n const eventId = data.at(index)?.id;\n options.onSucceed?.(eventId ? { id: eventId } : undefined);\n index++;\n if (!options.enableThirdPartyTracking || IGNORED_EVENTS.includes(name)) {\n continue;\n }\n config.thirdPartyTrackers.forEach((tracker) => {\n try {\n tracker(name, properties, eventId);\n } catch (e: unknown) {\n // A third-party script does not get to take the rest of the batch with it. This loop is\n // still draining `events` with `shift`, so a throw would escape to the catch below and\n // report failure to whatever is left in the queue — for events the server has already\n // accepted, and after the ones ahead of them were told they succeeded.\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 events.forEach((event) => event.options.onError?.(e));\n }\n}\n\nconst batch = 10;\nconst delay = 2000;\nconst list: Item[] = [];\nlet timer: ReturnType<typeof setTimeout> | null = null;\n\n/**\n * Both paths into a send go through here, so a batch that fills up cancels the timer the\n * previous push armed rather than leaving it to wake up on its own with nothing to send.\n */\nfunction flush() {\n if (timer) {\n clearTimeout(timer);\n timer = null;\n }\n if (list.length === 0) return;\n const copy = [...list];\n list.length = 0;\n void sendEvents(copy);\n}\n\nexport function track<T extends EventName = EventName>(\n name: TrackName<T>,\n properties?: TrackProperties<T>,\n options: TrackOptions = defaultOptions\n) {\n list.push({\n name,\n properties,\n options,\n tags: captureTags(),\n timestamp: new Date().toISOString(),\n });\n if (list.length >= batch) {\n flush();\n return;\n }\n if (timer) clearTimeout(timer);\n timer = setTimeout(flush, delay);\n}\n\nexport async function trackAsync<T extends EventName = EventName>(\n name: TrackName<T>,\n properties?: TrackProperties<T>,\n options: TrackOptions = defaultOptions\n) {\n await sendEvents([\n { name, properties, options, tags: captureTags(), timestamp: new Date().toISOString() },\n ]);\n}\n\nexport function sendBeacon<T extends EventName = EventName>(\n name: TrackName<T>,\n properties?: TrackProperties<T>\n) {\n // The visitor id is persisted, so a returning visitor already has one before `getVisitor` has\n // finished its round trip for this page. Requiring the in-memory copy threw away exactly the\n // events this function exists for: everything a visit accrues before its first batch comes\n // back, which for a short visit is the whole of it.\n const stored = config.storage.getItem(keys.visitor_id);\n const visitor_id = cache.visitor?.id ?? (stored && stored !== 'undefined' ? stored : undefined);\n if (!visitor_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: getSession().extend(),\n platform: config.platform,\n environment: config.environment,\n timestamp: new Date().toISOString(),\n },\n ];\n const blob = new Blob([JSON.stringify(dto)], { type: 'application/json' });\n const success = navigator.sendBeacon(`${config.endpoint}/events`, blob);\n if (success) return;\n console.warn('Failed to send beacon', name, properties);\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shware/analytics",
3
- "version": "7.2.1",
3
+ "version": "7.3.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",