up
All checks were successful
Deploy to Production / deploy (push) Successful in 8s

This commit is contained in:
lubukhu
2026-01-24 13:35:11 +07:00
parent 6c3e93636e
commit 65fd0158a3
145 changed files with 10262 additions and 0 deletions

View File

@@ -0,0 +1,465 @@
/**
* SDK Iframe Core
* Chạy trong hidden iframe riêng biệt
*
* Responsibilities:
* - Receive data từ parent (preview mode)
* - Fetch data từ API (live mode)
* - Store answers securely
* - Verify answers locally (fast feedback)
* - Sync với server (background)
* - Report results
*/
import { checkAnswer, sanitizeForClient, GAME_CODES } from '../kit/GameDataHandler';
import { getMockData } from '../client/MockData';
import { SDK_MESSAGE_TYPES, createSdkMessage, isSdkMessage, } from './types';
// =============================================================================
// SDK IFRAME CORE
// =============================================================================
export class SdkIframeCore {
constructor(config = {}) {
this.boundMessageHandler = null;
this.mode = null;
this.gameCode = null;
this.assignmentId = null;
this.studentId = null;
this.apiBaseUrl = '';
this.authToken = '';
// Data storage
this.originalItems = new Map(); // Có đáp án
this.clientItems = new Map(); // Shuffled (cho Client)
this.sanitizedItems = [];
this.userAnswers = new Map();
this.completedQuestions = new Set();
this.isInitialized = false;
this.parentOrigin = null;
this.config = {
debug: config.debug ?? false,
allowedOrigins: config.allowedOrigins ?? ['*'],
};
this.setupMessageListener();
this.log('info', 'SDK Iframe Core initialized');
}
// ==========================================================================
// MESSAGE HANDLING
// ==========================================================================
setupMessageListener() {
this.boundMessageHandler = this.handleMessage.bind(this);
window.addEventListener('message', this.boundMessageHandler);
}
handleMessage(event) {
// Validate origin
if (!this.isAllowedOrigin(event.origin)) {
this.log('warn', `Blocked message from unauthorized origin: ${event.origin}`);
return;
}
const data = event.data;
if (!isSdkMessage(data)) {
return;
}
this.log('debug', `Received: ${data.type}`, data.payload);
// Store parent origin for responses
if (!this.parentOrigin) {
this.parentOrigin = event.origin;
}
switch (data.type) {
case SDK_MESSAGE_TYPES.SDK_INIT:
this.handleInit(data.payload, data.request_id);
break;
case SDK_MESSAGE_TYPES.SDK_PUSH_DATA:
this.handlePushData(data.payload, data.request_id);
break;
case SDK_MESSAGE_TYPES.SDK_CHECK_ANSWER:
this.handleCheckAnswer(data.payload, data.request_id);
break;
case SDK_MESSAGE_TYPES.SDK_GET_RESULT:
this.handleGetResult(data.request_id);
break;
case SDK_MESSAGE_TYPES.SDK_RETRY_SYNC:
this.handleRetrySync(data.payload, data.request_id);
break;
default:
this.log('warn', `Unknown message type: ${data.type}`);
}
}
isAllowedOrigin(origin) {
if (this.config.allowedOrigins.includes('*')) {
return true;
}
return this.config.allowedOrigins.includes(origin);
}
// ==========================================================================
// MESSAGE HANDLERS
// ==========================================================================
async handleInit(payload, requestId) {
try {
this.mode = payload.mode;
this.gameCode = payload.game_code;
this.assignmentId = payload.assignment_id ?? null;
this.studentId = payload.student_id ?? null;
this.apiBaseUrl = payload.api_base_url ?? '';
this.authToken = payload.auth_token ?? '';
// Validate game code
if (!GAME_CODES[this.gameCode]) {
throw new Error(`Invalid game code: ${this.gameCode}`);
}
this.isInitialized = true;
// Send ready
this.sendToParent(SDK_MESSAGE_TYPES.SDK_READY, {
mode: this.mode,
game_code: this.gameCode,
}, requestId);
this.log('info', `Initialized: mode=${this.mode}, game_code=${this.gameCode}`);
// Mode handling
if (this.mode === 'live' && this.assignmentId) {
await this.fetchLiveData();
}
else if (this.mode === 'dev') {
const mockData = getMockData(this.gameCode);
if (mockData) {
this.log('info', `[Dev] Loaded mock data for ${this.gameCode}`);
this.processData(mockData.data);
}
else {
this.sendError('MOCK_NOT_FOUND', `No mock data found for ${this.gameCode}`, requestId);
}
}
}
catch (error) {
this.sendError('INIT_ERROR', error.message, requestId);
}
}
handlePushData(payload, requestId) {
if (!this.isInitialized) {
this.sendError('NOT_INITIALIZED', 'SDK not initialized. Call SDK_INIT first.', requestId);
return;
}
if (this.mode !== 'preview') {
this.sendError('INVALID_MODE', 'SDK_PUSH_DATA only allowed in preview mode', requestId);
return;
}
try {
this.processData(payload.data, payload.completed_question_ids);
}
catch (error) {
this.sendError('DATA_ERROR', error.message, requestId);
}
}
async handleCheckAnswer(payload, requestId) {
if (!this.isInitialized || this.originalItems.size === 0) {
this.sendError('NOT_READY', 'No data loaded', requestId);
return;
}
const { question_id, choice, time_spent = 0 } = payload;
// ===== RESOLVE CHOICE BY GAME CODE =====
// Each game code has its own resolution logic
const code = this.gameCode;
const clientItem = this.clientItems.get(question_id);
let answerValue = choice; // Default: use raw choice
switch (code) {
// ===== QUIZ VARIANTS (G001-G005): Client sends INDEX -> resolve to VALUE =====
case 'G001': // Quiz Text-Text
case 'G002': // Quiz Audio-Text
case 'G003': // Quiz Text-Audio
case 'G004': // Quiz Image-Text
case 'G005': // Quiz Text-Image
// Sanitizers store __shuffledOptions which contains resolved values (strings)
if (clientItem && typeof choice === 'number') {
const shuffled = clientItem.__shuffledOptions;
if (Array.isArray(shuffled) && choice >= 0 && choice < shuffled.length) {
answerValue = shuffled[choice];
this.log('debug', `[${code}] Resolved: index ${choice} -> "${answerValue}"`);
}
}
break;
// ===== SEQUENCE WORD VARIANTS (G110-G113) =====
case 'G110': // Sequence Word
case 'G111': // Sequence Word Audio
case 'G112': // Sequence Word Audio
case 'G113': // Sequence Word Audio
// ===== SEQUENCE SENTENCE VARIANTS (G120-G123) =====
case 'G120': // Sequence Sentence
case 'G121': // Sequence Sentence Audio
case 'G122': // Sequence Sentence Audio
case 'G123': // Sequence Sentence Audio
// Client gửi mảng chữ đã sắp xếp ["H","E","L","L","O"]
// Không cần resolve - so trực tiếp với answer
this.log('debug', `[${code}] Answer array: ${JSON.stringify(choice)}`);
break;
default:
this.log('warn', `Unknown game code: ${code}, using raw choice`);
}
// Get original item
const originalItem = this.originalItems.get(question_id);
if (!originalItem) {
this.sendError('QUESTION_NOT_FOUND', `Question ${question_id} not found`, requestId);
return;
}
// =====================================================================
// LOCAL VERIFY (Preview / Dev): Verify locally, return immediately
// =====================================================================
if (this.mode === 'preview' || this.mode === 'dev') {
// Verify using RESOLVED VALUE (answerValue)
const result = checkAnswer(this.gameCode, originalItem, answerValue);
// Store answer
const storedAnswer = {
question_id,
choice,
answer_value: answerValue, // Store resolved value for retry
correct: result.isCorrect,
score: result.score,
synced: true, // Local (preview/dev) = always synced
time_spent,
};
this.userAnswers.set(question_id, storedAnswer);
// Send result immediately
this.sendToParent(SDK_MESSAGE_TYPES.SDK_ANSWER_RESULT, {
question_id,
correct: result.isCorrect,
score: result.score,
synced: true,
}, requestId);
this.log('info', `[Local] Answer checked locally (${this.mode}): ${question_id} = ${result.isCorrect}`);
return;
}
// =====================================================================
// LIVE MODE: Submit to server, wait for response, then return result
// =====================================================================
try {
// Submit RESOLVED VALUE (answerValue) to server
const serverResult = await this.submitAnswerToServer(question_id, answerValue, time_spent);
// Store answer with server result
const storedAnswer = {
question_id,
choice,
answer_value: answerValue, // Store resolved value for retry
correct: serverResult.correct,
score: serverResult.score,
synced: true,
time_spent,
};
this.userAnswers.set(question_id, storedAnswer);
// Send result from server
this.sendToParent(SDK_MESSAGE_TYPES.SDK_ANSWER_RESULT, {
question_id,
correct: serverResult.correct,
score: serverResult.score,
synced: true,
}, requestId);
this.log('info', `[Live] Answer verified by server: ${question_id} = ${serverResult.correct}`);
}
catch (error) {
// Server error - send error to game
this.sendError('SUBMIT_ERROR', error.message || 'Failed to submit answer', requestId);
this.log('error', `[Live] Failed to submit answer: ${question_id}`, error);
}
}
/**
* Submit answer to server and wait for result (Live mode)
*/
async submitAnswerToServer(questionId, choice, timeSpent) {
if (!this.apiBaseUrl || !this.assignmentId) {
throw new Error('Missing apiBaseUrl or assignmentId');
}
const url = `${this.apiBaseUrl}/submissions/live/answer`;
const headers = {
'Content-Type': 'application/json',
};
if (this.authToken) {
headers['Authorization'] = `Bearer ${this.authToken}`;
}
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({
assignment_id: this.assignmentId,
student_id: this.studentId,
question_id: questionId,
selected_answer: choice,
time_spent: timeSpent,
is_timeout: false,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `Server error: ${response.status}`);
}
const result = await response.json();
return {
correct: result.correct ?? false,
score: result.score ?? (result.correct ? 1 : 0),
};
}
handleGetResult(requestId) {
const details = Array.from(this.userAnswers.values()).map(answer => ({
question_id: answer.question_id,
choice: answer.choice,
correct: answer.correct,
synced: answer.synced,
time_spent: answer.time_spent,
}));
const correct = details.filter(d => d.correct).length;
const total = this.originalItems.size;
const allSynced = details.every(d => d.synced);
this.sendToParent(SDK_MESSAGE_TYPES.SDK_FINAL_RESULT, {
score: total > 0 ? Math.round((correct / total) * 100) : 0,
total,
correct,
wrong: total - correct,
synced: allSynced,
details,
}, requestId);
}
async handleRetrySync(payload, requestId) {
const answer = this.userAnswers.get(payload.question_id);
if (!answer) {
this.sendError('ANSWER_NOT_FOUND', `Answer for ${payload.question_id} not found`, requestId);
return;
}
if (answer.synced) {
// Already synced
this.sendToParent(SDK_MESSAGE_TYPES.SDK_SYNC_STATUS, {
question_id: payload.question_id,
synced: true,
}, requestId);
return;
}
// Retry submit to server - use RESOLVED value, not raw choice
try {
const serverResult = await this.submitAnswerToServer(answer.question_id, answer.answer_value, // Use resolved value!
answer.time_spent);
// Update stored answer
answer.correct = serverResult.correct;
answer.score = serverResult.score;
answer.synced = true;
// Send success
this.sendToParent(SDK_MESSAGE_TYPES.SDK_SYNC_STATUS, {
question_id: payload.question_id,
synced: true,
server_result: serverResult,
}, requestId);
}
catch (error) {
this.sendError('RETRY_ERROR', error.message || 'Retry failed', requestId);
}
}
// ==========================================================================
// DATA PROCESSING
// ==========================================================================
processData(items, completedIds) {
// Store original items (with answers)
this.originalItems.clear();
items.forEach(item => {
if (item.id) {
this.originalItems.set(item.id, item);
}
});
// Track completed questions
this.completedQuestions.clear();
if (completedIds) {
completedIds.forEach(q => this.completedQuestions.add(q.id));
}
// Sanitize for game (remove answers)
this.sanitizedItems = sanitizeForClient(this.gameCode, items);
// Store sanitized items for lookup (to map choice index -> value)
this.clientItems.clear();
this.sanitizedItems.forEach(item => {
if (item.id)
this.clientItems.set(item.id, item);
});
// Send data ready
this.sendToParent(SDK_MESSAGE_TYPES.SDK_DATA_READY, {
items: this.sanitizedItems,
total_questions: items.length,
completed_count: this.completedQuestions.size,
resume_data: completedIds,
});
this.log('info', `Data processed: ${items.length} items, ${this.completedQuestions.size} completed`);
}
// ==========================================================================
// API CALLS
// ==========================================================================
async fetchLiveData() {
if (!this.apiBaseUrl || !this.assignmentId) {
this.sendError('CONFIG_ERROR', 'Missing apiBaseUrl or assignmentId for live mode');
return;
}
try {
const url = `${this.apiBaseUrl}/games/${this.assignmentId}`;
const headers = {
'Content-Type': 'application/json',
};
if (this.authToken) {
headers['Authorization'] = `Bearer ${this.authToken}`;
}
this.log('info', `Fetching live data: ${url}`);
const response = await fetch(url, { headers });
if (!response.ok) {
throw new Error(`API Error: ${response.status}`);
}
const data = await response.json();
// Extract items from response
const items = data.data || data.items || data.questions || [];
const completedIds = data.completed_question_ids || [];
this.processData(items, completedIds);
}
catch (error) {
this.log('error', 'Failed to fetch live data', error);
this.sendError('FETCH_ERROR', error.message);
}
}
// ==========================================================================
// HELPERS
// ==========================================================================
sendToParent(type, payload, requestId) {
const message = createSdkMessage(type, payload, requestId);
// NOTE: Khi chạy local file://, event.origin có thể là "null" (string) => postMessage sẽ throw nếu dùng làm targetOrigin
const origin = this.parentOrigin;
const targetOrigin = (!origin || origin === 'null') ? '*' : origin;
window.parent.postMessage(message, targetOrigin);
this.log('debug', `Sent: ${type}`, payload);
}
sendError(code, message, requestId) {
this.sendToParent(SDK_MESSAGE_TYPES.SDK_ERROR, {
code,
message,
}, requestId);
this.log('error', `Error: ${code} - ${message}`);
}
log(level, message, data) {
if (!this.config.debug && level === 'debug')
return;
const prefix = '[SdkIframe]';
switch (level) {
case 'debug':
case 'info':
console.log(prefix, message, data ?? '');
break;
case 'warn':
console.warn(prefix, message, data ?? '');
break;
case 'error':
console.error(prefix, message, data ?? '');
break;
}
}
// ==========================================================================
// CLEANUP
// ==========================================================================
destroy() {
if (this.boundMessageHandler) {
window.removeEventListener('message', this.boundMessageHandler);
this.boundMessageHandler = null;
}
this.originalItems.clear();
this.userAnswers.clear();
this.log('info', 'SDK Iframe destroyed');
}
}
// Auto-init khi script được load
if (typeof window !== 'undefined') {
window.SdkIframe = new SdkIframeCore({
debug: new URLSearchParams(window.location.search).get('debug') === 'true',
});
}
//# sourceMappingURL=SdkIframeCore.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,11 @@
/**
* SDK Iframe exports
*/
import { SdkIframeCore } from './SdkIframeCore';
export * from './types';
export { SdkIframeCore };
// Export to window for direct browser usage
if (typeof window !== 'undefined') {
window.SdkIframeCore = SdkIframeCore;
}
//# sourceMappingURL=index.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/sdk-iframe/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAEhD,cAAc,SAAS,CAAC;AACxB,OAAO,EAAE,aAAa,EAAE,CAAC;AAEzB,4CAA4C;AAC5C,IAAI,OAAO,MAAM,KAAK,WAAW,EAAE,CAAC;IAC/B,MAAc,CAAC,aAAa,GAAG,aAAa,CAAC;AAClD,CAAC"}

