@joryio/web-sdk 0.0.0-stage → 1.3.0-beta.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/CHANGELOG.md +5 -0
- package/LICENSE +54 -0
- package/README.md +497 -2
- package/dist/core/config.d.ts +101 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/device.d.ts +14 -0
- package/dist/core/device.d.ts.map +1 -0
- package/dist/core/ecommerce.d.ts +67 -0
- package/dist/core/ecommerce.d.ts.map +1 -0
- package/dist/core/identity.d.ts +36 -0
- package/dist/core/identity.d.ts.map +1 -0
- package/dist/core/inapp.d.ts +169 -0
- package/dist/core/inapp.d.ts.map +1 -0
- package/dist/core/network.d.ts +158 -0
- package/dist/core/network.d.ts.map +1 -0
- package/dist/core/outbox.d.ts +79 -0
- package/dist/core/outbox.d.ts.map +1 -0
- package/dist/core/pending-attributes.d.ts +43 -0
- package/dist/core/pending-attributes.d.ts.map +1 -0
- package/dist/core/push.d.ts +59 -0
- package/dist/core/push.d.ts.map +1 -0
- package/dist/core/queue.d.ts +107 -0
- package/dist/core/queue.d.ts.map +1 -0
- package/dist/core/sdk.d.ts +169 -0
- package/dist/core/sdk.d.ts.map +1 -0
- package/dist/core/session.d.ts +36 -0
- package/dist/core/session.d.ts.map +1 -0
- package/dist/core/storage.d.ts +17 -0
- package/dist/core/storage.d.ts.map +1 -0
- package/dist/core/user.d.ts +28 -0
- package/dist/core/user.d.ts.map +1 -0
- package/dist/core/utm.d.ts +39 -0
- package/dist/core/utm.d.ts.map +1 -0
- package/dist/core/values.d.ts +3 -0
- package/dist/core/values.d.ts.map +1 -0
- package/dist/core/write-retry.d.ts +66 -0
- package/dist/core/write-retry.d.ts.map +1 -0
- package/dist/index.d.mts +711 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/joryio-sw.js +6 -0
- package/dist/joryio.cjs.js +7265 -0
- package/dist/joryio.min.js +7 -0
- package/dist/joryio.min.js.map +1 -0
- package/dist/joryio.mjs +7254 -0
- package/dist/joryio.umd.js +7271 -0
- package/dist/utils/bucket-hash.d.ts +2 -0
- package/dist/utils/bucket-hash.d.ts.map +1 -0
- package/dist/utils/logger.d.ts +14 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/uuid.d.ts +3 -0
- package/dist/utils/uuid.d.ts.map +1 -0
- package/package.json +90 -4
- package/src/core/config.ts +362 -0
- package/src/core/device.ts +50 -0
- package/src/core/ecommerce.ts +482 -0
- package/src/core/identity.ts +251 -0
- package/src/core/inapp.ts +2419 -0
- package/src/core/network.ts +1261 -0
- package/src/core/outbox.ts +707 -0
- package/src/core/pending-attributes.ts +350 -0
- package/src/core/push.ts +667 -0
- package/src/core/queue.ts +894 -0
- package/src/core/sdk.ts +3108 -0
- package/src/core/session.ts +311 -0
- package/src/core/storage.ts +157 -0
- package/src/core/user.ts +203 -0
- package/src/core/utm.ts +304 -0
- package/src/core/values.ts +85 -0
- package/src/core/write-retry.ts +136 -0
- package/src/index.ts +305 -0
- package/src/snippet/loader.js +134 -0
- package/src/utils/bucket-hash.ts +44 -0
- package/src/utils/logger.ts +58 -0
- package/src/utils/uuid.ts +29 -0
- package/src/worker/joryio-sw.js +218 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Manager for tracking user sessions
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { StorageManager } from './storage';
|
|
6
|
+
import { Logger } from '../utils/logger';
|
|
7
|
+
import { generateShortId } from '../utils/uuid';
|
|
8
|
+
|
|
9
|
+
export const SESSION_ID_KEY = 'session_id';
|
|
10
|
+
const SESSION_START_KEY = 'session_start';
|
|
11
|
+
const LAST_ACTIVITY_KEY = 'last_activity';
|
|
12
|
+
/**
|
|
13
|
+
* How often the last-activity stamp is actually written to storage. It only
|
|
14
|
+
* feeds a 30-minute timeout decision, so a 30s resolution is far more precision
|
|
15
|
+
* than the decision needs — see updateActivity().
|
|
16
|
+
*/
|
|
17
|
+
const ACTIVITY_PERSIST_INTERVAL_MS = 30_000;
|
|
18
|
+
const DEFAULT_SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
19
|
+
const MAX_SESSION_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours max session age
|
|
20
|
+
|
|
21
|
+
export interface SessionInfo {
|
|
22
|
+
sessionId: string;
|
|
23
|
+
sessionStart: number;
|
|
24
|
+
lastActivity: number;
|
|
25
|
+
isNewSession: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class SessionManager {
|
|
29
|
+
private storage: StorageManager;
|
|
30
|
+
private logger: Logger;
|
|
31
|
+
private sessionTimeoutMs: number;
|
|
32
|
+
private currentSessionId: string | null = null;
|
|
33
|
+
/** Exact in-memory activity time; the persisted copy is throttled. */
|
|
34
|
+
private lastActivityAt = 0;
|
|
35
|
+
private lastActivityPersistedAt = 0;
|
|
36
|
+
private isNewSession: boolean = false;
|
|
37
|
+
private onNewSessionCallback: (() => void) | null = null;
|
|
38
|
+
private boundVisibilityHandler: (() => void) | null = null;
|
|
39
|
+
|
|
40
|
+
constructor(storage: StorageManager, logger: Logger, sessionTimeoutMs?: number) {
|
|
41
|
+
this.storage = storage;
|
|
42
|
+
this.logger = logger;
|
|
43
|
+
this.sessionTimeoutMs = sessionTimeoutMs || DEFAULT_SESSION_TIMEOUT_MS;
|
|
44
|
+
|
|
45
|
+
// Initialize session on construction
|
|
46
|
+
this.initializeSession();
|
|
47
|
+
|
|
48
|
+
// Listen for visibility changes to check session when tab becomes visible
|
|
49
|
+
if (typeof document !== 'undefined') {
|
|
50
|
+
this.boundVisibilityHandler = () => {
|
|
51
|
+
if (document.visibilityState === 'visible') {
|
|
52
|
+
this.checkAndRefreshSession();
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
document.addEventListener('visibilitychange', this.boundVisibilityHandler);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Set callback to be called when a new session is created (e.g., to track session start)
|
|
61
|
+
*/
|
|
62
|
+
onNewSession(callback: () => void): void {
|
|
63
|
+
this.onNewSessionCallback = callback;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Initialize or resume session
|
|
68
|
+
*/
|
|
69
|
+
private initializeSession(): void {
|
|
70
|
+
const existingSessionId = this.storage.get<string>(SESSION_ID_KEY);
|
|
71
|
+
const lastActivity = this.storage.get<number>(LAST_ACTIVITY_KEY);
|
|
72
|
+
const sessionStart = this.storage.get<number>(SESSION_START_KEY);
|
|
73
|
+
|
|
74
|
+
if (existingSessionId && lastActivity) {
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
const timeSinceLastActivity = now - lastActivity;
|
|
77
|
+
const sessionAge = sessionStart ? now - sessionStart : Infinity;
|
|
78
|
+
|
|
79
|
+
// Check both inactivity timeout AND max session age
|
|
80
|
+
// This prevents sessions from being kept alive indefinitely by background tabs
|
|
81
|
+
if (timeSinceLastActivity < this.sessionTimeoutMs && sessionAge < MAX_SESSION_AGE_MS) {
|
|
82
|
+
// Resume existing session
|
|
83
|
+
this.currentSessionId = existingSessionId;
|
|
84
|
+
this.isNewSession = false;
|
|
85
|
+
this.updateActivity();
|
|
86
|
+
this.logger.debug(`Resumed session: ${this.currentSessionId}`);
|
|
87
|
+
return;
|
|
88
|
+
} else if (sessionAge >= MAX_SESSION_AGE_MS) {
|
|
89
|
+
this.logger.debug(`Session too old (${Math.round(sessionAge / 1000 / 60 / 60)}h), creating new session`);
|
|
90
|
+
} else {
|
|
91
|
+
this.logger.debug(`Session expired (${timeSinceLastActivity}ms since last activity)`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Create new session
|
|
96
|
+
this.startNewSession();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Start a new session
|
|
101
|
+
*/
|
|
102
|
+
private startNewSession(): void {
|
|
103
|
+
const previousSessionId = this.currentSessionId;
|
|
104
|
+
this.currentSessionId = generateShortId();
|
|
105
|
+
this.isNewSession = true;
|
|
106
|
+
const now = Date.now();
|
|
107
|
+
|
|
108
|
+
this.storage.set(SESSION_ID_KEY, this.currentSessionId);
|
|
109
|
+
this.storage.set(SESSION_START_KEY, now);
|
|
110
|
+
this.storage.set(LAST_ACTIVITY_KEY, now);
|
|
111
|
+
this.lastActivityAt = now;
|
|
112
|
+
this.lastActivityPersistedAt = now;
|
|
113
|
+
|
|
114
|
+
this.logger.debug(`Started new session: ${this.currentSessionId}`);
|
|
115
|
+
|
|
116
|
+
// Call the callback if this is a session refresh (not initial creation)
|
|
117
|
+
if (previousSessionId && this.onNewSessionCallback) {
|
|
118
|
+
this.onNewSessionCallback();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Check session validity and refresh if needed (called when tab becomes visible)
|
|
124
|
+
*/
|
|
125
|
+
private checkAndRefreshSession(): void {
|
|
126
|
+
const lastActivity = this.storage.get<number>(LAST_ACTIVITY_KEY);
|
|
127
|
+
const sessionStart = this.storage.get<number>(SESSION_START_KEY);
|
|
128
|
+
|
|
129
|
+
if (lastActivity && sessionStart) {
|
|
130
|
+
const now = Date.now();
|
|
131
|
+
const timeSinceLastActivity = now - lastActivity;
|
|
132
|
+
const sessionAge = now - sessionStart;
|
|
133
|
+
|
|
134
|
+
// Check if session should expire
|
|
135
|
+
if (timeSinceLastActivity >= this.sessionTimeoutMs) {
|
|
136
|
+
this.logger.debug(`Session expired on tab focus (${Math.round(timeSinceLastActivity / 1000 / 60)} min inactive)`);
|
|
137
|
+
this.startNewSession();
|
|
138
|
+
} else if (sessionAge >= MAX_SESSION_AGE_MS) {
|
|
139
|
+
this.logger.debug(`Session too old on tab focus (${Math.round(sessionAge / 1000 / 60 / 60)}h old)`);
|
|
140
|
+
this.startNewSession();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* End the session if it timed out or outlived its maximum age, and start a
|
|
147
|
+
* new one (its Session Start comes from the onNewSession hook). Called on
|
|
148
|
+
* every track(): the visibility check alone never ended a session in a tab
|
|
149
|
+
* that stayed visible (contract item 32). Returns true when a new session
|
|
150
|
+
* began.
|
|
151
|
+
*/
|
|
152
|
+
refreshIfExpired(): boolean {
|
|
153
|
+
if (!this.currentSessionId) return false;
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
// The later of this tab's exact stamp and the stored one (another tab
|
|
156
|
+
// of the same site keeps the session alive too).
|
|
157
|
+
const lastActivity = Math.max(this.lastActivityAt, this.storage.get<number>(LAST_ACTIVITY_KEY) || 0) || now;
|
|
158
|
+
const sessionStart = this.storage.get<number>(SESSION_START_KEY) || now;
|
|
159
|
+
if (now - lastActivity >= this.sessionTimeoutMs) {
|
|
160
|
+
this.logger.debug(`Session expired (${Math.round((now - lastActivity) / 1000 / 60)} min inactive)`);
|
|
161
|
+
this.startNewSession();
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
if (now - sessionStart >= MAX_SESSION_AGE_MS) {
|
|
165
|
+
this.logger.debug('Session reached its maximum age');
|
|
166
|
+
this.startNewSession();
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Update last activity timestamp (call this when tracking events)
|
|
174
|
+
*/
|
|
175
|
+
updateActivity(): void {
|
|
176
|
+
if (!this.currentSessionId) return;
|
|
177
|
+
|
|
178
|
+
// Written on EVERY tracked event, and localStorage.setItem is synchronous
|
|
179
|
+
// main-thread I/O. A page that tracks a burst (scroll, product impressions)
|
|
180
|
+
// paid one write per event: measured in headless Chrome as 40 writes for a
|
|
181
|
+
// 40-event burst.
|
|
182
|
+
//
|
|
183
|
+
// The stamp only has to be accurate enough to judge a 30-MINUTE session
|
|
184
|
+
// timeout, so persisting at most once every 30 seconds is ~60x fewer writes
|
|
185
|
+
// and cannot change a timeout decision. The in-memory value stays exact; only
|
|
186
|
+
// the persisted copy is throttled, so the worst case after an abrupt tab
|
|
187
|
+
// close is a stamp up to 30s stale against a 30min threshold.
|
|
188
|
+
const now = Date.now();
|
|
189
|
+
this.lastActivityAt = now;
|
|
190
|
+
if (now - this.lastActivityPersistedAt < ACTIVITY_PERSIST_INTERVAL_MS) return;
|
|
191
|
+
this.lastActivityPersistedAt = now;
|
|
192
|
+
this.storage.set(LAST_ACTIVITY_KEY, now);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Get current session information
|
|
197
|
+
*/
|
|
198
|
+
getSessionInfo(): SessionInfo {
|
|
199
|
+
// A READ (rule 54): never starts or extends a session. With none (after
|
|
200
|
+
// wipeData(), before the next tracked event) the id is ''.
|
|
201
|
+
const sessionId = this.currentSessionId || this.storage.get<string>(SESSION_ID_KEY) || '';
|
|
202
|
+
|
|
203
|
+
const sessionStart = this.storage.get<number>(SESSION_START_KEY) || Date.now();
|
|
204
|
+
// In-memory value first: it is exact, whereas the persisted copy is
|
|
205
|
+
// throttled to one write per ACTIVITY_PERSIST_INTERVAL_MS.
|
|
206
|
+
const lastActivity =
|
|
207
|
+
this.lastActivityAt || this.storage.get<number>(LAST_ACTIVITY_KEY) || Date.now();
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
sessionId,
|
|
211
|
+
sessionStart,
|
|
212
|
+
lastActivity,
|
|
213
|
+
isNewSession: this.isNewSession,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Get current session ID
|
|
219
|
+
*/
|
|
220
|
+
getSessionId(): string {
|
|
221
|
+
// A READ (rule 54): in-app sync, resolve and impressions read the id, and
|
|
222
|
+
// a read used to start a session silently - one with no Session Start.
|
|
223
|
+
return this.currentSessionId || this.storage.get<string>(SESSION_ID_KEY) || '';
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The lifecycle path: the current session, or a new one when there is none
|
|
228
|
+
* (after wipeData()). A new one is announced through onNewSession - which
|
|
229
|
+
* tracks its Session Start - so no session ever starts without one.
|
|
230
|
+
* Returns the session id.
|
|
231
|
+
*/
|
|
232
|
+
ensureSession(): string {
|
|
233
|
+
if (this.currentSessionId) return this.currentSessionId;
|
|
234
|
+
const stored = this.storage.get<string>(SESSION_ID_KEY);
|
|
235
|
+
if (stored) {
|
|
236
|
+
this.currentSessionId = stored;
|
|
237
|
+
return stored;
|
|
238
|
+
}
|
|
239
|
+
this.currentSessionId = generateShortId();
|
|
240
|
+
this.isNewSession = true;
|
|
241
|
+
const now = Date.now();
|
|
242
|
+
this.storage.set(SESSION_ID_KEY, this.currentSessionId);
|
|
243
|
+
this.storage.set(SESSION_START_KEY, now);
|
|
244
|
+
this.storage.set(LAST_ACTIVITY_KEY, now);
|
|
245
|
+
this.lastActivityAt = now;
|
|
246
|
+
this.lastActivityPersistedAt = now;
|
|
247
|
+
this.logger.debug(`Started new session: ${this.currentSessionId}`);
|
|
248
|
+
if (this.onNewSessionCallback) this.onNewSessionCallback();
|
|
249
|
+
return this.currentSessionId;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Another tab changed the visitor (its reset()): take over the session it
|
|
254
|
+
* stored - it started that session and sent its Session Start - instead of
|
|
255
|
+
* carrying the outgoing visitor's session id in memory here. No stored
|
|
256
|
+
* session (a wipe there): this one is forgotten too, and the next call that
|
|
257
|
+
* needs one starts it (with its Session Start). Returns true when the
|
|
258
|
+
* session id here changed.
|
|
259
|
+
*/
|
|
260
|
+
followStoredSession(): boolean {
|
|
261
|
+
const stored = this.storage.get<string>(SESSION_ID_KEY);
|
|
262
|
+
if (stored === this.currentSessionId) return false;
|
|
263
|
+
this.currentSessionId = stored || null;
|
|
264
|
+
this.isNewSession = false;
|
|
265
|
+
this.lastActivityAt = stored ? this.storage.get<number>(LAST_ACTIVITY_KEY) || Date.now() : 0;
|
|
266
|
+
this.lastActivityPersistedAt = this.lastActivityAt;
|
|
267
|
+
this.logger.debug(`Following the session another tab started: ${stored || '(none)'}`);
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Manually renew the session (reset timeout)
|
|
273
|
+
*/
|
|
274
|
+
renewSession(): void {
|
|
275
|
+
this.updateActivity();
|
|
276
|
+
this.logger.debug(`Session renewed: ${this.currentSessionId}`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Force start a new session
|
|
281
|
+
*/
|
|
282
|
+
resetSession(): void {
|
|
283
|
+
this.logger.debug(`Resetting session (was: ${this.currentSessionId})`);
|
|
284
|
+
this.startNewSession();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Forget the session entirely (wipeData). The next call that needs a session
|
|
289
|
+
* starts a fresh one; no Session Start is tracked for it, because nothing
|
|
290
|
+
* about the wiped visitor may carry over.
|
|
291
|
+
*/
|
|
292
|
+
clear(): void {
|
|
293
|
+
this.currentSessionId = null;
|
|
294
|
+
this.isNewSession = false;
|
|
295
|
+
this.lastActivityAt = 0;
|
|
296
|
+
this.lastActivityPersistedAt = 0;
|
|
297
|
+
this.storage.remove(SESSION_ID_KEY);
|
|
298
|
+
this.storage.remove(SESSION_START_KEY);
|
|
299
|
+
this.storage.remove(LAST_ACTIVITY_KEY);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Cleanup session manager
|
|
304
|
+
*/
|
|
305
|
+
destroy(): void {
|
|
306
|
+
if (this.boundVisibilityHandler && typeof document !== 'undefined') {
|
|
307
|
+
document.removeEventListener('visibilitychange', this.boundVisibilityHandler);
|
|
308
|
+
this.boundVisibilityHandler = null;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage Manager for localStorage with fallback to in-memory storage
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Logger } from '../utils/logger';
|
|
6
|
+
import { bucketHash } from '../utils/bucket-hash';
|
|
7
|
+
|
|
8
|
+
interface StorageItem<T = any> {
|
|
9
|
+
value: T;
|
|
10
|
+
expiresAt: number | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class StorageManager {
|
|
14
|
+
private prefix: string;
|
|
15
|
+
private memoryStore: Map<string, StorageItem> = new Map();
|
|
16
|
+
private isLocalStorageAvailable: boolean;
|
|
17
|
+
private logger: Logger;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Namespaces this workspace's localStorage so two SDK keys on one origin do
|
|
21
|
+
* not share a deviceId, session or consent state.
|
|
22
|
+
*
|
|
23
|
+
* Was the Java String.hashCode recurrence (`hash * 31 + char`), the last
|
|
24
|
+
* inline copy of it in the repo after every bucketing site moved to the
|
|
25
|
+
* shared util. Measured on real-format keys (`jry_live_<32 random>`) that
|
|
26
|
+
* recurrence was only ~2x worse than ideal here rather than dangerous - the
|
|
27
|
+
* shared `jry_live_` prefix is the part it weights LEAST, and the random tail
|
|
28
|
+
* dominates - and a single origin carries one or two SDK keys, so collisions
|
|
29
|
+
* were never a practical risk.
|
|
30
|
+
*
|
|
31
|
+
* It was replaced anyway, and the reason is timing rather than risk: the
|
|
32
|
+
* prefix is baked into every stored key, so changing it orphans every
|
|
33
|
+
* browser's deviceId, session and consent. That costs nothing today and can
|
|
34
|
+
* never be done again once real visitors hold state. Taking the free move now
|
|
35
|
+
* leaves ONE hash in the codebase with no exception to remember.
|
|
36
|
+
*/
|
|
37
|
+
private static hashKey(key: string): string {
|
|
38
|
+
return bucketHash(key).toString(36);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
constructor(sdkKey: string, logger: Logger) {
|
|
42
|
+
this.prefix = `jry_${StorageManager.hashKey(sdkKey)}_`;
|
|
43
|
+
this.logger = logger;
|
|
44
|
+
this.isLocalStorageAvailable = this.checkLocalStorageAvailable();
|
|
45
|
+
|
|
46
|
+
if (!this.isLocalStorageAvailable) {
|
|
47
|
+
this.logger.warn('LocalStorage not available, using in-memory storage');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private checkLocalStorageAvailable(): boolean {
|
|
52
|
+
try {
|
|
53
|
+
const testKey = '__jry_test__';
|
|
54
|
+
localStorage.setItem(testKey, 'test');
|
|
55
|
+
localStorage.removeItem(testKey);
|
|
56
|
+
return true;
|
|
57
|
+
} catch (e) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private getKey(key: string): string {
|
|
63
|
+
return `${this.prefix}${key}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The localStorage key one SDK key is stored under (to read `storage` events of other tabs). */
|
|
67
|
+
storageKeyFor(key: string): string {
|
|
68
|
+
return this.getKey(key);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
set<T>(key: string, value: T, expiresInDays?: number): void {
|
|
72
|
+
const item: StorageItem<T> = {
|
|
73
|
+
value,
|
|
74
|
+
expiresAt: expiresInDays
|
|
75
|
+
? Date.now() + expiresInDays * 24 * 60 * 60 * 1000
|
|
76
|
+
: null,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
if (this.isLocalStorageAvailable) {
|
|
80
|
+
try {
|
|
81
|
+
localStorage.setItem(this.getKey(key), JSON.stringify(item));
|
|
82
|
+
this.memoryStore.delete(key);
|
|
83
|
+
} catch (e) {
|
|
84
|
+
this.logger.error('Failed to write to localStorage:', e);
|
|
85
|
+
// Fallback to memory - and the OLD stored value goes, or get() would
|
|
86
|
+
// keep preferring it (a stale event queue re-sent on the next page).
|
|
87
|
+
try {
|
|
88
|
+
localStorage.removeItem(this.getKey(key));
|
|
89
|
+
} catch {
|
|
90
|
+
// nothing more to do
|
|
91
|
+
}
|
|
92
|
+
this.memoryStore.set(key, item);
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
this.memoryStore.set(key, item);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
get<T>(key: string): T | null {
|
|
100
|
+
let item: StorageItem<T> | null = null;
|
|
101
|
+
|
|
102
|
+
if (this.isLocalStorageAvailable) {
|
|
103
|
+
try {
|
|
104
|
+
const stored = localStorage.getItem(this.getKey(key));
|
|
105
|
+
if (stored) {
|
|
106
|
+
item = JSON.parse(stored);
|
|
107
|
+
}
|
|
108
|
+
} catch (e) {
|
|
109
|
+
this.logger.error('Failed to read from localStorage:', e);
|
|
110
|
+
try { localStorage.removeItem(this.getKey(key)); } catch {}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Fallback to memory store
|
|
115
|
+
if (!item) {
|
|
116
|
+
item = this.memoryStore.get(key) as StorageItem<T> || null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Check expiration
|
|
120
|
+
if (item && item.expiresAt && item.expiresAt < Date.now()) {
|
|
121
|
+
this.remove(key);
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return item?.value ?? null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
remove(key: string): void {
|
|
129
|
+
if (this.isLocalStorageAvailable) {
|
|
130
|
+
try {
|
|
131
|
+
localStorage.removeItem(this.getKey(key));
|
|
132
|
+
} catch (e) {
|
|
133
|
+
this.logger.error('Failed to remove from localStorage:', e);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
this.memoryStore.delete(key);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
clear(): void {
|
|
140
|
+
if (this.isLocalStorageAvailable) {
|
|
141
|
+
try {
|
|
142
|
+
// Remove only keys with our prefix
|
|
143
|
+
const keysToRemove: string[] = [];
|
|
144
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
145
|
+
const key = localStorage.key(i);
|
|
146
|
+
if (key && key.startsWith(this.prefix)) {
|
|
147
|
+
keysToRemove.push(key);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
|
151
|
+
} catch (e) {
|
|
152
|
+
this.logger.error('Failed to clear localStorage:', e);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
this.memoryStore.clear();
|
|
156
|
+
}
|
|
157
|
+
}
|
package/src/core/user.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Joryio SDK User
|
|
3
|
+
* Provides methods to update user subscription status
|
|
4
|
+
* Write-only for security - no getter methods
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Logger } from '../utils/logger';
|
|
8
|
+
import { NetworkClient } from './network';
|
|
9
|
+
import { IdentityManager } from './identity';
|
|
10
|
+
import type { WriteRunner, WriteRecord } from './write-retry';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Subscription status types
|
|
14
|
+
* - OPTED_IN: User has explicitly opted in (e.g., confirmed double opt-in)
|
|
15
|
+
* - SUBSCRIBED: User is subscribed but hasn't explicitly opted in
|
|
16
|
+
* - UNSUBSCRIBED: User has opted out
|
|
17
|
+
*/
|
|
18
|
+
export enum SubscriptionStatus {
|
|
19
|
+
// Wire value MUST be camelCase `optedIn` — the backend `@IsIn`/`@IsEnum`
|
|
20
|
+
// (sdk-subscriptions.controller.ts, update-channel-subscription.dto.ts) 400s
|
|
21
|
+
// on snake_case `opted_in` (a NormalizeEnumCasing migration renamed the stored
|
|
22
|
+
// value). See docs/SSOT_DRIFT_AUDIT.md B7 — the enum NAME stays OPTED_IN.
|
|
23
|
+
OPTED_IN = 'optedIn',
|
|
24
|
+
SUBSCRIBED = 'subscribed',
|
|
25
|
+
UNSUBSCRIBED = 'unsubscribed',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Channel types for subscriptions
|
|
30
|
+
*/
|
|
31
|
+
/*
|
|
32
|
+
* MIRRORS the backend's SUBSCRIPTION_CHANNELS, which is five, not four.
|
|
33
|
+
*
|
|
34
|
+
* `viber` was added there and never here, so this SDK refused locally - with a
|
|
35
|
+
* logged error and no request - a channel the backend accepts. Viber is a
|
|
36
|
+
* first-class campaign channel, so a web preference centre simply could not
|
|
37
|
+
* offer it, and the failure was client-side and silent to the server.
|
|
38
|
+
*/
|
|
39
|
+
export type SubscriptionChannel = 'email' | 'sms' | 'whatsapp' | 'push' | 'viber';
|
|
40
|
+
|
|
41
|
+
/** The runtime list, so validation and the type cannot drift apart. */
|
|
42
|
+
export const SUBSCRIPTION_CHANNELS: readonly SubscriptionChannel[] = [
|
|
43
|
+
'email',
|
|
44
|
+
'sms',
|
|
45
|
+
'whatsapp',
|
|
46
|
+
'push',
|
|
47
|
+
'viber',
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Subscription preferences object for bulk updates
|
|
52
|
+
*/
|
|
53
|
+
export type SubscriptionPreferences = Partial<Record<SubscriptionChannel, SubscriptionStatus>>;
|
|
54
|
+
|
|
55
|
+
export class User {
|
|
56
|
+
private logger: Logger;
|
|
57
|
+
private network: NetworkClient;
|
|
58
|
+
private identity: IdentityManager;
|
|
59
|
+
private apiEndpoint: string;
|
|
60
|
+
private runWrite: WriteRunner;
|
|
61
|
+
|
|
62
|
+
constructor(
|
|
63
|
+
logger: Logger,
|
|
64
|
+
network: NetworkClient,
|
|
65
|
+
identity: IdentityManager,
|
|
66
|
+
apiEndpoint: string,
|
|
67
|
+
runWrite?: WriteRunner,
|
|
68
|
+
) {
|
|
69
|
+
this.logger = logger;
|
|
70
|
+
this.network = network;
|
|
71
|
+
this.identity = identity;
|
|
72
|
+
this.apiEndpoint = apiEndpoint;
|
|
73
|
+
// Standalone use (tests): send once, immediately. The SDK passes its
|
|
74
|
+
// persisted, ordered outbox (outbox.ts) instead.
|
|
75
|
+
this.runWrite =
|
|
76
|
+
runWrite ||
|
|
77
|
+
((label, record) => {
|
|
78
|
+
this.sendOnce(record).then(
|
|
79
|
+
(res) => {
|
|
80
|
+
if (!res.ok) this.logger.error(`${label} failed (HTTP ${res.status || 'no response'})`);
|
|
81
|
+
},
|
|
82
|
+
(error) => this.logger.error(`${label} failed:`, error),
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private sendOnce(record: WriteRecord) {
|
|
88
|
+
if (record.kind === 'subscription') return this.network.sendSubscriptionUpdate(this.apiEndpoint, record.payload);
|
|
89
|
+
if (record.kind === 'group') return this.network.sendSubscriptionGroupUpdate(this.apiEndpoint, record.payload);
|
|
90
|
+
return Promise.resolve({ ok: false, status: 0 });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Both ids, captured NOW. The server resolves `userId` first and falls back
|
|
95
|
+
* to `anonymousId`, so a visitor whose identify has not been merged yet is
|
|
96
|
+
* still found. A write made while anonymous is sent with the user id once
|
|
97
|
+
* that anonymous id has been identified (resolved at send time by the
|
|
98
|
+
* outbox, contract item 38); a write of an earlier identity keeps its own.
|
|
99
|
+
*/
|
|
100
|
+
private ids(): { userId?: string; anonymousId: string } {
|
|
101
|
+
const userId = this.identity.getUserId();
|
|
102
|
+
return { ...(userId ? { userId } : {}), anonymousId: this.identity.getAnonymousId() };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Set subscription status for a channel
|
|
107
|
+
* @param channel - The channel (email, sms, whatsapp, push, viber)
|
|
108
|
+
* @param status - The subscription status (OPTED_IN, SUBSCRIBED, UNSUBSCRIBED)
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* user.setSubscription('email', SubscriptionStatus.OPTED_IN);
|
|
112
|
+
* user.setSubscription('sms', SubscriptionStatus.UNSUBSCRIBED);
|
|
113
|
+
*/
|
|
114
|
+
setSubscription(channel: SubscriptionChannel, status: SubscriptionStatus): void {
|
|
115
|
+
// An unsubscribe is a WITHDRAWAL: it is delivered even after optOut(), a
|
|
116
|
+
// denied consent or wipeData() (contract item 36). Any other status stops
|
|
117
|
+
// with them.
|
|
118
|
+
// Validate channel
|
|
119
|
+
if (!SUBSCRIPTION_CHANNELS.includes(channel)) {
|
|
120
|
+
this.logger.error(
|
|
121
|
+
`Invalid channel: ${channel}. Must be one of: ${SUBSCRIPTION_CHANNELS.join(', ')}`,
|
|
122
|
+
);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Validate status
|
|
127
|
+
const validStatuses = Object.values(SubscriptionStatus);
|
|
128
|
+
if (!validStatuses.includes(status)) {
|
|
129
|
+
this.logger.error(`Invalid status: ${status}. Must be one of: ${validStatuses.join(', ')}`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
this.logger.debug(`Setting ${channel} subscription status to ${status}`);
|
|
134
|
+
const payload = { channel, status, ...this.ids() };
|
|
135
|
+
this.runWrite(`Update ${channel} subscription`, { kind: 'subscription', payload });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Set multiple channel subscriptions at once
|
|
140
|
+
* @param preferences - Object mapping channels to their subscription status
|
|
141
|
+
*
|
|
142
|
+
* @example
|
|
143
|
+
* user.setSubscriptions({
|
|
144
|
+
* email: SubscriptionStatus.OPTED_IN,
|
|
145
|
+
* sms: SubscriptionStatus.SUBSCRIBED,
|
|
146
|
+
* push: SubscriptionStatus.UNSUBSCRIBED
|
|
147
|
+
* });
|
|
148
|
+
*/
|
|
149
|
+
setSubscriptions(preferences: SubscriptionPreferences): void {
|
|
150
|
+
if (!preferences || typeof preferences !== 'object') {
|
|
151
|
+
this.logger.error('setSubscriptions requires an object, e.g. { email: SubscriptionStatus.OPTED_IN }');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
for (const [channel, status] of Object.entries(preferences)) {
|
|
155
|
+
if (status !== undefined) {
|
|
156
|
+
this.setSubscription(channel as SubscriptionChannel, status);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Add user to a subscription group (list)
|
|
163
|
+
* @param groupId - The subscription group/list ID
|
|
164
|
+
* @param channel - Optional channel for the subscription (defaults to email)
|
|
165
|
+
*
|
|
166
|
+
* @example
|
|
167
|
+
* user.addToSubscriptionGroup('list_abc123');
|
|
168
|
+
* user.addToSubscriptionGroup('list_abc123', 'sms');
|
|
169
|
+
*/
|
|
170
|
+
addToSubscriptionGroup(groupId: string, channel: SubscriptionChannel = 'email'): void {
|
|
171
|
+
this.updateGroup(groupId, channel, 'subscribe');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Remove user from a subscription group (list)
|
|
176
|
+
* @param groupId - The subscription group/list ID
|
|
177
|
+
* @param channel - Optional channel for the subscription (defaults to email)
|
|
178
|
+
*
|
|
179
|
+
* @example
|
|
180
|
+
* user.removeFromSubscriptionGroup('list_abc123');
|
|
181
|
+
* user.removeFromSubscriptionGroup('list_abc123', 'sms');
|
|
182
|
+
*/
|
|
183
|
+
removeFromSubscriptionGroup(groupId: string, channel: SubscriptionChannel = 'email'): void {
|
|
184
|
+
this.updateGroup(groupId, channel, 'unsubscribe');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private updateGroup(groupId: string, channel: SubscriptionChannel, action: 'subscribe' | 'unsubscribe'): void {
|
|
188
|
+
if (typeof groupId !== 'string' || !groupId) {
|
|
189
|
+
this.logger.error(`Cannot ${action} subscription group: groupId is required`);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (!SUBSCRIPTION_CHANNELS.includes(channel)) {
|
|
193
|
+
this.logger.error(
|
|
194
|
+
`Invalid channel: ${channel}. Must be one of: ${SUBSCRIPTION_CHANNELS.join(', ')}`,
|
|
195
|
+
);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
this.logger.debug(`${action} subscription group ${groupId} for channel ${channel}`);
|
|
200
|
+
const payload = { groupId, channel, action, ...this.ids() };
|
|
201
|
+
this.runWrite(`Subscription group ${groupId} ${action}`, { kind: 'group', payload });
|
|
202
|
+
}
|
|
203
|
+
}
|