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/dist/sync.d.ts ADDED
@@ -0,0 +1,53 @@
1
+ import { EventQueueDB } from './db';
2
+ import { TrackingEvent } from './types';
3
+ export declare class SyncService {
4
+ private db;
5
+ private apiEndpoint;
6
+ private getJwtToken;
7
+ private onRefreshToken?;
8
+ private batchIntervalMs;
9
+ private maxRetryCount;
10
+ private onSyncStatusChange?;
11
+ private onEventSynced?;
12
+ private onDeadLetter?;
13
+ private debug;
14
+ private periodicTimer;
15
+ private isSyncing;
16
+ private backoffIndex;
17
+ private backoffDelays;
18
+ private backoffTimeout;
19
+ constructor(db: EventQueueDB, config: {
20
+ apiEndpoint: string;
21
+ getJwtToken: () => Promise<string> | string;
22
+ onRefreshToken?: () => Promise<boolean | string>;
23
+ batchIntervalMs?: number;
24
+ maxRetryCount?: number;
25
+ onSyncStatusChange?: (status: 'idle' | 'syncing' | 'error') => void;
26
+ onEventSynced?: (eventIds: string[]) => void;
27
+ onDeadLetter?: (event: TrackingEvent) => void;
28
+ debug?: boolean;
29
+ });
30
+ private isBrowser;
31
+ /**
32
+ * Start periodic batch syncing and register connectivity listeners.
33
+ */
34
+ start(): void;
35
+ /**
36
+ * Stop periodic syncing and clean up event listeners.
37
+ */
38
+ stop(): void;
39
+ private handleOnline;
40
+ private handleOffline;
41
+ private handleVisibilityChange;
42
+ private handleBeforeUnload;
43
+ /**
44
+ * Trigger the sync engine manually.
45
+ */
46
+ syncNow(force?: boolean): Promise<void>;
47
+ private runSyncProcess;
48
+ private sendBatchWithTimeout;
49
+ private handleResponse;
50
+ private handlePermanentFailure;
51
+ private handleTemporaryFailure;
52
+ private scheduleBackoff;
53
+ }
package/dist/sync.js ADDED
@@ -0,0 +1,336 @@
1
+ export class SyncService {
2
+ db;
3
+ apiEndpoint;
4
+ getJwtToken;
5
+ onRefreshToken;
6
+ batchIntervalMs;
7
+ maxRetryCount;
8
+ onSyncStatusChange;
9
+ onEventSynced;
10
+ onDeadLetter;
11
+ debug;
12
+ periodicTimer = null;
13
+ isSyncing = false;
14
+ backoffIndex = 0;
15
+ backoffDelays = [30000, 60000, 120000, 300000, 600000]; // 30s, 60s, 2m, 5m, 10m
16
+ backoffTimeout = null;
17
+ constructor(db, config) {
18
+ this.db = db;
19
+ this.apiEndpoint = config.apiEndpoint;
20
+ this.getJwtToken = config.getJwtToken;
21
+ this.onRefreshToken = config.onRefreshToken;
22
+ this.batchIntervalMs = config.batchIntervalMs || 30000;
23
+ this.maxRetryCount = config.maxRetryCount || 5;
24
+ this.onSyncStatusChange = config.onSyncStatusChange;
25
+ this.onEventSynced = config.onEventSynced;
26
+ this.onDeadLetter = config.onDeadLetter;
27
+ this.debug = config.debug || false;
28
+ }
29
+ isBrowser() {
30
+ return typeof window !== 'undefined';
31
+ }
32
+ /**
33
+ * Start periodic batch syncing and register connectivity listeners.
34
+ */
35
+ start() {
36
+ if (!this.isBrowser())
37
+ return;
38
+ this.stop();
39
+ // Reset syncing state for events that got stuck due to a previous crash
40
+ this.db.resetSyncingToPending()
41
+ .then(() => this.syncNow())
42
+ .catch((err) => {
43
+ if (this.debug)
44
+ console.error('[Tracking Sync] Failed to reset syncing state:', err);
45
+ });
46
+ // Schedule regular syncs
47
+ this.periodicTimer = setInterval(() => {
48
+ this.syncNow();
49
+ }, this.batchIntervalMs);
50
+ // Online/Offline listeners
51
+ window.addEventListener('online', this.handleOnline);
52
+ window.addEventListener('offline', this.handleOffline);
53
+ // Document Visibility / Unload triggers
54
+ window.addEventListener('visibilitychange', this.handleVisibilityChange);
55
+ window.addEventListener('beforeunload', this.handleBeforeUnload);
56
+ if (this.debug)
57
+ console.log('[Tracking Sync] Service started');
58
+ }
59
+ /**
60
+ * Stop periodic syncing and clean up event listeners.
61
+ */
62
+ stop() {
63
+ if (this.periodicTimer) {
64
+ clearInterval(this.periodicTimer);
65
+ this.periodicTimer = null;
66
+ }
67
+ if (this.backoffTimeout) {
68
+ clearTimeout(this.backoffTimeout);
69
+ this.backoffTimeout = null;
70
+ }
71
+ if (this.isBrowser()) {
72
+ window.removeEventListener('online', this.handleOnline);
73
+ window.removeEventListener('offline', this.handleOffline);
74
+ window.removeEventListener('visibilitychange', this.handleVisibilityChange);
75
+ window.removeEventListener('beforeunload', this.handleBeforeUnload);
76
+ }
77
+ if (this.debug)
78
+ console.log('[Tracking Sync] Service stopped');
79
+ }
80
+ handleOnline = () => {
81
+ if (this.debug)
82
+ console.log('[Tracking Sync] Browser is online. Triggering immediate sync.');
83
+ this.backoffIndex = 0; // Reset backoff when network restores
84
+ this.syncNow();
85
+ };
86
+ handleOffline = () => {
87
+ if (this.debug)
88
+ console.warn('[Tracking Sync] Browser is offline. Sync suspended.');
89
+ };
90
+ handleVisibilityChange = () => {
91
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
92
+ if (this.debug)
93
+ console.log('[Tracking Sync] App visibility hidden. Flushing queue.');
94
+ this.syncNow(true); // Sync immediately when user switches tabs
95
+ }
96
+ };
97
+ handleBeforeUnload = () => {
98
+ if (this.debug)
99
+ console.log('[Tracking Sync] Page unloading. Final queue flush.');
100
+ this.syncNow(true); // Synchronous send-beacon-like sync during page exit
101
+ };
102
+ /**
103
+ * Trigger the sync engine manually.
104
+ */
105
+ async syncNow(force = false) {
106
+ if (this.isSyncing)
107
+ return;
108
+ if (this.isBrowser() && !navigator.onLine && !force) {
109
+ if (this.debug)
110
+ console.log('[Tracking Sync] Offline. Skipping sync.');
111
+ return;
112
+ }
113
+ this.isSyncing = true;
114
+ this.onSyncStatusChange?.('syncing');
115
+ try {
116
+ await this.runSyncProcess();
117
+ this.onSyncStatusChange?.('idle');
118
+ }
119
+ catch (error) {
120
+ if (this.debug)
121
+ console.error('[Tracking Sync] Sync error occurred:', error);
122
+ this.onSyncStatusChange?.('error');
123
+ this.scheduleBackoff();
124
+ }
125
+ finally {
126
+ this.isSyncing = false;
127
+ }
128
+ }
129
+ async runSyncProcess() {
130
+ // 1. Fetch pending events (up to 50 at a time)
131
+ const events = await this.db.getPendingEvents(50);
132
+ if (events.length === 0) {
133
+ return;
134
+ }
135
+ if (this.debug)
136
+ console.log(`[Tracking Sync] Syncing batch of ${events.length} events...`);
137
+ // 2. Mark events as syncing
138
+ const eventIds = events.map(e => e.event_id);
139
+ await this.db.updateStatus(eventIds, 'syncing');
140
+ let jwtToken = '';
141
+ try {
142
+ jwtToken = (await this.getJwtToken()) || '';
143
+ }
144
+ catch (err) {
145
+ if (this.debug)
146
+ console.warn('[Tracking Sync] Failed to retrieve JWT token, proceeding with empty token:', err);
147
+ }
148
+ // 3. Make HTTP request with 15s timeout
149
+ try {
150
+ const response = await this.sendBatchWithTimeout(events, jwtToken, 15000);
151
+ await this.handleResponse(response, events, jwtToken);
152
+ }
153
+ catch (error) {
154
+ // Revert status to pending and let retry handlers manage
155
+ await this.db.updateStatus(eventIds, 'pending');
156
+ throw error;
157
+ }
158
+ }
159
+ async sendBatchWithTimeout(events, jwtToken, timeoutMs) {
160
+ const controller = new AbortController();
161
+ const id = setTimeout(() => controller.abort(), timeoutMs);
162
+ const body = {
163
+ user_id: events[0].user_id,
164
+ app_id: events[0].app_id,
165
+ app_version: '1.0.0',
166
+ section_id: events.find(e => e.payload && e.payload.section_id)?.payload.section_id || '',
167
+ events: events.map(e => {
168
+ // Extract event_name from payload, spread remaining fields flat (no double-spread)
169
+ // session_id is intentionally excluded from the API payload (local debug only)
170
+ const { event_name, ...payloadRest } = e.payload;
171
+ return {
172
+ event_id: e.event_id,
173
+ event_name: e.event_name,
174
+ timestamp: e.timestamp,
175
+ client_timestamp: e.client_timestamp,
176
+ ...payloadRest // flat spread: word_id, score, is_correct, etc.
177
+ };
178
+ })
179
+ };
180
+ try {
181
+ const response = await fetch(this.apiEndpoint, {
182
+ method: 'POST',
183
+ headers: {
184
+ 'Content-Type': 'application/json',
185
+ 'Authorization': `Bearer ${jwtToken}`,
186
+ 'X-App-Version': '1.0.0'
187
+ },
188
+ body: JSON.stringify(body),
189
+ signal: controller.signal
190
+ });
191
+ clearTimeout(id);
192
+ return response;
193
+ }
194
+ catch (err) {
195
+ clearTimeout(id);
196
+ throw err;
197
+ }
198
+ }
199
+ async handleResponse(response, events, currentToken, isRetry = false) {
200
+ const eventIds = events.map(e => e.event_id);
201
+ // -- Status 200 OK: Batch Sync Succeeded (Handle Partial successes) --
202
+ if (response.ok) {
203
+ const result = await response.json();
204
+ this.backoffIndex = 0; // Reset backoff upon successful communication
205
+ if (result.rejected_details && result.rejected_details.length > 0) {
206
+ const rejectedMap = new Map();
207
+ result.rejected_details.forEach(item => {
208
+ rejectedMap.set(item.event_id, item.reason);
209
+ });
210
+ const acceptedIds = [];
211
+ const rejectedIds = [];
212
+ events.forEach(event => {
213
+ if (rejectedMap.has(event.event_id)) {
214
+ rejectedIds.push(event.event_id);
215
+ // Fire callback
216
+ this.handlePermanentFailure(event, rejectedMap.get(event.event_id) || 'REJECTED_BY_SERVER');
217
+ }
218
+ else {
219
+ acceptedIds.push(event.event_id);
220
+ }
221
+ });
222
+ // Delete successful/duplicate ones, route rejected ones to failed/dead_letter
223
+ await this.db.deleteDone(acceptedIds);
224
+ this.onEventSynced?.(acceptedIds);
225
+ }
226
+ else {
227
+ // Complete success: delete all from local queue
228
+ await this.db.deleteDone(eventIds);
229
+ this.onEventSynced?.(eventIds);
230
+ }
231
+ if (this.debug)
232
+ console.log(`[Tracking Sync] Sync success: ${result.accepted || events.length} accepted.`);
233
+ // Keep flushing queue if there is more data pending
234
+ const moreEvents = await this.db.getPendingEvents(1);
235
+ if (moreEvents.length > 0) {
236
+ setTimeout(() => this.syncNow(), 100);
237
+ }
238
+ return;
239
+ }
240
+ const statusCode = response.status;
241
+ if (this.debug) {
242
+ console.warn(`[Tracking Sync] Server responded with error status: ${statusCode}`);
243
+ try {
244
+ const errBody = await response.clone().json();
245
+ console.error('[Tracking Sync] Server error details:', errBody);
246
+ }
247
+ catch (e) {
248
+ try {
249
+ const errText = await response.clone().text();
250
+ console.error('[Tracking Sync] Server error text:', errText);
251
+ }
252
+ catch (e2) { }
253
+ }
254
+ }
255
+ // -- Status 401: Unauthorized -> Refresh Token Flow --
256
+ if (statusCode === 401 && !isRetry && this.onRefreshToken) {
257
+ if (this.debug)
258
+ console.log('[Tracking Sync] Token expired (401). Initiating refresh token...');
259
+ try {
260
+ const refreshResult = await this.onRefreshToken();
261
+ if (refreshResult) {
262
+ const newToken = typeof refreshResult === 'string' ? refreshResult : await this.getJwtToken();
263
+ if (this.debug)
264
+ console.log('[Tracking Sync] Token refreshed. Retrying sync once.');
265
+ const retryResponse = await this.sendBatchWithTimeout(events, newToken, 15000);
266
+ await this.handleResponse(retryResponse, events, newToken, true);
267
+ return;
268
+ }
269
+ }
270
+ catch (err) {
271
+ if (this.debug)
272
+ console.error('[Tracking Sync] Token refresh crashed:', err);
273
+ }
274
+ // If refresh failed, revert to pending and request re-login
275
+ await this.db.updateStatus(eventIds, 'pending');
276
+ throw new Error('Authentication expired. Re-login is required.');
277
+ }
278
+ // -- Status 400 / 403 / 422: Permanent Failures -> dead_letter --
279
+ if (statusCode === 400 || statusCode === 403 || statusCode === 422) {
280
+ const reason = statusCode === 422 ? 'HMAC_VERIFICATION_FAILED' : `PERMANENT_HTTP_ERROR_${statusCode}`;
281
+ for (const event of events) {
282
+ await this.handlePermanentFailure(event, reason);
283
+ }
284
+ return;
285
+ }
286
+ // -- Status 429 / 5xx: Temporary Failures -> backoff retry --
287
+ if (statusCode === 429 || statusCode >= 500) {
288
+ await this.handleTemporaryFailure(events, `Temporary HTTP Error ${statusCode}`);
289
+ return;
290
+ }
291
+ // fallback for any other codes
292
+ await this.handleTemporaryFailure(events, `HTTP Error ${statusCode}`);
293
+ }
294
+ async handlePermanentFailure(event, reason) {
295
+ if (this.debug)
296
+ console.warn(`[Tracking Sync] Event ${event.event_id} failed permanently: ${reason}`);
297
+ // Set status to dead_letter directly to prevent retries
298
+ await this.db.updateStatus([event.event_id], 'dead_letter', reason);
299
+ // Call callback for external alerting
300
+ this.onDeadLetter?.({
301
+ ...event,
302
+ status: 'dead_letter',
303
+ error_message: reason
304
+ });
305
+ }
306
+ async handleTemporaryFailure(events, reason) {
307
+ const eventIds = events.map(e => e.event_id);
308
+ if (this.debug)
309
+ console.warn(`[Tracking Sync] Batch failed temporarily: ${reason}`);
310
+ for (const event of events) {
311
+ if (event.retry_count >= this.maxRetryCount) {
312
+ // Move to dead letter queue if exceeded max retries
313
+ await this.handlePermanentFailure(event, `EXCEEDED_MAX_RETRIES (${this.maxRetryCount})`);
314
+ }
315
+ else {
316
+ // Return event to pending status so it can be retried in the next batch
317
+ await this.db.updateStatus([event.event_id], 'pending');
318
+ }
319
+ }
320
+ throw new Error(`Sync temporarily suspended: ${reason}`);
321
+ }
322
+ scheduleBackoff() {
323
+ if (this.backoffTimeout)
324
+ clearTimeout(this.backoffTimeout);
325
+ const delay = this.backoffDelays[this.backoffIndex];
326
+ if (this.debug)
327
+ console.log(`[Tracking Sync] Scheduling next retry in ${delay / 1000} seconds (backoff level ${this.backoffIndex + 1})`);
328
+ this.backoffTimeout = setTimeout(() => {
329
+ this.syncNow();
330
+ }, delay);
331
+ // Advance backoff, capping at the maximum backoff duration (10 minutes)
332
+ if (this.backoffIndex < this.backoffDelays.length - 1) {
333
+ this.backoffIndex++;
334
+ }
335
+ }
336
+ }
package/dist/test.txt ADDED
@@ -0,0 +1 @@
1
+ test
@@ -0,0 +1,77 @@
1
+ export type EventStatus = 'pending' | 'syncing' | 'done' | 'failed' | 'dead_letter';
2
+ export interface LearningEventPayload {
3
+ event_name: string;
4
+ [key: string]: any;
5
+ }
6
+ export interface IframeTrackingMessage {
7
+ type: 'LEARNING_EVENT';
8
+ event_id?: string;
9
+ payload: LearningEventPayload;
10
+ timestamp: number;
11
+ signature?: string;
12
+ nonce?: string;
13
+ }
14
+ export interface TrackingEvent {
15
+ event_id: string;
16
+ user_id: string;
17
+ session_id?: string;
18
+ app_id: string;
19
+ event_name: string;
20
+ payload: LearningEventPayload;
21
+ signature?: string;
22
+ timestamp: number;
23
+ client_timestamp: number;
24
+ status: EventStatus;
25
+ retry_count: number;
26
+ error_message?: string;
27
+ created_at: number;
28
+ }
29
+ export interface IframeHandshakeInit {
30
+ type: 'INIT_HANDSHAKE';
31
+ nonce?: string;
32
+ hmacSecret?: string;
33
+ channel?: string;
34
+ }
35
+ export interface IframeHandshakeAck {
36
+ type: 'HANDSHAKE_ACK';
37
+ nonce?: string;
38
+ }
39
+ export interface BatchSyncResponse {
40
+ status: 'ok' | 'error';
41
+ accepted: number;
42
+ duplicate: number;
43
+ rejected: number;
44
+ rejected_details?: Array<{
45
+ event_id: string;
46
+ reason: string;
47
+ }>;
48
+ batch_id?: string;
49
+ message?: string;
50
+ }
51
+ export interface IframeHostTrackerConfig {
52
+ iframeUrl: string;
53
+ trustedOrigins: string[];
54
+ userId: string;
55
+ sessionId?: string;
56
+ appId: string;
57
+ appVersion?: string;
58
+ hmacSecret?: string;
59
+ apiEndpoint: string;
60
+ getJwtToken: () => Promise<string> | string;
61
+ onRefreshToken?: () => Promise<boolean | string>;
62
+ batchIntervalMs?: number;
63
+ maxQueueSize?: number;
64
+ maxRetryCount?: number;
65
+ onSyncStatusChange?: (status: 'idle' | 'syncing' | 'error') => void;
66
+ onEventReceived?: (event: TrackingEvent) => void;
67
+ onEventSynced?: (eventIds: string[]) => void;
68
+ onDeadLetter?: (event: TrackingEvent) => void;
69
+ /**
70
+ * Called when the game emits unit_restart or unit_start.
71
+ * The host can use this to update UI/debug state. Restart is treated as a new attempt
72
+ * by the backend through the unit_restart event itself, not by changing session_id.
73
+ */
74
+ onSessionRefreshNeeded?: (eventName: string) => void;
75
+ debug?: boolean;
76
+ sectionId?: string;
77
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "iframe-tracking-sdk",
3
+ "version": "1.0.0",
4
+ "description": "SDK for secure and resilient Iframe tracking progress, offline queues, and automatic sync with backoff.",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "files": [
9
+ "dist"
10
+ ],
11
+ "scripts": {
12
+ "build": "tsc"
13
+ },
14
+ "keywords": [
15
+ "iframe",
16
+ "tracking",
17
+ "nextjs",
18
+ "vite",
19
+ "indexeddb",
20
+ "security"
21
+ ],
22
+ "author": "Hai Nguyen",
23
+ "license": "MIT",
24
+ "peerDependencies": {
25
+ "react": "^18.0.0 || ^19.0.0"
26
+ },
27
+ "devDependencies": {
28
+ "@types/react": "^19.0.0",
29
+ "react": "^19.0.0",
30
+ "typescript": "^5.0.0"
31
+ }
32
+ }