@clue-ai/browser-sdk 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +100 -0
- package/dist/authoring/overlay.d.ts +12 -0
- package/dist/authoring/overlay.js +468 -0
- package/dist/authoring/recording.d.ts +125 -0
- package/dist/authoring/recording.js +481 -0
- package/dist/authoring/service-logo.d.ts +1 -0
- package/dist/authoring/service-logo.generated.d.ts +1 -0
- package/dist/authoring/service-logo.generated.js +3 -0
- package/dist/authoring/service-logo.js +1 -0
- package/dist/authoring/session.d.ts +23 -0
- package/dist/authoring/session.js +127 -0
- package/dist/authoring/surface.d.ts +11 -0
- package/dist/authoring/surface.js +63 -0
- package/dist/authoring/toolbar-constants.d.ts +23 -0
- package/dist/authoring/toolbar-constants.js +42 -0
- package/dist/authoring/toolbar-drag.d.ts +29 -0
- package/dist/authoring/toolbar-drag.js +270 -0
- package/dist/authoring/toolbar-view.d.ts +21 -0
- package/dist/authoring/toolbar-view.js +2584 -0
- package/dist/capture/action.d.ts +2 -0
- package/dist/capture/action.js +62 -0
- package/dist/capture/dom.d.ts +23 -0
- package/dist/capture/dom.js +329 -0
- package/dist/capture/drag.d.ts +2 -0
- package/dist/capture/drag.js +75 -0
- package/dist/capture/error.d.ts +2 -0
- package/dist/capture/error.js +193 -0
- package/dist/capture/form.d.ts +2 -0
- package/dist/capture/form.js +137 -0
- package/dist/capture/frustration.d.ts +2 -0
- package/dist/capture/frustration.js +171 -0
- package/dist/capture/input.d.ts +2 -0
- package/dist/capture/input.js +109 -0
- package/dist/capture/location.d.ts +10 -0
- package/dist/capture/location.js +42 -0
- package/dist/capture/navigation.d.ts +2 -0
- package/dist/capture/navigation.js +100 -0
- package/dist/capture/network.d.ts +13 -0
- package/dist/capture/network.js +903 -0
- package/dist/capture/page.d.ts +2 -0
- package/dist/capture/page.js +78 -0
- package/dist/capture/performance.d.ts +2 -0
- package/dist/capture/performance.js +268 -0
- package/dist/context/account.d.ts +12 -0
- package/dist/context/account.js +129 -0
- package/dist/context/environment.d.ts +42 -0
- package/dist/context/environment.js +208 -0
- package/dist/context/identity.d.ts +14 -0
- package/dist/context/identity.js +123 -0
- package/dist/context/session.d.ts +28 -0
- package/dist/context/session.js +155 -0
- package/dist/context/tab.d.ts +22 -0
- package/dist/context/tab.js +142 -0
- package/dist/context/trace.d.ts +32 -0
- package/dist/context/trace.js +65 -0
- package/dist/core/config.d.ts +4 -0
- package/dist/core/config.js +199 -0
- package/dist/core/constants.d.ts +43 -0
- package/dist/core/constants.js +109 -0
- package/dist/core/contracts.d.ts +58 -0
- package/dist/core/contracts.js +53 -0
- package/dist/core/sdk.d.ts +2 -0
- package/dist/core/sdk.js +831 -0
- package/dist/core/types.d.ts +413 -0
- package/dist/core/types.js +1 -0
- package/dist/core/usage-governor.d.ts +7 -0
- package/dist/core/usage-governor.js +127 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +36 -0
- package/dist/integrations/next-router.d.ts +16 -0
- package/dist/integrations/next-router.js +18 -0
- package/dist/integrations/react-router.d.ts +7 -0
- package/dist/integrations/react-router.js +37 -0
- package/dist/internal/metrics.d.ts +9 -0
- package/dist/internal/metrics.js +38 -0
- package/dist/normalize/builders.d.ts +15 -0
- package/dist/normalize/builders.js +786 -0
- package/dist/normalize/canonical.d.ts +13 -0
- package/dist/normalize/canonical.js +77 -0
- package/dist/normalize/event-id.d.ts +8 -0
- package/dist/normalize/event-id.js +39 -0
- package/dist/normalize/path-template.d.ts +1 -0
- package/dist/normalize/path-template.js +33 -0
- package/dist/privacy/local-minimization.d.ts +29 -0
- package/dist/privacy/local-minimization.js +88 -0
- package/dist/privacy/mask.d.ts +7 -0
- package/dist/privacy/mask.js +60 -0
- package/dist/privacy/parameter-snapshot.d.ts +14 -0
- package/dist/privacy/parameter-snapshot.js +206 -0
- package/dist/privacy/sanitize.d.ts +11 -0
- package/dist/privacy/sanitize.js +145 -0
- package/dist/privacy/schema-evidence.d.ts +20 -0
- package/dist/privacy/schema-evidence.js +238 -0
- package/dist/transport/batch.d.ts +37 -0
- package/dist/transport/batch.js +182 -0
- package/dist/transport/client.d.ts +61 -0
- package/dist/transport/client.js +267 -0
- package/dist/transport/queue.d.ts +22 -0
- package/dist/transport/queue.js +56 -0
- package/dist/transport/retry.d.ts +14 -0
- package/dist/transport/retry.js +46 -0
- package/package.json +38 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
const firstMatch = (userAgent, pattern) => {
|
|
2
|
+
const match = pattern.exec(userAgent);
|
|
3
|
+
return match?.[1] ?? null;
|
|
4
|
+
};
|
|
5
|
+
const detectBrowserInfo = (userAgent) => {
|
|
6
|
+
if (/Edg\//.test(userAgent)) {
|
|
7
|
+
return {
|
|
8
|
+
family: "edge",
|
|
9
|
+
majorVersion: firstMatch(userAgent, /Edg\/(\d+)/),
|
|
10
|
+
engine: "blink",
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
if (/Chrome\//.test(userAgent)) {
|
|
14
|
+
return {
|
|
15
|
+
family: "chrome",
|
|
16
|
+
majorVersion: firstMatch(userAgent, /Chrome\/(\d+)/),
|
|
17
|
+
engine: "blink",
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
if (/Safari\//.test(userAgent) && /Version\//.test(userAgent)) {
|
|
21
|
+
return {
|
|
22
|
+
family: "safari",
|
|
23
|
+
majorVersion: firstMatch(userAgent, /Version\/(\d+)/),
|
|
24
|
+
engine: "webkit",
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
if (/Firefox\//.test(userAgent)) {
|
|
28
|
+
return {
|
|
29
|
+
family: "firefox",
|
|
30
|
+
majorVersion: firstMatch(userAgent, /Firefox\/(\d+)/),
|
|
31
|
+
engine: "gecko",
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
family: null,
|
|
36
|
+
majorVersion: null,
|
|
37
|
+
engine: null,
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
const detectOsInfo = (userAgent) => {
|
|
41
|
+
if (/Windows/.test(userAgent)) {
|
|
42
|
+
return {
|
|
43
|
+
family: "windows",
|
|
44
|
+
majorVersion: firstMatch(userAgent, /Windows NT (\d+)/),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (/Mac OS X/.test(userAgent)) {
|
|
48
|
+
return {
|
|
49
|
+
family: "macos",
|
|
50
|
+
majorVersion: firstMatch(userAgent, /Mac OS X (\d+)[_.]/),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
if (/Android/.test(userAgent)) {
|
|
54
|
+
return {
|
|
55
|
+
family: "android",
|
|
56
|
+
majorVersion: firstMatch(userAgent, /Android (\d+)/),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
if (/(iPhone|iPad|iPod)/.test(userAgent)) {
|
|
60
|
+
return {
|
|
61
|
+
family: "ios",
|
|
62
|
+
majorVersion: firstMatch(userAgent, /OS (\d+)[_.]/),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (/Linux/.test(userAgent)) {
|
|
66
|
+
return {
|
|
67
|
+
family: "linux",
|
|
68
|
+
majorVersion: null,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
family: null,
|
|
73
|
+
majorVersion: null,
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
const detectDeviceType = (userAgent) => {
|
|
77
|
+
if (/Tablet|iPad/.test(userAgent)) {
|
|
78
|
+
return "tablet";
|
|
79
|
+
}
|
|
80
|
+
if (/Mobile|Android|iPhone|iPod/.test(userAgent)) {
|
|
81
|
+
return "mobile";
|
|
82
|
+
}
|
|
83
|
+
return "desktop";
|
|
84
|
+
};
|
|
85
|
+
const detectTimezone = () => {
|
|
86
|
+
try {
|
|
87
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || null;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
const storageAvailable = (storageName) => {
|
|
94
|
+
try {
|
|
95
|
+
const storage = globalThis[storageName];
|
|
96
|
+
if (!storage) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
const key = "__clue_storage_probe__";
|
|
100
|
+
storage.setItem(key, "1");
|
|
101
|
+
storage.removeItem(key);
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
const detectPointerType = () => {
|
|
109
|
+
if (typeof globalThis.matchMedia !== "function") {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
if (globalThis.matchMedia("(pointer: coarse)").matches) {
|
|
113
|
+
return "coarse";
|
|
114
|
+
}
|
|
115
|
+
if (globalThis.matchMedia("(pointer: fine)").matches) {
|
|
116
|
+
return "fine";
|
|
117
|
+
}
|
|
118
|
+
return "unknown";
|
|
119
|
+
};
|
|
120
|
+
const detectMediaPreference = (query, matchesValue, fallbackValue) => {
|
|
121
|
+
if (typeof globalThis.matchMedia !== "function") {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
return globalThis.matchMedia(query).matches ? matchesValue : fallbackValue;
|
|
125
|
+
};
|
|
126
|
+
const detectWebglSupport = () => {
|
|
127
|
+
try {
|
|
128
|
+
if (typeof globalThis.document === "undefined") {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
if (!("WebGLRenderingContext" in globalThis) &&
|
|
132
|
+
!("WebGL2RenderingContext" in globalThis)) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
const canvas = globalThis.document.createElement("canvas");
|
|
136
|
+
return Boolean(canvas.getContext("webgl"));
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
export class EnvironmentManager {
|
|
143
|
+
constructor(options) {
|
|
144
|
+
this.options = options;
|
|
145
|
+
}
|
|
146
|
+
getContext() {
|
|
147
|
+
const userAgent = typeof globalThis.navigator !== "undefined"
|
|
148
|
+
? globalThis.navigator.userAgent
|
|
149
|
+
: "";
|
|
150
|
+
const locale = typeof globalThis.navigator !== "undefined"
|
|
151
|
+
? globalThis.navigator.language || null
|
|
152
|
+
: null;
|
|
153
|
+
const referrer = typeof globalThis.document !== "undefined"
|
|
154
|
+
? globalThis.document.referrer || null
|
|
155
|
+
: null;
|
|
156
|
+
const browserInfo = detectBrowserInfo(userAgent);
|
|
157
|
+
const osInfo = detectOsInfo(userAgent);
|
|
158
|
+
return {
|
|
159
|
+
environment: this.options.environment,
|
|
160
|
+
frontendRelease: this.options.frontendRelease,
|
|
161
|
+
featureFlags: this.safeFeatureFlags(),
|
|
162
|
+
experimentVariant: this.safeExperimentVariant(),
|
|
163
|
+
locale,
|
|
164
|
+
country: null,
|
|
165
|
+
deviceType: detectDeviceType(userAgent),
|
|
166
|
+
browser: browserInfo.family,
|
|
167
|
+
os: osInfo.family,
|
|
168
|
+
referrer,
|
|
169
|
+
browserFamily: browserInfo.family,
|
|
170
|
+
browserMajorVersion: browserInfo.majorVersion,
|
|
171
|
+
browserEngine: browserInfo.engine,
|
|
172
|
+
osFamily: osInfo.family,
|
|
173
|
+
osMajorVersion: osInfo.majorVersion,
|
|
174
|
+
timezone: detectTimezone(),
|
|
175
|
+
pointerTypeCandidate: detectPointerType(),
|
|
176
|
+
touchCapable: typeof globalThis.navigator !== "undefined"
|
|
177
|
+
? globalThis.navigator.maxTouchPoints > 0
|
|
178
|
+
: null,
|
|
179
|
+
cookiesEnabled: typeof globalThis.navigator !== "undefined"
|
|
180
|
+
? globalThis.navigator.cookieEnabled
|
|
181
|
+
: null,
|
|
182
|
+
localStorageAvailable: storageAvailable("localStorage"),
|
|
183
|
+
sessionStorageAvailable: storageAvailable("sessionStorage"),
|
|
184
|
+
serviceWorkerSupported: typeof globalThis.navigator !== "undefined"
|
|
185
|
+
? "serviceWorker" in globalThis.navigator
|
|
186
|
+
: null,
|
|
187
|
+
webglSupported: detectWebglSupport(),
|
|
188
|
+
reducedMotion: detectMediaPreference("(prefers-reduced-motion: reduce)", "reduce", "no-preference"),
|
|
189
|
+
colorScheme: detectMediaPreference("(prefers-color-scheme: dark)", "dark", "light"),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
safeFeatureFlags() {
|
|
193
|
+
try {
|
|
194
|
+
return this.options.featureFlagsProvider?.() ?? [];
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return [];
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
safeExperimentVariant() {
|
|
201
|
+
try {
|
|
202
|
+
return this.options.experimentVariantProvider?.() ?? null;
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { IdentityContext } from "../core/types";
|
|
2
|
+
type StorageLike = Pick<Storage, "getItem" | "setItem" | "removeItem">;
|
|
3
|
+
export declare class IdentityManager {
|
|
4
|
+
private readonly storage;
|
|
5
|
+
private state;
|
|
6
|
+
constructor(storage?: StorageLike);
|
|
7
|
+
getContext(): IdentityContext;
|
|
8
|
+
identify(userId: string, traits?: Record<string, unknown>): void;
|
|
9
|
+
clearUser(): void;
|
|
10
|
+
rotateAnonymousId(): string;
|
|
11
|
+
reset(): void;
|
|
12
|
+
private loadState;
|
|
13
|
+
}
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { STORAGE_KEYS } from "../core/constants";
|
|
2
|
+
import { generateAnonymousId } from "../normalize/event-id";
|
|
3
|
+
const createMemoryStorage = () => {
|
|
4
|
+
const store = new Map();
|
|
5
|
+
return {
|
|
6
|
+
getItem: (key) => (store.has(key) ? store.get(key) : null),
|
|
7
|
+
setItem: (key, value) => {
|
|
8
|
+
store.set(key, value);
|
|
9
|
+
},
|
|
10
|
+
removeItem: (key) => {
|
|
11
|
+
store.delete(key);
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
const resolveStorage = () => {
|
|
16
|
+
try {
|
|
17
|
+
if (typeof globalThis.localStorage !== "undefined") {
|
|
18
|
+
const probeKey = "clue:probe";
|
|
19
|
+
globalThis.localStorage.setItem(probeKey, "1");
|
|
20
|
+
globalThis.localStorage.removeItem(probeKey);
|
|
21
|
+
return globalThis.localStorage;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Ignore and fallback.
|
|
26
|
+
}
|
|
27
|
+
return createMemoryStorage();
|
|
28
|
+
};
|
|
29
|
+
const safeJsonParse = (value) => {
|
|
30
|
+
if (!value) {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(value);
|
|
35
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
36
|
+
return parsed;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Ignore broken stored value.
|
|
41
|
+
}
|
|
42
|
+
return {};
|
|
43
|
+
};
|
|
44
|
+
const saveJson = (storage, key, value) => {
|
|
45
|
+
try {
|
|
46
|
+
storage.setItem(key, JSON.stringify(value));
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Swallow to keep SDK non-blocking.
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
export class IdentityManager {
|
|
53
|
+
constructor(storage) {
|
|
54
|
+
this.storage = storage ?? resolveStorage();
|
|
55
|
+
this.state = this.loadState();
|
|
56
|
+
}
|
|
57
|
+
getContext() {
|
|
58
|
+
return {
|
|
59
|
+
anonymousId: this.state.anonymousId,
|
|
60
|
+
userId: this.state.userId,
|
|
61
|
+
traits: { ...this.state.traits },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
identify(userId, traits) {
|
|
65
|
+
this.state.userId = userId;
|
|
66
|
+
this.state.traits = {
|
|
67
|
+
...this.state.traits,
|
|
68
|
+
...(traits ?? {}),
|
|
69
|
+
};
|
|
70
|
+
try {
|
|
71
|
+
this.storage.setItem(STORAGE_KEYS.userId, userId);
|
|
72
|
+
saveJson(this.storage, STORAGE_KEYS.userTraits, this.state.traits);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Swallow to keep SDK non-blocking.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
clearUser() {
|
|
79
|
+
this.state.userId = null;
|
|
80
|
+
this.state.traits = {};
|
|
81
|
+
try {
|
|
82
|
+
this.storage.removeItem(STORAGE_KEYS.userId);
|
|
83
|
+
this.storage.removeItem(STORAGE_KEYS.userTraits);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Swallow to keep SDK non-blocking.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
rotateAnonymousId() {
|
|
90
|
+
const anonymousId = generateAnonymousId();
|
|
91
|
+
this.state.anonymousId = anonymousId;
|
|
92
|
+
try {
|
|
93
|
+
this.storage.setItem(STORAGE_KEYS.anonymousId, anonymousId);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Swallow to keep SDK non-blocking.
|
|
97
|
+
}
|
|
98
|
+
return anonymousId;
|
|
99
|
+
}
|
|
100
|
+
reset() {
|
|
101
|
+
this.clearUser();
|
|
102
|
+
this.rotateAnonymousId();
|
|
103
|
+
}
|
|
104
|
+
loadState() {
|
|
105
|
+
const storedAnonymousId = this.storage.getItem(STORAGE_KEYS.anonymousId);
|
|
106
|
+
const anonymousId = storedAnonymousId || generateAnonymousId();
|
|
107
|
+
if (!storedAnonymousId) {
|
|
108
|
+
try {
|
|
109
|
+
this.storage.setItem(STORAGE_KEYS.anonymousId, anonymousId);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// Swallow to keep SDK non-blocking.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const userId = this.storage.getItem(STORAGE_KEYS.userId);
|
|
116
|
+
const traits = safeJsonParse(this.storage.getItem(STORAGE_KEYS.userTraits));
|
|
117
|
+
return {
|
|
118
|
+
anonymousId,
|
|
119
|
+
userId,
|
|
120
|
+
traits,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { SessionContext } from "../core/types";
|
|
2
|
+
type StorageLike = Pick<Storage, "getItem" | "setItem" | "removeItem">;
|
|
3
|
+
type SessionLifecycleResult = {
|
|
4
|
+
sessionId: string;
|
|
5
|
+
started: boolean;
|
|
6
|
+
ended: {
|
|
7
|
+
sessionId: string;
|
|
8
|
+
durationMs: number;
|
|
9
|
+
} | null;
|
|
10
|
+
};
|
|
11
|
+
export declare class SessionManager {
|
|
12
|
+
private readonly storage;
|
|
13
|
+
private readonly timeoutMs;
|
|
14
|
+
private context;
|
|
15
|
+
constructor(timeoutMs?: number, storage?: StorageLike);
|
|
16
|
+
getContext(): SessionContext;
|
|
17
|
+
ensureActiveSession(nowMs?: number): SessionLifecycleResult;
|
|
18
|
+
touch(nowMs?: number): void;
|
|
19
|
+
endSession(nowMs?: number): {
|
|
20
|
+
sessionId: string;
|
|
21
|
+
durationMs: number;
|
|
22
|
+
} | null;
|
|
23
|
+
reset(nowMs?: number): string;
|
|
24
|
+
private startSession;
|
|
25
|
+
private clear;
|
|
26
|
+
private loadState;
|
|
27
|
+
}
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { DEFAULT_SESSION_TIMEOUT_MS, STORAGE_KEYS } from "../core/constants";
|
|
2
|
+
import { generateSessionId } from "../normalize/event-id";
|
|
3
|
+
const createMemoryStorage = () => {
|
|
4
|
+
const store = new Map();
|
|
5
|
+
return {
|
|
6
|
+
getItem: (key) => (store.has(key) ? store.get(key) : null),
|
|
7
|
+
setItem: (key, value) => {
|
|
8
|
+
store.set(key, value);
|
|
9
|
+
},
|
|
10
|
+
removeItem: (key) => {
|
|
11
|
+
store.delete(key);
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
const resolveStorage = () => {
|
|
16
|
+
try {
|
|
17
|
+
if (typeof globalThis.sessionStorage !== "undefined") {
|
|
18
|
+
const probeKey = "clue:session_probe";
|
|
19
|
+
globalThis.sessionStorage.setItem(probeKey, "1");
|
|
20
|
+
globalThis.sessionStorage.removeItem(probeKey);
|
|
21
|
+
return globalThis.sessionStorage;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Ignore and fallback.
|
|
26
|
+
}
|
|
27
|
+
return createMemoryStorage();
|
|
28
|
+
};
|
|
29
|
+
const parseMs = (value) => {
|
|
30
|
+
if (!value) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const parsed = Number(value);
|
|
34
|
+
if (Number.isFinite(parsed)) {
|
|
35
|
+
return parsed;
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
};
|
|
39
|
+
export class SessionManager {
|
|
40
|
+
constructor(timeoutMs = DEFAULT_SESSION_TIMEOUT_MS, storage) {
|
|
41
|
+
this.timeoutMs = timeoutMs;
|
|
42
|
+
this.storage = storage ?? resolveStorage();
|
|
43
|
+
this.context = this.loadState();
|
|
44
|
+
}
|
|
45
|
+
getContext() {
|
|
46
|
+
return { ...this.context };
|
|
47
|
+
}
|
|
48
|
+
ensureActiveSession(nowMs = Date.now()) {
|
|
49
|
+
const currentSessionId = this.context.sessionId;
|
|
50
|
+
const currentStartedAt = this.context.startedAtMs;
|
|
51
|
+
const lastActivityAt = this.context.lastActivityAtMs;
|
|
52
|
+
if (!currentSessionId || !currentStartedAt || !lastActivityAt) {
|
|
53
|
+
const started = this.startSession(nowMs);
|
|
54
|
+
return {
|
|
55
|
+
sessionId: started,
|
|
56
|
+
started: true,
|
|
57
|
+
ended: null,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const idleMs = nowMs - lastActivityAt;
|
|
61
|
+
if (idleMs > this.timeoutMs) {
|
|
62
|
+
const endedDuration = Math.max(0, nowMs - currentStartedAt);
|
|
63
|
+
const ended = {
|
|
64
|
+
sessionId: currentSessionId,
|
|
65
|
+
durationMs: endedDuration,
|
|
66
|
+
};
|
|
67
|
+
const newSessionId = this.startSession(nowMs);
|
|
68
|
+
return {
|
|
69
|
+
sessionId: newSessionId,
|
|
70
|
+
started: true,
|
|
71
|
+
ended,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
this.touch(nowMs);
|
|
75
|
+
return {
|
|
76
|
+
sessionId: currentSessionId,
|
|
77
|
+
started: false,
|
|
78
|
+
ended: null,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
touch(nowMs = Date.now()) {
|
|
82
|
+
this.context.lastActivityAtMs = nowMs;
|
|
83
|
+
try {
|
|
84
|
+
this.storage.setItem(STORAGE_KEYS.sessionLastActivityAt, String(nowMs));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// Swallow to keep SDK non-blocking.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
endSession(nowMs = Date.now()) {
|
|
91
|
+
if (!this.context.sessionId || !this.context.startedAtMs) {
|
|
92
|
+
this.clear();
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const ended = {
|
|
96
|
+
sessionId: this.context.sessionId,
|
|
97
|
+
durationMs: Math.max(0, nowMs - this.context.startedAtMs),
|
|
98
|
+
};
|
|
99
|
+
this.clear();
|
|
100
|
+
return ended;
|
|
101
|
+
}
|
|
102
|
+
reset(nowMs = Date.now()) {
|
|
103
|
+
this.clear();
|
|
104
|
+
return this.startSession(nowMs);
|
|
105
|
+
}
|
|
106
|
+
startSession(nowMs) {
|
|
107
|
+
const sessionId = generateSessionId();
|
|
108
|
+
this.context = {
|
|
109
|
+
sessionId,
|
|
110
|
+
startedAtMs: nowMs,
|
|
111
|
+
lastActivityAtMs: nowMs,
|
|
112
|
+
};
|
|
113
|
+
try {
|
|
114
|
+
this.storage.setItem(STORAGE_KEYS.sessionId, sessionId);
|
|
115
|
+
this.storage.setItem(STORAGE_KEYS.sessionStartedAt, String(nowMs));
|
|
116
|
+
this.storage.setItem(STORAGE_KEYS.sessionLastActivityAt, String(nowMs));
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// Swallow to keep SDK non-blocking.
|
|
120
|
+
}
|
|
121
|
+
return sessionId;
|
|
122
|
+
}
|
|
123
|
+
clear() {
|
|
124
|
+
this.context = {
|
|
125
|
+
sessionId: null,
|
|
126
|
+
startedAtMs: null,
|
|
127
|
+
lastActivityAtMs: null,
|
|
128
|
+
};
|
|
129
|
+
try {
|
|
130
|
+
this.storage.removeItem(STORAGE_KEYS.sessionId);
|
|
131
|
+
this.storage.removeItem(STORAGE_KEYS.sessionStartedAt);
|
|
132
|
+
this.storage.removeItem(STORAGE_KEYS.sessionLastActivityAt);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Swallow to keep SDK non-blocking.
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
loadState() {
|
|
139
|
+
const sessionId = this.storage.getItem(STORAGE_KEYS.sessionId);
|
|
140
|
+
const startedAtMs = parseMs(this.storage.getItem(STORAGE_KEYS.sessionStartedAt));
|
|
141
|
+
const lastActivityAtMs = parseMs(this.storage.getItem(STORAGE_KEYS.sessionLastActivityAt));
|
|
142
|
+
if (!sessionId || !startedAtMs || !lastActivityAtMs) {
|
|
143
|
+
return {
|
|
144
|
+
sessionId: null,
|
|
145
|
+
startedAtMs: null,
|
|
146
|
+
lastActivityAtMs: null,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
sessionId,
|
|
151
|
+
startedAtMs,
|
|
152
|
+
lastActivityAtMs,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
type StorageLike = Pick<Storage, "getItem" | "setItem" | "removeItem">;
|
|
2
|
+
type TabManagerOptions = {
|
|
3
|
+
claimStorage?: StorageLike | null;
|
|
4
|
+
documentClaimId?: string;
|
|
5
|
+
now?: () => number;
|
|
6
|
+
bindLifecycleEvents?: boolean;
|
|
7
|
+
};
|
|
8
|
+
export declare class TabManager {
|
|
9
|
+
private readonly storage;
|
|
10
|
+
private readonly claimStorage;
|
|
11
|
+
private readonly documentClaimId;
|
|
12
|
+
private readonly now;
|
|
13
|
+
private readonly bindLifecycleEvents;
|
|
14
|
+
private readonly tabId;
|
|
15
|
+
constructor(storage?: StorageLike, options?: TabManagerOptions);
|
|
16
|
+
getTabId(): string;
|
|
17
|
+
private loadOrCreateTabId;
|
|
18
|
+
private isClaimedByAnotherDocument;
|
|
19
|
+
private claimTabId;
|
|
20
|
+
private installLifecycleCleanup;
|
|
21
|
+
}
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { STORAGE_KEYS } from "../core/constants";
|
|
2
|
+
import { generateEventId, generateTabId } from "../normalize/event-id";
|
|
3
|
+
const TAB_CLAIM_KEY_PREFIX = "clue:tab_claim:";
|
|
4
|
+
const TAB_CLAIM_TTL_MS = 5 * 60 * 1000;
|
|
5
|
+
const DOCUMENT_CLAIM_ID = `doc_${generateEventId()}`;
|
|
6
|
+
const createMemoryStorage = () => {
|
|
7
|
+
const store = new Map();
|
|
8
|
+
return {
|
|
9
|
+
getItem: (key) => (store.has(key) ? store.get(key) : null),
|
|
10
|
+
setItem: (key, value) => {
|
|
11
|
+
store.set(key, value);
|
|
12
|
+
},
|
|
13
|
+
removeItem: (key) => {
|
|
14
|
+
store.delete(key);
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
const resolveStorage = () => {
|
|
19
|
+
try {
|
|
20
|
+
if (typeof globalThis.sessionStorage !== "undefined") {
|
|
21
|
+
const probeKey = "clue:tab_probe";
|
|
22
|
+
globalThis.sessionStorage.setItem(probeKey, "1");
|
|
23
|
+
globalThis.sessionStorage.removeItem(probeKey);
|
|
24
|
+
return globalThis.sessionStorage;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Ignore and fallback.
|
|
29
|
+
}
|
|
30
|
+
return createMemoryStorage();
|
|
31
|
+
};
|
|
32
|
+
const resolveClaimStorage = () => {
|
|
33
|
+
try {
|
|
34
|
+
if (typeof globalThis.localStorage !== "undefined") {
|
|
35
|
+
const probeKey = "clue:tab_claim_probe";
|
|
36
|
+
globalThis.localStorage.setItem(probeKey, "1");
|
|
37
|
+
globalThis.localStorage.removeItem(probeKey);
|
|
38
|
+
return globalThis.localStorage;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// Ignore and continue without duplicate-tab coordination.
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
};
|
|
46
|
+
const parseTabClaim = (value) => {
|
|
47
|
+
if (!value) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(value);
|
|
52
|
+
if (typeof parsed.claimId === "string" &&
|
|
53
|
+
typeof parsed.expiresAt === "number") {
|
|
54
|
+
return {
|
|
55
|
+
claimId: parsed.claimId,
|
|
56
|
+
expiresAt: parsed.expiresAt,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Ignore invalid claim records and let the current document reclaim it.
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
};
|
|
65
|
+
export class TabManager {
|
|
66
|
+
constructor(storage, options) {
|
|
67
|
+
this.storage = storage ?? resolveStorage();
|
|
68
|
+
this.claimStorage =
|
|
69
|
+
options && "claimStorage" in options
|
|
70
|
+
? (options.claimStorage ?? null)
|
|
71
|
+
: resolveClaimStorage();
|
|
72
|
+
this.documentClaimId = options?.documentClaimId ?? DOCUMENT_CLAIM_ID;
|
|
73
|
+
this.now = options?.now ?? Date.now;
|
|
74
|
+
this.bindLifecycleEvents = options?.bindLifecycleEvents ?? true;
|
|
75
|
+
this.tabId = this.loadOrCreateTabId();
|
|
76
|
+
this.claimTabId(this.tabId);
|
|
77
|
+
this.installLifecycleCleanup();
|
|
78
|
+
}
|
|
79
|
+
getTabId() {
|
|
80
|
+
return this.tabId;
|
|
81
|
+
}
|
|
82
|
+
loadOrCreateTabId() {
|
|
83
|
+
const stored = this.storage.getItem(STORAGE_KEYS.tabId);
|
|
84
|
+
if (stored && !this.isClaimedByAnotherDocument(stored)) {
|
|
85
|
+
return stored;
|
|
86
|
+
}
|
|
87
|
+
const tabId = generateTabId();
|
|
88
|
+
try {
|
|
89
|
+
this.storage.setItem(STORAGE_KEYS.tabId, tabId);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// Swallow to keep SDK non-blocking.
|
|
93
|
+
}
|
|
94
|
+
return tabId;
|
|
95
|
+
}
|
|
96
|
+
isClaimedByAnotherDocument(tabId) {
|
|
97
|
+
if (!this.claimStorage) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
const claim = parseTabClaim(this.claimStorage.getItem(`${TAB_CLAIM_KEY_PREFIX}${tabId}`));
|
|
101
|
+
if (!claim || claim.expiresAt <= this.now()) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
return claim.claimId !== this.documentClaimId;
|
|
105
|
+
}
|
|
106
|
+
claimTabId(tabId) {
|
|
107
|
+
if (!this.claimStorage) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
this.claimStorage.setItem(`${TAB_CLAIM_KEY_PREFIX}${tabId}`, JSON.stringify({
|
|
112
|
+
claimId: this.documentClaimId,
|
|
113
|
+
expiresAt: this.now() + TAB_CLAIM_TTL_MS,
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// Swallow to keep SDK non-blocking.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
installLifecycleCleanup() {
|
|
121
|
+
if (!this.bindLifecycleEvents || !this.claimStorage) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
if (typeof globalThis.addEventListener !== "function") {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const cleanup = () => {
|
|
129
|
+
const claimKey = `${TAB_CLAIM_KEY_PREFIX}${this.tabId}`;
|
|
130
|
+
const claim = parseTabClaim(this.claimStorage?.getItem(claimKey) ?? null);
|
|
131
|
+
if (claim?.claimId === this.documentClaimId) {
|
|
132
|
+
this.claimStorage?.removeItem(claimKey);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
globalThis.addEventListener("pagehide", cleanup, { once: true });
|
|
136
|
+
globalThis.addEventListener("beforeunload", cleanup, { once: true });
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// Swallow to keep SDK non-blocking.
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|