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/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**.
|
package/dist/client.d.ts
ADDED
|
@@ -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
|
+
}
|