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 ADDED
@@ -0,0 +1,530 @@
1
+ # `@bkt/iframe-tracking-sdk`
2
+
3
+ **`@bkt/iframe-tracking-sdk`** là thư viện npm chuyên dụng giúp chuẩn hóa và tự động hóa toàn bộ quy trình thu thập sự kiện học tập (tracking) từ **Iframe (Vite App / HTML)** hoặc **Mobile WebView (Flutter)** lên **Backend API**.
4
+
5
+ Thư viện cung cấp hàng chờ bền vững bằng **IndexedDB**, bộ đồng bộ tự phục hồi **Exponential Backoff**, và class **`EventProcessor`** có thể tái sử dụng ở cả **backend Node.js / NestJS**.
6
+
7
+ ---
8
+
9
+ ## 🏗️ Kiến trúc Hệ thống
10
+
11
+ ```
12
+ ┌──────────────────────────┐ postMessage ┌────────────────────────────────────────────────┐
13
+ │ SENDER (Iframe / App) │ ──────────────────► │ RECEIVER (Next.js Host) │
14
+ │ │ { type: LEARNING_ │ │
15
+ │ IframeClientTracker │ EVENT, payload } │ IframeHostTracker │
16
+ │ - emit(eventName, data) │ │ - handleMessage() → validate origin │
17
+ │ - setGameType() │ │ - enrich: user_id, app_id, event_id │
18
+ │ - start() / stop() │ │ - enqueue() → EventQueueDB (IndexedDB) │
19
+ └──────────────────────────┘ └────────────────────┬───────────────────────────┘
20
+
21
+
22
+ ┌────────────────────────────────────────────────┐
23
+ │ SyncService │
24
+ │ - Batch mỗi 30s hoặc immediate sync │
25
+ │ (unit_finish, unit_submit, unit_restart...) │
26
+ │ - Online/offline listener │
27
+ │ - Exponential Backoff (30s → 10m) │
28
+ │ - 401 → refresh token → retry │
29
+ │ - 400/403/422 → Dead Letter Queue │
30
+ └────────────────────┬───────────────────────────┘
31
+ │ POST /api/progress/batch
32
+ │ Bearer JWT
33
+
34
+ ┌────────────────────────────────────────────────┐
35
+ │ Backend API │
36
+ │ Có thể dùng EventProcessor để phân loại, │
37
+ │ normalize và build payload chuẩn │
38
+ └────────────────────────────────────────────────┘
39
+ ```
40
+
41
+ ---
42
+
43
+ ## 📂 Cấu trúc Module
44
+
45
+ | Module | Môi trường | Chức năng |
46
+ |--------|-----------|-----------|
47
+ | `IframeClientTracker` | Browser (Iframe) | Emit sự kiện qua `postMessage` hoặc Flutter bridge |
48
+ | `IframeHostTracker` | Browser (Host) | Nhận, enrich và lưu sự kiện vào IndexedDB |
49
+ | `EventQueueDB` | Browser | Hàng chờ bền vững IndexedDB với FIFO limit |
50
+ | `SyncService` | Browser | Batch upload, retry backoff, dead letter |
51
+ | `useIframeHostTracking` | React / Next.js | Hook tiện lợi bao bọc `IframeHostTracker` |
52
+ | **`EventProcessor`** | **Browser + Node.js** | **Pure logic: normalize, classify, buildBatch — dùng được ở backend** |
53
+ | `security` (utilities) | Browser + Node.js | HMAC-SHA256: `generateNonce`, `signHmacSha256`, `verifyHmacSha256` |
54
+
55
+ ---
56
+
57
+ ## ⚡ Tính năng Cốt lõi
58
+
59
+ - **Hàng chờ offline IndexedDB (Durable Queue):** Bảo toàn dữ liệu khi mất mạng, reload trang, đóng tab. FIFO limit `maxQueueSize` (mặc định 500), tự xóa 50 bản ghi cũ nhất khi đầy.
60
+ - **Đồng bộ thông minh:** Gom lô 30s hoặc sync ngay (`unit_finish`, `unit_submit`, `unit_restart`, `session_end`, `course_finish`, `pronunciation_score`, `client_error`).
61
+ - **Exponential Backoff:** Thử lại tăng dần 30s → 60s → 2m → 5m → 10m, tối đa 5 lần.
62
+ - **Dead Letter Queue:** Lỗi 400/403/422 không thử lại, đẩy vào `dead_letter`. Tự dọn dẹp sau 7 ngày.
63
+ - **JWT Refresh Flow:** Khi gặp 401, tự gọi `onRefreshToken` và retry 1 lần.
64
+ - **React Ready:** Hook `useIframeHostTracking` đồng bộ hóa state UI đầy đủ.
65
+ - **Backend Compatible:** `EventProcessor` không phụ thuộc browser API, dùng được trong NestJS/Node.js.
66
+
67
+ ---
68
+
69
+ ## 📦 Cài đặt
70
+
71
+ ```bash
72
+ # Cài từ thư mục nội bộ
73
+ npm install ./iframe-tracking-sdk
74
+
75
+ # Hoặc từ registry (nếu đã publish)
76
+ npm install @bkt/iframe-tracking-sdk
77
+ ```
78
+
79
+ ```bash
80
+ # Biên dịch thư viện (TypeScript → JavaScript)
81
+ cd iframe-tracking-sdk
82
+ npm run build
83
+ ```
84
+
85
+ ---
86
+
87
+ ## 🚀 Hướng dẫn Sử dụng
88
+
89
+ > **Luồng hoạt động:** Iframe `emit()` → `postMessage` → Host nhận → Lưu **IndexedDB** → SyncService gom batch → `POST /api/progress/batch`
90
+
91
+ ---
92
+
93
+ ### Bước 1 — Phía Iframe (Vite App): Emit sự kiện
94
+
95
+ ```typescript
96
+ import { IframeClientTracker } from '@bkt/iframe-tracking-sdk';
97
+
98
+ const tracker = new IframeClientTracker({
99
+ trustedHostOrigin: "https://host.bkt.edu.vn",
100
+ gameType: "multiple_choice",
101
+ debug: true,
102
+ });
103
+
104
+ tracker.start();
105
+ ```
106
+
107
+ **Emit theo luồng bài học:**
108
+
109
+ ```typescript
110
+ // ① Vào bài — lưu queue, gửi batch 30s
111
+ await tracker.emit('unit_start', {
112
+ unit_id: 'unit_lesson_01',
113
+ course_id: 'course_eng_basic',
114
+ });
115
+
116
+ // ② Xem câu hỏi
117
+ await tracker.emit('question_view', {
118
+ question_id: 'q_001',
119
+ question_type: 'multiple_choice',
120
+ unit_id: 'unit_lesson_01',
121
+ text: 'This is an apple',
122
+ });
123
+
124
+ // ③ Trả lời câu hỏi
125
+ await tracker.emit('question_answer', {
126
+ question_id: 'q_001',
127
+ question_type: 'multiple_choice',
128
+ is_correct: true,
129
+ score: 10,
130
+ duration_ms: 5200,
131
+ text: 'This is an apple',
132
+ });
133
+
134
+ // ④ Nghe phát âm
135
+ await tracker.emit('audio_play', {
136
+ audio_id: 'aud_apple_01',
137
+ context: 'word_detail',
138
+ });
139
+
140
+ // ⑤ Kết quả phát âm AI — sync NGAY ⚡
141
+ await tracker.emit('pronunciation_score', {
142
+ word_id: 'w_apple_001',
143
+ word_text: 'Apple',
144
+ overall_score: 85,
145
+ accuracy_score: 82,
146
+ fluency_score: 90,
147
+ });
148
+
149
+ // ⑥ Hoàn thành bài — sync NGAY ⚡
150
+ await tracker.emit('unit_finish', {
151
+ unit_id: 'unit_lesson_01',
152
+ course_id: 'course_eng_basic',
153
+ score: 85,
154
+ correct_answers: 17,
155
+ total_questions: 20,
156
+ });
157
+
158
+ // ⑦ Nộp bài — sync NGAY ⚡
159
+ await tracker.emit('unit_submit', {
160
+ unit_id: 'unit_lesson_01',
161
+ attempts: 1,
162
+ });
163
+ ```
164
+
165
+ **Đổi loại game động:**
166
+
167
+ ```typescript
168
+ tracker.setGameType('drag_the_words');
169
+
170
+ await tracker.emit('drag_drop_interaction', {
171
+ exercise_id: 'ex_drag_001',
172
+ item_id: 'word_subject',
173
+ target_id: 'box_blank_01',
174
+ is_correct: true,
175
+ word_text: 'subject',
176
+ });
177
+ ```
178
+
179
+ > Thư viện tự phân luồng môi trường — cùng `tracker.emit()`:
180
+ > - **Web Iframe** → `window.parent.postMessage()`
181
+ > - **Flutter WebView** → `window.TrackingBridge.postMessage()`
182
+ > - **Standalone debug** → `console.log()`
183
+
184
+ ---
185
+
186
+ ### Bước 2 — Phía Host (Next.js / React): Nhận, lưu và đồng bộ
187
+
188
+ ```tsx
189
+ "use client";
190
+ import React from 'react';
191
+ import { useIframeHostTracking } from '@bkt/iframe-tracking-sdk';
192
+
193
+ export default function LessonPage({ userId, lessonUrl }: {
194
+ userId: string;
195
+ lessonUrl: string;
196
+ }) {
197
+ const {
198
+ iframeRef,
199
+ isReady,
200
+ syncing,
201
+ events, // Danh sách event trong IndexedDB (debug)
202
+ flush, // Sync khẩn cấp toàn bộ queue ngay lập tức
203
+ handleIframeLoad, // Gắn vào onLoad của <iframe>
204
+ clearLogs,
205
+ clearQueue,
206
+ } = useIframeHostTracking({
207
+ // === BẮT BUỘC ===
208
+ iframeUrl: lessonUrl,
209
+ trustedOrigins: ["https://game.bkt.edu.vn"],
210
+ userId: userId,
211
+ appId: "bkt-kids-web",
212
+ apiEndpoint: "/api/progress/batch",
213
+ getJwtToken: async () => sessionStorage.getItem('access_token') || '',
214
+
215
+ // === TÙY CHỌN ===
216
+ sectionId: "section_001", // Tự động chèn section_id vào mọi event
217
+ batchIntervalMs: 30000, // Chu kỳ batch (mặc định 30s)
218
+ maxQueueSize: 500,
219
+ maxRetryCount: 5,
220
+
221
+ // Refresh token khi gặp lỗi 401
222
+ onRefreshToken: async () => {
223
+ const res = await fetch('/api/auth/refresh', { method: 'POST' });
224
+ if (!res.ok) return false;
225
+ const { token } = await res.json();
226
+ sessionStorage.setItem('access_token', token);
227
+ return token;
228
+ },
229
+
230
+ // Callback khi nhận event từ Iframe (trước khi lưu DB)
231
+ onEventReceived: (event) => {
232
+ console.log('[Tracking] Received:', event.event_name, event.payload);
233
+ },
234
+
235
+ // Callback khi sync lên server thành công
236
+ onEventSynced: (eventIds) => {
237
+ console.log('[Tracking] Synced:', eventIds.length, 'events');
238
+ },
239
+
240
+ // Callback khi cần reset UI (unit_restart / find_the_words)
241
+ onSessionRefreshNeeded: (eventName) => {
242
+ console.log('[Tracking] Session reset triggered by:', eventName);
243
+ },
244
+
245
+ // Callback khi event bị lỗi vĩnh viễn
246
+ onDeadLetter: (event) => {
247
+ console.error('[Tracking] Dead Letter:', event.event_name, event.error_message);
248
+ },
249
+
250
+ debug: process.env.NODE_ENV === 'development',
251
+ });
252
+
253
+ return (
254
+ <div>
255
+ {/* Nhúng Iframe — bắt buộc gắn ref và onLoad */}
256
+ <iframe
257
+ ref={iframeRef}
258
+ src={lessonUrl}
259
+ onLoad={handleIframeLoad}
260
+ width={700}
261
+ height={500}
262
+ sandbox="allow-scripts allow-same-origin"
263
+ title="Learning content"
264
+ />
265
+
266
+ {/* Thanh trạng thái (tùy chọn) */}
267
+ <div style={{ fontSize: 12, color: '#888', marginTop: 8 }}>
268
+ {syncing ? '🔄 Đang đồng bộ...' : isReady ? '✅ Sẵn sàng' : '⏳ Đang tải...'}
269
+ <span style={{ marginLeft: 8 }}>Queue: {events.length} events</span>
270
+ <button onClick={flush} disabled={syncing} style={{ marginLeft: 8 }}>
271
+ Sync ngay
272
+ </button>
273
+ </div>
274
+ </div>
275
+ );
276
+ }
277
+ ```
278
+
279
+ **Không dùng React hook (vanilla JS / Vue):**
280
+
281
+ ```typescript
282
+ import { IframeHostTracker } from '@bkt/iframe-tracking-sdk';
283
+
284
+ const tracker = new IframeHostTracker({
285
+ iframeUrl: "https://game.bkt.edu.vn/embed/lesson-1",
286
+ trustedOrigins: ["https://game.bkt.edu.vn"],
287
+ userId: "u_opaque_bkt_99",
288
+ appId: "bkt-kids-web",
289
+ apiEndpoint: "/api/progress/batch",
290
+ getJwtToken: () => sessionStorage.getItem('token') || '',
291
+ debug: true,
292
+ });
293
+
294
+ tracker.start();
295
+
296
+ // Flush khi người dùng thoát trang
297
+ window.addEventListener('beforeunload', () => tracker.flush());
298
+
299
+ // Dừng khi unmount / rời trang
300
+ // tracker.stop();
301
+ ```
302
+
303
+ ---
304
+
305
+ ### Bước 3 — Phía Backend (NestJS / Node.js): Xử lý với `EventProcessor`
306
+
307
+ `EventProcessor` là class thuần, không dùng `window` hay `IndexedDB` — chạy hoàn toàn ở server:
308
+
309
+ ```typescript
310
+ import { EventProcessor, TrackingEvent } from '@bkt/iframe-tracking-sdk';
311
+
312
+ @Injectable()
313
+ export class ProgressBatchService {
314
+
315
+ async handleBatch(body: { user_id: string; app_id: string; events: any[] }) {
316
+ const { user_id, app_id, events: rawEvents } = body;
317
+
318
+ // 1. Normalize & validate (lọc event thiếu event_name)
319
+ const events = rawEvents
320
+ .map(e => EventProcessor.normalize(e, { user_id, app_id }))
321
+ .filter(Boolean) as TrackingEvent[];
322
+
323
+ if (events.length === 0) return { accepted: 0 };
324
+
325
+ // 2. Phân loại → route đến đúng service thống kê
326
+ const groups = EventProcessor.groupByCategory(events);
327
+ // groups.lifecycle → unit_start, unit_finish, unit_submit, ...
328
+ // groups.vocabulary → word_click, word_view, flashcard_flip, ...
329
+ // groups.question → question_answer, drag_drop_interaction, ...
330
+ // groups.speaking → pronunciation_score, pronunciation_record
331
+ // groups.media → audio_play, video_completed, ...
332
+
333
+ await Promise.all([
334
+ this.saveLifecycle(groups.lifecycle),
335
+ this.saveVocabulary(groups.vocabulary),
336
+ this.saveQuestion(groups.question),
337
+ this.saveSpeaking(groups.speaking),
338
+ ]);
339
+
340
+ return { accepted: events.length };
341
+ }
342
+
343
+ private async saveSpeaking(events: TrackingEvent[]) {
344
+ for (const event of events) {
345
+ if (event.event_name !== 'pronunciation_score') continue;
346
+
347
+ // extractScore tự xử lý: is_correct / result / overall_score >= 80
348
+ const { isCorrect, score } = EventProcessor.extractScore(event);
349
+
350
+ await this.db.upsertPronunciationStat({
351
+ user_id: event.user_id,
352
+ word_text: event.payload.word_text,
353
+ overall_score: score,
354
+ is_correct: isCorrect,
355
+ });
356
+ }
357
+ }
358
+ }
359
+ ```
360
+
361
+ **Phân loại và kiểm tra đơn lẻ:**
362
+
363
+ ```typescript
364
+ EventProcessor.classify('pronunciation_score'); // → 'speaking'
365
+ EventProcessor.classify('word_answer'); // → 'vocabulary'
366
+ EventProcessor.classify('video_completed'); // → 'media'
367
+
368
+ EventProcessor.isCritical('unit_finish'); // → true
369
+ EventProcessor.isCritical('word_click'); // → false
370
+
371
+ const { isCorrect, score } = EventProcessor.extractScore(event);
372
+ const payload = EventProcessor.parseIframeMessage(rawWsData); // WebSocket
373
+
374
+
375
+ ## 🛠️ API Reference
376
+
377
+ ### `IframeHostTrackerConfig`
378
+
379
+ | Tham số | Kiểu | Bắt buộc | Mô tả |
380
+ |---------|------|----------|-------|
381
+ | `iframeUrl` | `string` | ✅ | URL của iframe đang nhúng |
382
+ | `trustedOrigins` | `string[]` | ✅ | Whitelist origin được phép gửi event |
383
+ | `userId` | `string` | ✅ | Opaque User ID của học sinh |
384
+ | `appId` | `string` | ✅ | Mã ứng dụng (vd: `"bkt-kids-web-v1"`) |
385
+ | `apiEndpoint` | `string` | ✅ | Endpoint nhận batch: `/api/progress/batch` |
386
+ | `getJwtToken` | `() => Promise<string> \| string` | ✅ | Lấy Bearer JWT token để gửi API |
387
+ | `onRefreshToken` | `() => Promise<boolean \| string>` | ❌ | Gọi khi gặp 401, trả về `true` hoặc token mới |
388
+ | `batchIntervalMs` | `number` | ❌ | Chu kỳ batch (mặc định: `30000`ms) |
389
+ | `maxQueueSize` | `number` | ❌ | Giới hạn IndexedDB (mặc định: `500`) |
390
+ | `maxRetryCount` | `number` | ❌ | Số lần retry tối đa (mặc định: `5`) |
391
+ | `debug` | `boolean` | ❌ | Bật console logs chi tiết |
392
+ | `onEventReceived` | `(event: TrackingEvent) => void` | ❌ | Callback khi nhận event từ Iframe |
393
+ | `onEventSynced` | `(eventIds: string[]) => void` | ❌ | Callback khi sync thành công |
394
+ | `onDeadLetter` | `(event: TrackingEvent) => void` | ❌ | Callback khi event vào Dead Letter Queue |
395
+ | `onSyncStatusChange` | `(status: 'idle' \| 'syncing' \| 'error') => void` | ❌ | Callback thay đổi trạng thái sync |
396
+ | `onSessionRefreshNeeded` | `(eventName: string) => void` | ❌ | Callback khi nhận `unit_restart` hoặc `unit_start` (find_the_words) |
397
+
398
+ ### `UseIframeHostTrackingResult` (React Hook Return)
399
+
400
+ | Property | Kiểu | Mô tả |
401
+ |----------|------|-------|
402
+ | `iframeRef` | `RefObject<HTMLIFrameElement>` | Gắn vào thẻ `<iframe>` |
403
+ | `isReady` | `boolean` | Tracker đã khởi động và sẵn sàng |
404
+ | `syncing` | `boolean` | Đang trong quá trình sync |
405
+ | `events` | `TrackingEvent[]` | Danh sách event từ IndexedDB (tối đa 100) |
406
+ | `flush` | `() => Promise<void>` | Sync khẩn cấp toàn bộ queue |
407
+ | `handleIframeLoad` | `() => void` | Gán vào `onLoad` của `<iframe>` |
408
+ | `clearLogs` | `() => void` | Xóa log hiển thị trên UI |
409
+ | `clearQueue` | `() => Promise<void>` | Xóa toàn bộ IndexedDB queue |
410
+ | `updateCredentials` | `(sessionId, hmacSecret) => void` | Cập nhật credentials local |
411
+ | `reinitiateHandshake` | `() => void` | Tái kết nối với iframe |
412
+
413
+ ### `IframeClientTrackerConfig`
414
+
415
+ | Tham số | Kiểu | Mô tả |
416
+ |---------|------|-------|
417
+ | `trustedHostOrigin` | `string` | Origin của trang Host (Next.js) |
418
+ | `gameType` | `string` | Game type mặc định gắn vào mọi event |
419
+ | `debug` | `boolean` | Bật logs chi tiết trong iframe |
420
+ | `onHandshakeComplete` | `() => void` | Callback khi kênh sẵn sàng |
421
+
422
+ ### `IframeClientTracker` Methods
423
+
424
+ | Method | Mô tả |
425
+ |--------|-------|
426
+ | `start()` | Bắt đầu lắng nghe, kích hoạt handshake |
427
+ | `stop()` | Dừng tracker, xóa listeners |
428
+ | `setGameType(type)` | Thay đổi game type động |
429
+ | `isReady()` | Kiểm tra kênh sẵn sàng |
430
+ | `emit(eventName, payload?, gameType?)` | Gửi sự kiện học tập lên Host |
431
+
432
+ ### `EventProcessor` (Static Methods)
433
+
434
+ | Method | Môi trường | Mô tả |
435
+ |--------|-----------|-------|
436
+ | `normalize(raw, defaults?)` | Browser + Node.js | Validate & chuẩn hóa raw event → `TrackingEvent \| null` |
437
+ | `buildBatchPayload(events, appVersion?)` | Browser + Node.js | Build `BatchRequestBody` chuẩn API (không có `session_id`) |
438
+ | `classify(eventName)` | Browser + Node.js | Phân loại → `EventCategory` |
439
+ | `groupByCategory(events)` | Browser + Node.js | Nhóm events theo category |
440
+ | `isCritical(eventName)` | Browser + Node.js | Kiểm tra có cần immediate sync không |
441
+ | `extractScore(event)` | Browser + Node.js | Trích xuất `{ isCorrect, score }` từ payload |
442
+ | `parseIframeMessage(rawData)` | Browser + Node.js | Parse raw `postMessage` data → `LearningEventPayload \| null` |
443
+
444
+ **`EventCategory`** values: `'lifecycle'` | `'vocabulary'` | `'question'` | `'media'` | `'speaking'` | `'system'` | `'unknown'`
445
+
446
+ ---
447
+
448
+ ## 📊 Cấu trúc Payload Batch gửi lên API
449
+
450
+ Khi `SyncService` thực hiện đồng bộ, JSON gửi lên endpoint `POST /api/progress/batch`:
451
+
452
+ ```json
453
+ {
454
+ "user_id": "u_opaque_bkt_99",
455
+ "app_id": "bkt-kids-web-v1",
456
+ "app_version": "1.0.0",
457
+ "events": [
458
+ {
459
+ "event_id": "evt_1716000000000_rj82kd9z",
460
+ "event_name": "unit_finish",
461
+ "timestamp": 1716000000000,
462
+ "client_timestamp": 1716000000050,
463
+ "unit_id": "unit_lesson_01",
464
+ "course_id": "course_eng_basic",
465
+ "score": 85,
466
+ "correct_answers": 17,
467
+ "total_questions": 20
468
+ },
469
+ {
470
+ "event_id": "evt_1716000005000_ab12cd34",
471
+ "event_name": "question_answer",
472
+ "timestamp": 1716000005000,
473
+ "client_timestamp": 1716000005020,
474
+ "question_id": "q_001",
475
+ "question_type": "multiple_choice",
476
+ "is_correct": true,
477
+ "score": 10,
478
+ "duration_ms": 5200,
479
+ "text": "This is an apple"
480
+ }
481
+ ]
482
+ }
483
+ ```
484
+
485
+ > **Lưu ý quan trọng:**
486
+ > - `event_id` theo format chuẩn: `evt_[timestamp_ms]_[random_8_chars]`
487
+ > - `session_id` **không** có trong payload API (chỉ dùng local debug)
488
+ > - Payload fields được flatten phẳng vào từng event object (không nested trong `payload: {}`)
489
+ > - `app_id` chỉ 1 lần ở root body, không lặp lại trong từng event
490
+
491
+ ---
492
+
493
+ ## 🔄 Xử lý Ngoại lệ & Retry
494
+
495
+ ```
496
+ POST /api/progress/batch thất bại
497
+ ├── 200 OK (partial rejected_details) → Delete accepted, Dead Letter rejected
498
+ ├── 401 Unauthorized → Gọi onRefreshToken() → retry 1 lần
499
+ │ Thất bại → pending, báo re-login
500
+ ├── 400 Bad Request → ❌ Dead Letter ngay (lỗi cấu trúc)
501
+ ├── 403 Forbidden → ❌ Dead Letter ngay (session hỏng)
502
+ ├── 422 Unprocessable → ❌ Dead Letter ngay (HMAC sai)
503
+ ├── 429 / 5xx Server Error → 🔄 Exponential Backoff retry
504
+ ├── Timeout (> 15s) → 🔄 Exponential Backoff retry
505
+ └── Offline → Giữ queue, sync khi online trở lại
506
+ ```
507
+
508
+ **Exponential Backoff delays:** `30s → 60s → 120s → 300s → 600s`
509
+
510
+ ---
511
+
512
+ ## 📋 Sự kiện Immediate Sync (Không chờ batch 30s)
513
+
514
+ Các event sau đây kích hoạt upload ngay lập tức sau khi lưu vào IndexedDB:
515
+
516
+ | Event | Lý do |
517
+ |-------|-------|
518
+ | `unit_finish` | Hoàn thành bài học → ghi nhận điểm ngay |
519
+ | `unit_submit` | Nộp bài → không được trễ |
520
+ | `unit_restart` | Làm lại bài → backend cần tạo attempt mới |
521
+ | `session_end` | Thoát phiên học |
522
+ | `course_finish` | Hoàn thành khoá học |
523
+ | `pronunciation_score` | Điểm phát âm AI |
524
+ | `client_error` | Lỗi runtime cần alert ngay |
525
+
526
+ ---
527
+
528
+ ## 🔒 License
529
+
530
+ Thư viện được phân phối dưới giấy phép **MIT**.
@@ -0,0 +1,38 @@
1
+ export interface IframeClientTrackerConfig {
2
+ trustedHostOrigin: string;
3
+ debug?: boolean;
4
+ onHandshakeComplete?: () => void;
5
+ gameType?: string;
6
+ }
7
+ export declare class IframeClientTracker {
8
+ private config;
9
+ private hmacSecret;
10
+ private nonce;
11
+ private isInitialized;
12
+ private debug;
13
+ private gameType;
14
+ constructor(config: IframeClientTrackerConfig);
15
+ private isBrowser;
16
+ private isWebView;
17
+ /**
18
+ * Set the game type dynamically.
19
+ */
20
+ setGameType(gameType: string): void;
21
+ /**
22
+ * Start tracking client (Handshake disabled).
23
+ */
24
+ start(): void;
25
+ /**
26
+ * Stop tracking client.
27
+ */
28
+ stop(): void;
29
+ /**
30
+ * Check if the handshake is complete and the client is ready to emit events.
31
+ */
32
+ isReady(): boolean;
33
+ /**
34
+ * Emit a learning tracking event to the Parent Host.
35
+ * Generates event ID and posts it to the Parent without HMAC signature.
36
+ */
37
+ emit(eventName: string, payload?: Record<string, any>, gameType?: string): Promise<void>;
38
+ }
package/dist/client.js ADDED
@@ -0,0 +1,110 @@
1
+ export class IframeClientTracker {
2
+ config;
3
+ hmacSecret = null;
4
+ nonce = null;
5
+ isInitialized = true;
6
+ debug;
7
+ gameType = null;
8
+ constructor(config) {
9
+ this.config = config;
10
+ this.debug = config.debug || false;
11
+ this.isInitialized = true;
12
+ }
13
+ isBrowser() {
14
+ return typeof window !== 'undefined';
15
+ }
16
+ isWebView() {
17
+ if (!this.isBrowser())
18
+ return false;
19
+ const win = window;
20
+ return !!(win.TrackingBridge && typeof win.TrackingBridge.postMessage === 'function');
21
+ }
22
+ /**
23
+ * Set the game type dynamically.
24
+ */
25
+ setGameType(gameType) {
26
+ this.gameType = gameType;
27
+ if (this.debug) {
28
+ console.log(`[Tracking Client] Game type updated dynamically to: ${gameType}`);
29
+ }
30
+ }
31
+ /**
32
+ * Start tracking client (Handshake disabled).
33
+ */
34
+ start() {
35
+ if (this.debug)
36
+ console.log('[Tracking Client] Started (Handshake disabled).');
37
+ this.config.onHandshakeComplete?.();
38
+ }
39
+ /**
40
+ * Stop tracking client.
41
+ */
42
+ stop() {
43
+ if (this.debug)
44
+ console.log('[Tracking Client] Stopped.');
45
+ }
46
+ /**
47
+ * Check if the handshake is complete and the client is ready to emit events.
48
+ */
49
+ isReady() {
50
+ return true;
51
+ }
52
+ /**
53
+ * Emit a learning tracking event to the Parent Host.
54
+ * Generates event ID and posts it to the Parent without HMAC signature.
55
+ */
56
+ async emit(eventName, payload = {}, gameType) {
57
+ if (!this.isBrowser())
58
+ return;
59
+ if (!this.isReady()) {
60
+ if (this.debug) {
61
+ console.warn(`[Tracking Client] Cannot emit event '${eventName}'. Handshake not complete. Logging event locally:`, payload);
62
+ }
63
+ // If parent is not active and not in WebView, fallback to standalone Console logger
64
+ if (window.parent === window && !this.isWebView()) {
65
+ console.log(`[Tracking Standalone] Emit '${eventName}':`, payload);
66
+ }
67
+ return;
68
+ }
69
+ const resolvedGameType = gameType || this.gameType || this.config.gameType || 'unknown_game';
70
+ const timestamp = Date.now();
71
+ try {
72
+ const messagePayload = {
73
+ type: 'LEARNING_EVENT',
74
+ timestamp: timestamp,
75
+ payload: {
76
+ event_name: eventName,
77
+ game: resolvedGameType,
78
+ ...payload
79
+ }
80
+ };
81
+ if (this.nonce) {
82
+ messagePayload.nonce = this.nonce;
83
+ }
84
+ if (this.debug) {
85
+ console.log(`[Tracking Client] Emitting event '${eventName}'`);
86
+ }
87
+ // Send message to the appropriate channel
88
+ const win = window;
89
+ if (this.isWebView()) {
90
+ if (this.debug)
91
+ console.log('[Tracking Client] WebView bridge detected. Sending to Flutter TrackingBridge.');
92
+ win.TrackingBridge.postMessage(JSON.stringify(messagePayload));
93
+ }
94
+ else if (window.parent !== window) {
95
+ if (this.debug)
96
+ console.log('[Tracking Client] Iframe environment detected. Sending via postMessage.');
97
+ window.parent.postMessage(messagePayload, this.config.trustedHostOrigin);
98
+ }
99
+ else {
100
+ // Fallback for standalone / debugging
101
+ console.log('[Tracking Client standalone emit]:', messagePayload);
102
+ }
103
+ }
104
+ catch (e) {
105
+ if (this.debug)
106
+ console.error('[Tracking Client] Failed to emit event:', e);
107
+ throw e;
108
+ }
109
+ }
110
+ }