iframe-tracking-sdk 1.0.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.
- package/README.md +530 -0
- package/dist/client.d.ts +38 -0
- package/dist/client.js +110 -0
- package/dist/db.d.ts +47 -0
- package/dist/db.js +290 -0
- package/dist/host.d.ts +49 -0
- package/dist/host.js +215 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +16 -0
- package/dist/processor.d.ts +84 -0
- package/dist/processor.js +179 -0
- package/dist/react-hook.d.ts +21 -0
- package/dist/react-hook.js +142 -0
- package/dist/security.d.ts +24 -0
- package/dist/security.js +191 -0
- package/dist/sync.d.ts +53 -0
- package/dist/sync.js +336 -0
- package/dist/test.txt +1 -0
- package/dist/types.d.ts +77 -0
- package/dist/types.js +1 -0
- package/package.json +32 -0
package/dist/db.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { TrackingEvent, EventStatus } from './types';
|
|
2
|
+
export declare class EventQueueDB {
|
|
3
|
+
private dbName;
|
|
4
|
+
private storeName;
|
|
5
|
+
private dbVersion;
|
|
6
|
+
private maxQueueSize;
|
|
7
|
+
private debug;
|
|
8
|
+
constructor(maxQueueSize?: number, debug?: boolean);
|
|
9
|
+
private isBrowser;
|
|
10
|
+
private getDB;
|
|
11
|
+
/**
|
|
12
|
+
* Enqueue an event. Performs FIFO cleaning if the size exceeds maxQueueSize.
|
|
13
|
+
*/
|
|
14
|
+
enqueue(event: TrackingEvent): Promise<void>;
|
|
15
|
+
/**
|
|
16
|
+
* Enforce FIFO queue limit. If database has more than maxQueueSize items, delete oldest 50.
|
|
17
|
+
*/
|
|
18
|
+
private enforceLimit;
|
|
19
|
+
/**
|
|
20
|
+
* Fetch pending events up to limit.
|
|
21
|
+
*/
|
|
22
|
+
getPendingEvents(limit?: number): Promise<TrackingEvent[]>;
|
|
23
|
+
/**
|
|
24
|
+
* Update status of multiple events.
|
|
25
|
+
*/
|
|
26
|
+
updateStatus(eventIds: string[], status: EventStatus, errorMessage?: string): Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* Delete successfully synced events.
|
|
29
|
+
*/
|
|
30
|
+
deleteDone(eventIds: string[]): Promise<void>;
|
|
31
|
+
/**
|
|
32
|
+
* Reset events stuck in 'syncing' status back to 'pending'.
|
|
33
|
+
*/
|
|
34
|
+
resetSyncingToPending(): Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* Delete old dead_letter events (older than daysLimit).
|
|
37
|
+
*/
|
|
38
|
+
cleanupOldDeadLetters(daysLimit?: number): Promise<number>;
|
|
39
|
+
/**
|
|
40
|
+
* Get all events in the store for UI rendering/debugging.
|
|
41
|
+
*/
|
|
42
|
+
getAllEvents(limit?: number): Promise<TrackingEvent[]>;
|
|
43
|
+
/**
|
|
44
|
+
* Clear all events in the store.
|
|
45
|
+
*/
|
|
46
|
+
clear(): Promise<void>;
|
|
47
|
+
}
|
package/dist/db.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
export class EventQueueDB {
|
|
2
|
+
dbName = 'IframeTrackingDB';
|
|
3
|
+
storeName = 'events';
|
|
4
|
+
dbVersion = 1;
|
|
5
|
+
maxQueueSize;
|
|
6
|
+
debug;
|
|
7
|
+
constructor(maxQueueSize = 500, debug = false) {
|
|
8
|
+
this.maxQueueSize = maxQueueSize;
|
|
9
|
+
this.debug = debug;
|
|
10
|
+
}
|
|
11
|
+
isBrowser() {
|
|
12
|
+
return typeof window !== 'undefined';
|
|
13
|
+
}
|
|
14
|
+
getDB() {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
if (!this.isBrowser()) {
|
|
17
|
+
reject(new Error('IndexedDB is only available in the browser'));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const request = indexedDB.open(this.dbName, this.dbVersion);
|
|
21
|
+
request.onerror = () => {
|
|
22
|
+
if (this.debug)
|
|
23
|
+
console.error('[Tracking DB] Failed to open IndexedDB');
|
|
24
|
+
reject(request.error);
|
|
25
|
+
};
|
|
26
|
+
request.onsuccess = () => {
|
|
27
|
+
resolve(request.result);
|
|
28
|
+
};
|
|
29
|
+
request.onupgradeneeded = (event) => {
|
|
30
|
+
const db = request.result;
|
|
31
|
+
if (!db.objectStoreNames.contains(this.storeName)) {
|
|
32
|
+
const store = db.createObjectStore(this.storeName, { keyPath: 'event_id' });
|
|
33
|
+
store.createIndex('status', 'status', { unique: false });
|
|
34
|
+
store.createIndex('created_at', 'created_at', { unique: false });
|
|
35
|
+
if (this.debug)
|
|
36
|
+
console.log('[Tracking DB] Object store created');
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Enqueue an event. Performs FIFO cleaning if the size exceeds maxQueueSize.
|
|
43
|
+
*/
|
|
44
|
+
async enqueue(event) {
|
|
45
|
+
const db = await this.getDB();
|
|
46
|
+
// First, check the size and enforce FIFO limit
|
|
47
|
+
await this.enforceLimit(db);
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const transaction = db.transaction(this.storeName, 'readwrite');
|
|
50
|
+
const store = transaction.objectStore(this.storeName);
|
|
51
|
+
const request = store.put(event);
|
|
52
|
+
request.onsuccess = () => {
|
|
53
|
+
if (this.debug)
|
|
54
|
+
console.log(`[Tracking DB] Enqueued event ${event.event_id} (${event.event_name})`);
|
|
55
|
+
resolve();
|
|
56
|
+
};
|
|
57
|
+
request.onerror = () => {
|
|
58
|
+
if (this.debug)
|
|
59
|
+
console.error('[Tracking DB] Enqueue failed', request.error);
|
|
60
|
+
reject(request.error);
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Enforce FIFO queue limit. If database has more than maxQueueSize items, delete oldest 50.
|
|
66
|
+
*/
|
|
67
|
+
async enforceLimit(db) {
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const transaction = db.transaction(this.storeName, 'readwrite');
|
|
70
|
+
const store = transaction.objectStore(this.storeName);
|
|
71
|
+
const countRequest = store.count();
|
|
72
|
+
countRequest.onsuccess = () => {
|
|
73
|
+
const count = countRequest.result;
|
|
74
|
+
if (count < this.maxQueueSize) {
|
|
75
|
+
resolve();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (this.debug)
|
|
79
|
+
console.warn(`[Tracking DB] Queue full (${count}/${this.maxQueueSize}). Removing oldest 50 events.`);
|
|
80
|
+
// Find and delete oldest 50 events using the created_at index
|
|
81
|
+
const createdAtIndex = store.index('created_at');
|
|
82
|
+
const cursorRequest = createdAtIndex.openCursor(null, 'next'); // oldest first
|
|
83
|
+
let deletedCount = 0;
|
|
84
|
+
cursorRequest.onsuccess = () => {
|
|
85
|
+
const cursor = cursorRequest.result;
|
|
86
|
+
if (cursor && deletedCount < 50) {
|
|
87
|
+
cursor.delete();
|
|
88
|
+
deletedCount++;
|
|
89
|
+
cursor.continue();
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
resolve();
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
cursorRequest.onerror = () => reject(cursorRequest.error);
|
|
96
|
+
};
|
|
97
|
+
countRequest.onerror = () => reject(countRequest.error);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Fetch pending events up to limit.
|
|
102
|
+
*/
|
|
103
|
+
async getPendingEvents(limit = 50) {
|
|
104
|
+
const db = await this.getDB();
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
const transaction = db.transaction(this.storeName, 'readonly');
|
|
107
|
+
const store = transaction.objectStore(this.storeName);
|
|
108
|
+
const statusIndex = store.index('status');
|
|
109
|
+
const events = [];
|
|
110
|
+
const request = statusIndex.openCursor(IDBKeyRange.only('pending'));
|
|
111
|
+
request.onsuccess = () => {
|
|
112
|
+
const cursor = request.result;
|
|
113
|
+
if (cursor && events.length < limit) {
|
|
114
|
+
events.push(cursor.value);
|
|
115
|
+
cursor.continue();
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
resolve(events);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
request.onerror = () => reject(request.error);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Update status of multiple events.
|
|
126
|
+
*/
|
|
127
|
+
async updateStatus(eventIds, status, errorMessage) {
|
|
128
|
+
if (eventIds.length === 0)
|
|
129
|
+
return;
|
|
130
|
+
const db = await this.getDB();
|
|
131
|
+
return new Promise((resolve, reject) => {
|
|
132
|
+
const transaction = db.transaction(this.storeName, 'readwrite');
|
|
133
|
+
const store = transaction.objectStore(this.storeName);
|
|
134
|
+
// Use real Promise.all to avoid manual counter race condition
|
|
135
|
+
const ops = eventIds.map(id => new Promise((res, rej) => {
|
|
136
|
+
const getReq = store.get(id);
|
|
137
|
+
getReq.onsuccess = () => {
|
|
138
|
+
const event = getReq.result;
|
|
139
|
+
if (!event) {
|
|
140
|
+
res();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
event.status = status;
|
|
144
|
+
if (status === 'failed') {
|
|
145
|
+
event.retry_count++;
|
|
146
|
+
}
|
|
147
|
+
if (errorMessage) {
|
|
148
|
+
event.error_message = errorMessage;
|
|
149
|
+
}
|
|
150
|
+
const putReq = store.put(event);
|
|
151
|
+
putReq.onsuccess = () => res();
|
|
152
|
+
putReq.onerror = () => rej(putReq.error);
|
|
153
|
+
};
|
|
154
|
+
getReq.onerror = () => rej(getReq.error);
|
|
155
|
+
}));
|
|
156
|
+
Promise.all(ops).then(() => resolve()).catch(reject);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Delete successfully synced events.
|
|
161
|
+
*/
|
|
162
|
+
async deleteDone(eventIds) {
|
|
163
|
+
if (eventIds.length === 0)
|
|
164
|
+
return;
|
|
165
|
+
const db = await this.getDB();
|
|
166
|
+
return new Promise((resolve, reject) => {
|
|
167
|
+
const transaction = db.transaction(this.storeName, 'readwrite');
|
|
168
|
+
const store = transaction.objectStore(this.storeName);
|
|
169
|
+
let completedCount = 0;
|
|
170
|
+
eventIds.forEach((id) => {
|
|
171
|
+
const request = store.delete(id);
|
|
172
|
+
request.onsuccess = () => {
|
|
173
|
+
completedCount++;
|
|
174
|
+
if (completedCount === eventIds.length) {
|
|
175
|
+
resolve();
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
request.onerror = () => reject(request.error);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Reset events stuck in 'syncing' status back to 'pending'.
|
|
184
|
+
*/
|
|
185
|
+
async resetSyncingToPending() {
|
|
186
|
+
const db = await this.getDB();
|
|
187
|
+
return new Promise((resolve, reject) => {
|
|
188
|
+
const transaction = db.transaction(this.storeName, 'readwrite');
|
|
189
|
+
const store = transaction.objectStore(this.storeName);
|
|
190
|
+
const statusIndex = store.index('status');
|
|
191
|
+
const request = statusIndex.openCursor(IDBKeyRange.only('syncing'));
|
|
192
|
+
const updates = [];
|
|
193
|
+
request.onsuccess = () => {
|
|
194
|
+
const cursor = request.result;
|
|
195
|
+
if (cursor) {
|
|
196
|
+
const event = cursor.value;
|
|
197
|
+
event.status = 'pending';
|
|
198
|
+
const updatePromise = new Promise((res, rej) => {
|
|
199
|
+
const putReq = store.put(event);
|
|
200
|
+
putReq.onsuccess = () => res();
|
|
201
|
+
putReq.onerror = () => rej(putReq.error);
|
|
202
|
+
});
|
|
203
|
+
updates.push(updatePromise);
|
|
204
|
+
cursor.continue();
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
Promise.all(updates).then(() => resolve()).catch(reject);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
request.onerror = () => reject(request.error);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Delete old dead_letter events (older than daysLimit).
|
|
215
|
+
*/
|
|
216
|
+
async cleanupOldDeadLetters(daysLimit = 7) {
|
|
217
|
+
const db = await this.getDB();
|
|
218
|
+
const cutOffTime = Date.now() - (daysLimit * 24 * 60 * 60 * 1000);
|
|
219
|
+
return new Promise((resolve, reject) => {
|
|
220
|
+
const transaction = db.transaction(this.storeName, 'readwrite');
|
|
221
|
+
const store = transaction.objectStore(this.storeName);
|
|
222
|
+
const statusIndex = store.index('status');
|
|
223
|
+
let deletedCount = 0;
|
|
224
|
+
const request = statusIndex.openCursor(IDBKeyRange.only('dead_letter'));
|
|
225
|
+
request.onsuccess = () => {
|
|
226
|
+
const cursor = request.result;
|
|
227
|
+
if (cursor) {
|
|
228
|
+
const event = cursor.value;
|
|
229
|
+
if (event.created_at < cutOffTime) {
|
|
230
|
+
cursor.delete();
|
|
231
|
+
deletedCount++;
|
|
232
|
+
}
|
|
233
|
+
cursor.continue();
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
if (this.debug && deletedCount > 0) {
|
|
237
|
+
console.log(`[Tracking DB] Cleaned up ${deletedCount} dead-letter events older than ${daysLimit} days.`);
|
|
238
|
+
}
|
|
239
|
+
resolve(deletedCount);
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
request.onerror = () => reject(request.error);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Get all events in the store for UI rendering/debugging.
|
|
247
|
+
*/
|
|
248
|
+
async getAllEvents(limit = 100) {
|
|
249
|
+
const db = await this.getDB();
|
|
250
|
+
return new Promise((resolve, reject) => {
|
|
251
|
+
const transaction = db.transaction(this.storeName, 'readonly');
|
|
252
|
+
const store = transaction.objectStore(this.storeName);
|
|
253
|
+
const createdAtIndex = store.index('created_at');
|
|
254
|
+
const events = [];
|
|
255
|
+
const request = createdAtIndex.openCursor(null, 'prev'); // newest first
|
|
256
|
+
request.onsuccess = () => {
|
|
257
|
+
const cursor = request.result;
|
|
258
|
+
if (cursor && events.length < limit) {
|
|
259
|
+
events.push(cursor.value);
|
|
260
|
+
cursor.continue();
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
resolve(events);
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
request.onerror = () => reject(request.error);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Clear all events in the store.
|
|
271
|
+
*/
|
|
272
|
+
async clear() {
|
|
273
|
+
const db = await this.getDB();
|
|
274
|
+
return new Promise((resolve, reject) => {
|
|
275
|
+
const transaction = db.transaction(this.storeName, 'readwrite');
|
|
276
|
+
const store = transaction.objectStore(this.storeName);
|
|
277
|
+
const request = store.clear();
|
|
278
|
+
request.onsuccess = () => {
|
|
279
|
+
if (this.debug)
|
|
280
|
+
console.log('[Tracking DB] Database queue cleared.');
|
|
281
|
+
resolve();
|
|
282
|
+
};
|
|
283
|
+
request.onerror = () => {
|
|
284
|
+
if (this.debug)
|
|
285
|
+
console.error('[Tracking DB] Clear failed', request.error);
|
|
286
|
+
reject(request.error);
|
|
287
|
+
};
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
package/dist/host.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { EventQueueDB } from './db';
|
|
2
|
+
import { IframeHostTrackerConfig } from './types';
|
|
3
|
+
export declare class IframeHostTracker {
|
|
4
|
+
private config;
|
|
5
|
+
private db;
|
|
6
|
+
private syncService;
|
|
7
|
+
private activeNonce;
|
|
8
|
+
private isHandshakeComplete;
|
|
9
|
+
private debug;
|
|
10
|
+
private immediateSyncEvents;
|
|
11
|
+
constructor(config: IframeHostTrackerConfig);
|
|
12
|
+
private isBrowser;
|
|
13
|
+
/**
|
|
14
|
+
* Initialize the tracking host. Registers event listeners and starts the sync engine.
|
|
15
|
+
*/
|
|
16
|
+
start(): void;
|
|
17
|
+
/**
|
|
18
|
+
* Stop the tracking host. Cleans up event listeners and stops the sync engine.
|
|
19
|
+
*/
|
|
20
|
+
stop(): void;
|
|
21
|
+
/**
|
|
22
|
+
* Force an immediate sync of any pending events.
|
|
23
|
+
*/
|
|
24
|
+
flush(): Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Update optional local credentials.
|
|
27
|
+
* sessionId is kept only for backward-compatible local debugging and is not sent to the API.
|
|
28
|
+
*/
|
|
29
|
+
updateCredentials(sessionId: string, hmacSecret: string): void;
|
|
30
|
+
/**
|
|
31
|
+
* Get the underlying database instance for advanced debugging/querying.
|
|
32
|
+
*/
|
|
33
|
+
getDB(): EventQueueDB;
|
|
34
|
+
/**
|
|
35
|
+
* Initiate a secure Handshake with the target iframe (Deprecated / Handshake disabled).
|
|
36
|
+
*/
|
|
37
|
+
initiateHandshake(iframeWindow: Window): string;
|
|
38
|
+
private getIframeOrigin;
|
|
39
|
+
private handleMessage;
|
|
40
|
+
private handleLearningEvent;
|
|
41
|
+
/**
|
|
42
|
+
* Filter out parameters used for envelope/signature routing to keep db/payload clean.
|
|
43
|
+
*/
|
|
44
|
+
private cleanPayload;
|
|
45
|
+
/**
|
|
46
|
+
* Clear all events in the local durable queue.
|
|
47
|
+
*/
|
|
48
|
+
clearQueue(): Promise<void>;
|
|
49
|
+
}
|
package/dist/host.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { EventQueueDB } from './db';
|
|
2
|
+
import { SyncService } from './sync';
|
|
3
|
+
export class IframeHostTracker {
|
|
4
|
+
config;
|
|
5
|
+
db;
|
|
6
|
+
syncService;
|
|
7
|
+
activeNonce = null;
|
|
8
|
+
isHandshakeComplete = true;
|
|
9
|
+
debug;
|
|
10
|
+
// Events that trigger immediate batch upload to Backend
|
|
11
|
+
immediateSyncEvents = new Set([
|
|
12
|
+
'session_end',
|
|
13
|
+
'course_finish',
|
|
14
|
+
'unit_finish',
|
|
15
|
+
'unit_submit',
|
|
16
|
+
'unit_restart'
|
|
17
|
+
]);
|
|
18
|
+
constructor(config) {
|
|
19
|
+
this.config = config;
|
|
20
|
+
this.debug = config.debug || false;
|
|
21
|
+
this.db = new EventQueueDB(config.maxQueueSize || 500, this.debug);
|
|
22
|
+
this.syncService = new SyncService(this.db, {
|
|
23
|
+
apiEndpoint: config.apiEndpoint,
|
|
24
|
+
getJwtToken: config.getJwtToken,
|
|
25
|
+
onRefreshToken: config.onRefreshToken,
|
|
26
|
+
batchIntervalMs: config.batchIntervalMs,
|
|
27
|
+
maxRetryCount: config.maxRetryCount,
|
|
28
|
+
onSyncStatusChange: config.onSyncStatusChange,
|
|
29
|
+
onEventSynced: config.onEventSynced,
|
|
30
|
+
onDeadLetter: config.onDeadLetter,
|
|
31
|
+
debug: this.debug
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
isBrowser() {
|
|
35
|
+
return typeof window !== 'undefined';
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Initialize the tracking host. Registers event listeners and starts the sync engine.
|
|
39
|
+
*/
|
|
40
|
+
start() {
|
|
41
|
+
if (!this.isBrowser())
|
|
42
|
+
return;
|
|
43
|
+
// Start postMessage listener
|
|
44
|
+
window.addEventListener('message', this.handleMessage);
|
|
45
|
+
// Start sync service (periodic batches)
|
|
46
|
+
this.syncService.start();
|
|
47
|
+
// Clean up dead-letters older than 7 days once on startup
|
|
48
|
+
this.db.cleanupOldDeadLetters(7).catch((err) => {
|
|
49
|
+
if (this.debug)
|
|
50
|
+
console.error('[Tracking Host] Dead-letter cleanup error:', err);
|
|
51
|
+
});
|
|
52
|
+
if (this.debug)
|
|
53
|
+
console.log('[Tracking Host] Initialized and listening for messages.');
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Stop the tracking host. Cleans up event listeners and stops the sync engine.
|
|
57
|
+
*/
|
|
58
|
+
stop() {
|
|
59
|
+
if (!this.isBrowser())
|
|
60
|
+
return;
|
|
61
|
+
window.removeEventListener('message', this.handleMessage);
|
|
62
|
+
this.syncService.stop();
|
|
63
|
+
this.activeNonce = null;
|
|
64
|
+
this.isHandshakeComplete = false;
|
|
65
|
+
if (this.debug)
|
|
66
|
+
console.log('[Tracking Host] Stopped.');
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Force an immediate sync of any pending events.
|
|
70
|
+
*/
|
|
71
|
+
async flush() {
|
|
72
|
+
await this.syncService.syncNow(true);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Update optional local credentials.
|
|
76
|
+
* sessionId is kept only for backward-compatible local debugging and is not sent to the API.
|
|
77
|
+
*/
|
|
78
|
+
updateCredentials(sessionId, hmacSecret) {
|
|
79
|
+
this.config.sessionId = sessionId;
|
|
80
|
+
this.config.hmacSecret = hmacSecret;
|
|
81
|
+
if (this.debug) {
|
|
82
|
+
console.log(`[Tracking Host] Credentials updated → sessionId: ${sessionId}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Get the underlying database instance for advanced debugging/querying.
|
|
87
|
+
*/
|
|
88
|
+
getDB() {
|
|
89
|
+
return this.db;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Initiate a secure Handshake with the target iframe (Deprecated / Handshake disabled).
|
|
93
|
+
*/
|
|
94
|
+
initiateHandshake(iframeWindow) {
|
|
95
|
+
this.isHandshakeComplete = true;
|
|
96
|
+
if (this.debug) {
|
|
97
|
+
console.log('[Tracking Host] Handshake is disabled. Client ready.');
|
|
98
|
+
}
|
|
99
|
+
return '';
|
|
100
|
+
}
|
|
101
|
+
getIframeOrigin() {
|
|
102
|
+
try {
|
|
103
|
+
const url = new URL(this.config.iframeUrl);
|
|
104
|
+
return url.origin;
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
// Fallback if URL parsing fails
|
|
108
|
+
if (this.config.trustedOrigins.length > 0) {
|
|
109
|
+
return this.config.trustedOrigins[0];
|
|
110
|
+
}
|
|
111
|
+
return '*';
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
handleMessage = async (event) => {
|
|
115
|
+
// 1. Strict Origin Validation (Bypassed / Warning only)
|
|
116
|
+
if (this.debug && !this.config.trustedOrigins.includes(event.origin)) {
|
|
117
|
+
console.warn(`[Tracking Host] Received postMessage from untrusted origin: ${event.origin}. Proceeding anyway.`);
|
|
118
|
+
}
|
|
119
|
+
// 2. Safe Parsing & XSS Prevention
|
|
120
|
+
let rawData;
|
|
121
|
+
try {
|
|
122
|
+
rawData = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
if (this.debug)
|
|
126
|
+
console.warn('[Security] Failed to parse message payload. Dropping.', event.data);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (!rawData || typeof rawData !== 'object')
|
|
130
|
+
return;
|
|
131
|
+
// 3. Handle Learning Event
|
|
132
|
+
if (rawData.type === 'LEARNING_EVENT') {
|
|
133
|
+
await this.handleLearningEvent(rawData);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
async handleLearningEvent(message) {
|
|
137
|
+
// Schema Validation
|
|
138
|
+
const payload = message.payload;
|
|
139
|
+
if (!payload || typeof payload !== 'object' || !payload.event_name) {
|
|
140
|
+
if (this.debug)
|
|
141
|
+
console.error('[Tracking Host] Malformed event payload. Missing event_name.', message);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
// Generate Event ID theo chuẩn: evt_[timestamp_ms]_[random_string_8_chars]
|
|
145
|
+
const randomChars = Math.random().toString(36).substring(2, 10);
|
|
146
|
+
const eventId = `evt_${Date.now()}_${randomChars}`;
|
|
147
|
+
const enrichedPayload = {
|
|
148
|
+
...this.cleanPayload(payload),
|
|
149
|
+
};
|
|
150
|
+
if (this.config.sectionId) {
|
|
151
|
+
enrichedPayload.section_id = this.config.sectionId;
|
|
152
|
+
}
|
|
153
|
+
// Notify the host app when a restart-like event is detected.
|
|
154
|
+
// The backend treats unit_restart as a new attempt without requiring session_id.
|
|
155
|
+
const isSessionResetEvent = payload.event_name === 'unit_restart' ||
|
|
156
|
+
(payload.event_name === 'unit_start' && (payload.game === 'find_the_words' || payload.question_type === 'find_the_words'));
|
|
157
|
+
if (isSessionResetEvent && this.config.onSessionRefreshNeeded) {
|
|
158
|
+
if (this.debug) {
|
|
159
|
+
console.log(`[Tracking Host] Restart-like event '${payload.event_name}' detected. Notifying host app.`);
|
|
160
|
+
}
|
|
161
|
+
this.config.onSessionRefreshNeeded(payload.event_name);
|
|
162
|
+
}
|
|
163
|
+
// Data Enrichment
|
|
164
|
+
const enrichedEvent = {
|
|
165
|
+
event_id: eventId,
|
|
166
|
+
user_id: this.config.userId,
|
|
167
|
+
app_id: this.config.appId,
|
|
168
|
+
event_name: payload.event_name,
|
|
169
|
+
payload: enrichedPayload,
|
|
170
|
+
signature: message.signature || '',
|
|
171
|
+
timestamp: message.timestamp || Date.now(),
|
|
172
|
+
client_timestamp: Date.now(),
|
|
173
|
+
status: 'pending',
|
|
174
|
+
retry_count: 0,
|
|
175
|
+
created_at: Date.now()
|
|
176
|
+
};
|
|
177
|
+
if (this.config.sessionId) {
|
|
178
|
+
enrichedEvent.session_id = this.config.sessionId;
|
|
179
|
+
}
|
|
180
|
+
// 5. Store Event in Durable Queue (IndexedDB)
|
|
181
|
+
try {
|
|
182
|
+
await this.db.enqueue(enrichedEvent);
|
|
183
|
+
// Fire onEventReceived callback
|
|
184
|
+
this.config.onEventReceived?.(enrichedEvent);
|
|
185
|
+
// 6. Trigger Sync Immediately for Critical Events
|
|
186
|
+
const shouldSyncImmediately = this.immediateSyncEvents.has(payload.event_name);
|
|
187
|
+
if (shouldSyncImmediately) {
|
|
188
|
+
if (this.debug)
|
|
189
|
+
console.log(`[Tracking Host] Critical event '${payload.event_name}' triggered immediate sync.`);
|
|
190
|
+
// Run sync on next event loop tick so db transaction can settle
|
|
191
|
+
setTimeout(() => this.syncService.syncNow(), 0);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
if (this.debug)
|
|
196
|
+
console.error('[Tracking Host] Failed to save tracking event to queue:', err);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Filter out parameters used for envelope/signature routing to keep db/payload clean.
|
|
201
|
+
*/
|
|
202
|
+
cleanPayload(payload) {
|
|
203
|
+
const { event_name, event_id, ...rest } = payload;
|
|
204
|
+
return {
|
|
205
|
+
event_name,
|
|
206
|
+
...rest
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Clear all events in the local durable queue.
|
|
211
|
+
*/
|
|
212
|
+
async clearQueue() {
|
|
213
|
+
await this.db.clear();
|
|
214
|
+
}
|
|
215
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from './types';
|
|
2
|
+
export { EventQueueDB } from './db';
|
|
3
|
+
export { SyncService } from './sync';
|
|
4
|
+
export { IframeHostTracker } from './host';
|
|
5
|
+
export { IframeClientTracker } from './client';
|
|
6
|
+
export { useIframeHostTracking } from './react-hook';
|
|
7
|
+
export type { UseIframeHostTrackingResult } from './react-hook';
|
|
8
|
+
export { generateNonce, sortObjectKeys, buildCanonicalString, signHmacSha256, verifyHmacSha256 } from './security';
|
|
9
|
+
export { EventProcessor } from './processor';
|
|
10
|
+
export type { EventCategory, BatchRequestBody } from './processor';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Export Types
|
|
2
|
+
export * from './types';
|
|
3
|
+
// Export Database Queue
|
|
4
|
+
export { EventQueueDB } from './db';
|
|
5
|
+
// Export Sync Engine
|
|
6
|
+
export { SyncService } from './sync';
|
|
7
|
+
// Export Host tracker
|
|
8
|
+
export { IframeHostTracker } from './host';
|
|
9
|
+
// Export Client tracker
|
|
10
|
+
export { IframeClientTracker } from './client';
|
|
11
|
+
// Export React Hook for Next.js
|
|
12
|
+
export { useIframeHostTracking } from './react-hook';
|
|
13
|
+
// Export Cryptographic Security Utilities
|
|
14
|
+
export { generateNonce, sortObjectKeys, buildCanonicalString, signHmacSha256, verifyHmacSha256 } from './security';
|
|
15
|
+
// Export Event Processor (browser-independent, usable in Node.js/NestJS backends)
|
|
16
|
+
export { EventProcessor } from './processor';
|