View File

@@ -0,0 +1,38 @@
/**
* SDK Iframe Message Types
* Shared types cho SDK Iframe và Game Bridge
*/
// =============================================================================
// MESSAGE TYPES
// =============================================================================
export const SDK_MESSAGE_TYPES = {
// Parent/Game → SDK Iframe
SDK_INIT: 'SDK_INIT',
SDK_PUSH_DATA: 'SDK_PUSH_DATA',
SDK_CHECK_ANSWER: 'SDK_CHECK_ANSWER',
SDK_GET_RESULT: 'SDK_GET_RESULT',
SDK_RETRY_SYNC: 'SDK_RETRY_SYNC',
// SDK Iframe → Parent/Game
SDK_READY: 'SDK_READY',
SDK_DATA_READY: 'SDK_DATA_READY',
SDK_ANSWER_RESULT: 'SDK_ANSWER_RESULT',
SDK_SYNC_STATUS: 'SDK_SYNC_STATUS',
SDK_SYNC_ERROR: 'SDK_SYNC_ERROR',
SDK_FINAL_RESULT: 'SDK_FINAL_RESULT',
SDK_ERROR: 'SDK_ERROR',
};
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
export function createSdkMessage(type, payload, requestId) {
return {
type,
payload,
timestamp: Date.now(),
request_id: requestId,
};
}
export function isSdkMessage(data) {
return data && typeof data === 'object' && 'type' in data && Object.values(SDK_MESSAGE_TYPES).includes(data.type);
}
//# sourceMappingURL=types.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../../src/sdk-iframe/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,gFAAgF;AAChF,gBAAgB;AAChB,gFAAgF;AAEhF,MAAM,CAAC,MAAM,iBAAiB,GAAG;IAC7B,2BAA2B;IAC3B,QAAQ,EAAE,UAAU;IACpB,aAAa,EAAE,eAAe;IAC9B,gBAAgB,EAAE,kBAAkB;IACpC,cAAc,EAAE,gBAAgB;IAChC,cAAc,EAAE,gBAAgB;IAEhC,2BAA2B;IAC3B,SAAS,EAAE,WAAW;IACtB,cAAc,EAAE,gBAAgB;IAChC,iBAAiB,EAAE,mBAAmB;IACtC,eAAe,EAAE,iBAAiB;IAClC,cAAc,EAAE,gBAAgB;IAChC,gBAAgB,EAAE,kBAAkB;IACpC,SAAS,EAAE,WAAW;CAChB,CAAC;AAyGX,gFAAgF;AAChF,mBAAmB;AACnB,gFAAgF;AAEhF,MAAM,UAAU,gBAAgB,CAAI,IAAoB,EAAE,OAAU,EAAE,SAAkB;IACpF,OAAO;QACH,IAAI;QACJ,OAAO;QACP,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE;QACrB,UAAU,EAAE,SAAS;KACxB,CAAC;AACN,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,IAAS;IAClC,OAAO,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,MAAM,IAAI,IAAI,IAAI,MAAM,CAAC,MAAM,CAAC,iBAAiB,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACtH,CAAC"}