iframe-tracking-sdk
v1.0.3
Published
SDK for secure and resilient Iframe tracking progress, offline queues, and automatic sync with backoff.
Maintainers
Readme
@bkt/iframe-tracking-sdk
@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.
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.
🏗️ Kiến trúc Hệ thống
┌──────────────────────────┐ postMessage ┌────────────────────────────────────────────────┐
│ SENDER (Iframe / App) │ ──────────────────► │ RECEIVER (Next.js Host) │
│ │ { type: LEARNING_ │ │
│ IframeClientTracker │ EVENT, payload } │ IframeHostTracker │
│ - emit(eventName, data) │ │ - handleMessage() → validate origin │
│ - setGameType() │ │ - enrich: user_id, app_id, event_id │
│ - start() / stop() │ │ - enqueue() → EventQueueDB (IndexedDB) │
└──────────────────────────┘ └────────────────────┬───────────────────────────┘
│
▼
┌────────────────────────────────────────────────┐
│ SyncService │
│ - Batch mỗi 30s hoặc immediate sync │
│ (unit_finish, unit_submit, unit_restart...) │
│ - Online/offline listener │
│ - Exponential Backoff (30s → 10m) │
│ - 401 → refresh token → retry │
│ - 400/403/422 → Dead Letter Queue │
└────────────────────┬───────────────────────────┘
│ POST /api/progress/batch
│ Bearer JWT
▼
┌────────────────────────────────────────────────┐
│ Backend API │
│ Có thể dùng EventProcessor để phân loại, │
│ normalize và build payload chuẩn │
└────────────────────────────────────────────────┘📂 Cấu trúc Module
| Module | Môi trường | Chức năng |
|--------|-----------|-----------|
| IframeClientTracker | Browser (Iframe) | Emit sự kiện qua postMessage hoặc Flutter bridge |
| IframeHostTracker | Browser (Host) | Nhận, enrich và lưu sự kiện vào IndexedDB |
| EventQueueDB | Browser | Hàng chờ bền vững IndexedDB với FIFO limit |
| SyncService | Browser | Batch upload, retry backoff, dead letter |
| useIframeHostTracking | React / Next.js | Hook tiện lợi bao bọc IframeHostTracker |
| EventProcessor | Browser + Node.js | Pure logic: normalize, classify, buildBatch — dùng được ở backend |
| security (utilities) | Browser + Node.js | HMAC-SHA256: generateNonce, signHmacSha256, verifyHmacSha256 |
⚡ Tính năng Cốt lõi
- 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. - Đồ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). - Exponential Backoff: Thử lại tăng dần 30s → 60s → 2m → 5m → 10m, tối đa 5 lần.
- 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. - JWT Refresh Flow: Khi gặp 401, tự gọi
onRefreshTokenvà retry 1 lần. - React Ready: Hook
useIframeHostTrackingđồng bộ hóa state UI đầy đủ. - Backend Compatible:
EventProcessorkhông phụ thuộc browser API, dùng được trong NestJS/Node.js.
📦 Cài đặt
# Cài từ thư mục nội bộ
npm install ./iframe-tracking-sdk
# Hoặc từ registry (nếu đã publish)
npm install @bkt/iframe-tracking-sdk# Biên dịch thư viện (TypeScript → JavaScript)
cd iframe-tracking-sdk
npm run build🚀 Hướng dẫn Sử dụng
Luồng hoạt động: Iframe
emit()→postMessage→ Host nhận → Lưu IndexedDB → SyncService gom batch →POST /api/progress/batch
Bước 1 — Phía Iframe (Vite App): Emit sự kiện
import { IframeClientTracker } from '@bkt/iframe-tracking-sdk';
const tracker = new IframeClientTracker({
trustedHostOrigin: "https://host.bkt.edu.vn",
gameType: "multiple_choice",
debug: true,
});
tracker.start();Emit theo luồng bài học:
// ① Vào bài — lưu queue, gửi batch 30s
await tracker.emit('unit_start', {
unit_id: 'unit_lesson_01',
course_id: 'course_eng_basic',
});
// ② Xem câu hỏi
await tracker.emit('question_view', {
question_id: 'q_001',
question_type: 'multiple_choice',
unit_id: 'unit_lesson_01',
text: 'This is an apple',
});
// ③ Trả lời câu hỏi
await tracker.emit('question_answer', {
question_id: 'q_001',
question_type: 'multiple_choice',
is_correct: true,
score: 10,
duration_ms: 5200,
text: 'This is an apple',
});
// ④ Nghe phát âm
await tracker.emit('audio_play', {
audio_id: 'aud_apple_01',
context: 'word_detail',
});
// ⑤ Kết quả phát âm AI — sync NGAY ⚡
await tracker.emit('pronunciation_score', {
word_id: 'w_apple_001',
word_text: 'Apple',
overall_score: 85,
accuracy_score: 82,
fluency_score: 90,
});
// ⑥ Hoàn thành bài — sync NGAY ⚡
await tracker.emit('unit_finish', {
unit_id: 'unit_lesson_01',
course_id: 'course_eng_basic',
score: 85,
correct_answers: 17,
total_questions: 20,
});
// ⑦ Nộp bài — sync NGAY ⚡
await tracker.emit('unit_submit', {
unit_id: 'unit_lesson_01',
attempts: 1,
});Đổi loại game động:
tracker.setGameType('drag_the_words');
await tracker.emit('drag_drop_interaction', {
exercise_id: 'ex_drag_001',
item_id: 'word_subject',
target_id: 'box_blank_01',
is_correct: true,
word_text: 'subject',
});Thư viện tự phân luồng môi trường — cùng
tracker.emit():
- Web Iframe →
window.parent.postMessage()- Flutter WebView →
window.TrackingBridge.postMessage()- Standalone debug →
console.log()
Bước 2 — Phía Host (Next.js / React): Nhận, lưu và đồng bộ
"use client";
import React from 'react';
import { useIframeHostTracking } from '@bkt/iframe-tracking-sdk';
export default function LessonPage({ userId, lessonUrl }: {
userId: string;
lessonUrl: string;
}) {
const {
iframeRef,
isReady,
syncing,
events, // Danh sách event trong IndexedDB (debug)
flush, // Sync khẩn cấp toàn bộ queue ngay lập tức
handleIframeLoad, // Gắn vào onLoad của <iframe>
clearLogs,
clearQueue,
} = useIframeHostTracking({
// === BẮT BUỘC ===
iframeUrl: lessonUrl,
trustedOrigins: ["https://game.bkt.edu.vn"],
userId: userId,
appId: "bkt-kids-web",
apiEndpoint: "/api/progress/batch",
getJwtToken: async () => sessionStorage.getItem('access_token') || '',
// === TÙY CHỌN ===
sectionId: "section_001", // Tự động chèn section_id vào mọi event
batchIntervalMs: 30000, // Chu kỳ batch (mặc định 30s)
maxQueueSize: 500,
maxRetryCount: 5,
// Refresh token khi gặp lỗi 401
onRefreshToken: async () => {
const res = await fetch('/api/auth/refresh', { method: 'POST' });
if (!res.ok) return false;
const { token } = await res.json();
sessionStorage.setItem('access_token', token);
return token;
},
// Callback khi nhận event từ Iframe (trước khi lưu DB)
onEventReceived: (event) => {
console.log('[Tracking] Received:', event.event_name, event.payload);
},
// Callback khi sync lên server thành công
onEventSynced: (eventIds) => {
console.log('[Tracking] Synced:', eventIds.length, 'events');
},
// Callback khi cần reset UI (unit_restart / find_the_words)
onSessionRefreshNeeded: (eventName) => {
console.log('[Tracking] Session reset triggered by:', eventName);
},
// Callback khi event bị lỗi vĩnh viễn
onDeadLetter: (event) => {
console.error('[Tracking] Dead Letter:', event.event_name, event.error_message);
},
debug: process.env.NODE_ENV === 'development',
});
return (
<div>
{/* Nhúng Iframe — bắt buộc gắn ref và onLoad */}
<iframe
ref={iframeRef}
src={lessonUrl}
onLoad={handleIframeLoad}
width={700}
height={500}
sandbox="allow-scripts allow-same-origin"
title="Learning content"
/>
{/* Thanh trạng thái (tùy chọn) */}
<div style={{ fontSize: 12, color: '#888', marginTop: 8 }}>
{syncing ? '🔄 Đang đồng bộ...' : isReady ? '✅ Sẵn sàng' : '⏳ Đang tải...'}
<span style={{ marginLeft: 8 }}>Queue: {events.length} events</span>
<button onClick={flush} disabled={syncing} style={{ marginLeft: 8 }}>
Sync ngay
</button>
</div>
</div>
);
}Không dùng React hook (vanilla JS / Vue):
import { IframeHostTracker } from '@bkt/iframe-tracking-sdk';
const tracker = new IframeHostTracker({
iframeUrl: "https://game.bkt.edu.vn/embed/lesson-1",
trustedOrigins: ["https://game.bkt.edu.vn"],
userId: "u_opaque_bkt_99",
appId: "bkt-kids-web",
apiEndpoint: "/api/progress/batch",
getJwtToken: () => sessionStorage.getItem('token') || '',
debug: true,
});
tracker.start();
// Flush khi người dùng thoát trang
window.addEventListener('beforeunload', () => tracker.flush());
// Dừng khi unmount / rời trang
// tracker.stop();Bước 3 — Phía Backend (NestJS / Node.js): Xử lý với EventProcessor
EventProcessor là class thuần, không dùng window hay IndexedDB — chạy hoàn toàn ở server:
import { EventProcessor, TrackingEvent } from '@bkt/iframe-tracking-sdk';
@Injectable()
export class ProgressBatchService {
async handleBatch(body: { user_id: string; app_id: string; events: any[] }) {
const { user_id, app_id, events: rawEvents } = body;
// 1. Normalize & validate (lọc event thiếu event_name)
const events = rawEvents
.map(e => EventProcessor.normalize(e, { user_id, app_id }))
.filter(Boolean) as TrackingEvent[];
if (events.length === 0) return { accepted: 0 };
// 2. Phân loại → route đến đúng service thống kê
const groups = EventProcessor.groupByCategory(events);
// groups.lifecycle → unit_start, unit_finish, unit_submit, ...
// groups.vocabulary → word_click, word_view, flashcard_flip, ...
// groups.question → question_answer, drag_drop_interaction, ...
// groups.speaking → pronunciation_score, pronunciation_record
// groups.media → audio_play, video_completed, ...
await Promise.all([
this.saveLifecycle(groups.lifecycle),
this.saveVocabulary(groups.vocabulary),
this.saveQuestion(groups.question),
this.saveSpeaking(groups.speaking),
]);
return { accepted: events.length };
}
private async saveSpeaking(events: TrackingEvent[]) {
for (const event of events) {
if (event.event_name !== 'pronunciation_score') continue;
// extractScore tự xử lý: is_correct / result / overall_score >= 80
const { isCorrect, score } = EventProcessor.extractScore(event);
await this.db.upsertPronunciationStat({
user_id: event.user_id,
word_text: event.payload.word_text,
overall_score: score,
is_correct: isCorrect,
});
}
}
}Phân loại và kiểm tra đơn lẻ:
EventProcessor.classify('pronunciation_score'); // → 'speaking'
EventProcessor.classify('word_answer'); // → 'vocabulary'
EventProcessor.classify('video_completed'); // → 'media'
EventProcessor.isCritical('unit_finish'); // → true
EventProcessor.isCritical('word_click'); // → false
const { isCorrect, score } = EventProcessor.extractScore(event);
const payload = EventProcessor.parseIframeMessage(rawWsData); // WebSocket
## 🛠️ API Reference
### `IframeHostTrackerConfig`
| Tham số | Kiểu | Bắt buộc | Mô tả |
|---------|------|----------|-------|
| `iframeUrl` | `string` | ✅ | URL của iframe đang nhúng |
| `trustedOrigins` | `string[]` | ✅ | Whitelist origin được phép gửi event |
| `userId` | `string` | ✅ | Opaque User ID của học sinh |
| `appId` | `string` | ✅ | Mã ứng dụng (vd: `"bkt-kids-web-v1"`) |
| `apiEndpoint` | `string` | ✅ | Endpoint nhận batch: `/api/progress/batch` |
| `getJwtToken` | `() => Promise<string> \| string` | ✅ | Lấy Bearer JWT token để gửi API |
| `onRefreshToken` | `() => Promise<boolean \| string>` | ❌ | Gọi khi gặp 401, trả về `true` hoặc token mới |
| `batchIntervalMs` | `number` | ❌ | Chu kỳ batch (mặc định: `30000`ms) |
| `maxQueueSize` | `number` | ❌ | Giới hạn IndexedDB (mặc định: `500`) |
| `maxRetryCount` | `number` | ❌ | Số lần retry tối đa (mặc định: `5`) |
| `debug` | `boolean` | ❌ | Bật console logs chi tiết |
| `onEventReceived` | `(event: TrackingEvent) => void` | ❌ | Callback khi nhận event từ Iframe |
| `onEventSynced` | `(eventIds: string[]) => void` | ❌ | Callback khi sync thành công |
| `onDeadLetter` | `(event: TrackingEvent) => void` | ❌ | Callback khi event vào Dead Letter Queue |
| `onSyncStatusChange` | `(status: 'idle' \| 'syncing' \| 'error') => void` | ❌ | Callback thay đổi trạng thái sync |
| `onSessionRefreshNeeded` | `(eventName: string) => void` | ❌ | Callback khi nhận `unit_restart` hoặc `unit_start` (find_the_words) |
### `UseIframeHostTrackingResult` (React Hook Return)
| Property | Kiểu | Mô tả |
|----------|------|-------|
| `iframeRef` | `RefObject<HTMLIFrameElement>` | Gắn vào thẻ `<iframe>` |
| `isReady` | `boolean` | Tracker đã khởi động và sẵn sàng |
| `syncing` | `boolean` | Đang trong quá trình sync |
| `events` | `TrackingEvent[]` | Danh sách event từ IndexedDB (tối đa 100) |
| `flush` | `() => Promise<void>` | Sync khẩn cấp toàn bộ queue |
| `handleIframeLoad` | `() => void` | Gán vào `onLoad` của `<iframe>` |
| `clearLogs` | `() => void` | Xóa log hiển thị trên UI |
| `clearQueue` | `() => Promise<void>` | Xóa toàn bộ IndexedDB queue |
| `updateCredentials` | `(sessionId, hmacSecret) => void` | Cập nhật credentials local |
| `reinitiateHandshake` | `() => void` | Tái kết nối với iframe |
### `IframeClientTrackerConfig`
| Tham số | Kiểu | Mô tả |
|---------|------|-------|
| `trustedHostOrigin` | `string` | Origin của trang Host (Next.js) |
| `gameType` | `string` | Game type mặc định gắn vào mọi event |
| `debug` | `boolean` | Bật logs chi tiết trong iframe |
| `onHandshakeComplete` | `() => void` | Callback khi kênh sẵn sàng |
### `IframeClientTracker` Methods
| Method | Mô tả |
|--------|-------|
| `start()` | Bắt đầu lắng nghe, kích hoạt handshake |
| `stop()` | Dừng tracker, xóa listeners |
| `setGameType(type)` | Thay đổi game type động |
| `isReady()` | Kiểm tra kênh sẵn sàng |
| `emit(eventName, payload?, gameType?)` | Gửi sự kiện học tập lên Host |
### `EventProcessor` (Static Methods)
| Method | Môi trường | Mô tả |
|--------|-----------|-------|
| `normalize(raw, defaults?)` | Browser + Node.js | Validate & chuẩn hóa raw event → `TrackingEvent \| null` |
| `buildBatchPayload(events, appVersion?)` | Browser + Node.js | Build `BatchRequestBody` chuẩn API (không có `session_id`) |
| `classify(eventName)` | Browser + Node.js | Phân loại → `EventCategory` |
| `groupByCategory(events)` | Browser + Node.js | Nhóm events theo category |
| `isCritical(eventName)` | Browser + Node.js | Kiểm tra có cần immediate sync không |
| `extractScore(event)` | Browser + Node.js | Trích xuất `{ isCorrect, score }` từ payload |
| `parseIframeMessage(rawData)` | Browser + Node.js | Parse raw `postMessage` data → `LearningEventPayload \| null` |
**`EventCategory`** values: `'lifecycle'` | `'vocabulary'` | `'question'` | `'media'` | `'speaking'` | `'system'` | `'unknown'`
---
## 📊 Cấu trúc Payload Batch gửi lên API
Khi `SyncService` thực hiện đồng bộ, JSON gửi lên endpoint `POST /api/progress/batch`:
```json
{
"user_id": "u_opaque_bkt_99",
"app_id": "bkt-kids-web-v1",
"app_version": "1.0.0",
"events": [
{
"event_id": "evt_1716000000000_rj82kd9z",
"event_name": "unit_finish",
"timestamp": 1716000000000,
"client_timestamp": 1716000000050,
"unit_id": "unit_lesson_01",
"course_id": "course_eng_basic",
"score": 85,
"correct_answers": 17,
"total_questions": 20
},
{
"event_id": "evt_1716000005000_ab12cd34",
"event_name": "question_answer",
"timestamp": 1716000005000,
"client_timestamp": 1716000005020,
"question_id": "q_001",
"question_type": "multiple_choice",
"is_correct": true,
"score": 10,
"duration_ms": 5200,
"text": "This is an apple"
}
]
}Lưu ý quan trọng:
event_idtheo format chuẩn:evt_[timestamp_ms]_[random_8_chars]session_idkhông có trong payload API (chỉ dùng local debug)- Payload fields được flatten phẳng vào từng event object (không nested trong
payload: {})app_idchỉ 1 lần ở root body, không lặp lại trong từng event
🔄 Xử lý Ngoại lệ & Retry
POST /api/progress/batch thất bại
├── 200 OK (partial rejected_details) → Delete accepted, Dead Letter rejected
├── 401 Unauthorized → Gọi onRefreshToken() → retry 1 lần
│ Thất bại → pending, báo re-login
├── 400 Bad Request → ❌ Dead Letter ngay (lỗi cấu trúc)
├── 403 Forbidden → ❌ Dead Letter ngay (session hỏng)
├── 422 Unprocessable → ❌ Dead Letter ngay (HMAC sai)
├── 429 / 5xx Server Error → 🔄 Exponential Backoff retry
├── Timeout (> 15s) → 🔄 Exponential Backoff retry
└── Offline → Giữ queue, sync khi online trở lạiExponential Backoff delays: 30s → 60s → 120s → 300s → 600s
📋 Sự kiện Immediate Sync (Không chờ batch 30s)
Các event sau đây kích hoạt upload ngay lập tức sau khi lưu vào IndexedDB:
| Event | Lý do |
|-------|-------|
| unit_finish | Hoàn thành bài học → ghi nhận điểm ngay |
| unit_submit | Nộp bài → không được trễ |
| unit_restart | Làm lại bài → backend cần tạo attempt mới |
| session_end | Thoát phiên học |
| course_finish | Hoàn thành khoá học |
| pronunciation_score | Điểm phát âm AI |
| client_error | Lỗi runtime cần alert ngay |
🔒 License
Thư viện được phân phối dưới giấy phép MIT.
