UNPKG

scorm-again

Version:

A modern SCORM JavaScript run-time library for SCORM 1.2 and SCORM 2004

348 lines (343 loc) 13.2 kB
this.CrossFrameAPI = (function () { 'use strict'; const global_errors = { GENERAL: 101}; var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); class CrossFrameAPI { /** * Creates a new CrossFrameAPI instance. * @param targetOrigin - Origin to send messages to. Default "*" sends to any origin. * @param targetWindow - Window to send messages to. Default is window.parent. * @param options - Configuration options */ constructor() { let targetOrigin = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : "*"; let targetWindow = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : window.parent; let options = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; __publicField(this, "_cache", /* @__PURE__ */new Map()); __publicField(this, "_cacheTimestamps", /* @__PURE__ */new Map()); __publicField(this, "_lastError", "0"); __publicField(this, "_pending", /* @__PURE__ */new Map()); __publicField(this, "_counter", 0); __publicField(this, "_origin"); __publicField(this, "_targetWindow"); __publicField(this, "_timeout"); __publicField(this, "_heartbeatInterval"); __publicField(this, "_heartbeatTimeout"); __publicField(this, "_destroyed", false); __publicField(this, "_connected", true); __publicField(this, "_lastHeartbeatResponse", Date.now()); __publicField(this, "_heartbeatTimer"); __publicField(this, "_eventListeners", /* @__PURE__ */new Map()); __publicField(this, "_boundOnMessage"); __publicField(this, "_handler", { get: (target, prop, receiver) => { if (typeof prop !== "string" || prop in target) { const v = Reflect.get(target, prop, receiver); return typeof v === "function" ? v.bind(target) : v; } const methodName = prop; const isGet = methodName.endsWith("GetValue"); const isSet = methodName.startsWith("LMSSet") || methodName.endsWith("SetValue"); const isInit = methodName === "Initialize" || methodName === "LMSInitialize"; const isFinish = methodName === "Terminate" || methodName === "LMSFinish"; const isCommit = methodName === "Commit" || methodName === "LMSCommit"; const isErrorString = methodName === "GetErrorString" || methodName === "LMSGetErrorString"; const isDiagnostic = methodName === "GetDiagnostic" || methodName === "LMSGetDiagnostic"; return function () { for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { args[_key] = arguments[_key]; } if (!CrossFrameAPI._validateArgs(args)) { console.error(`CrossFrameAPI: Invalid arguments for ${methodName}`); return ""; } if (isSet && args.length >= 2) { const key = args[0]; target._cache.set(key, String(args[1])); target._cacheTimestamps.set(key, Date.now()); target._lastError = "0"; } const requestTime = Date.now(); target._post(methodName, args).then(res => { if (isGet && args.length >= 1) { const key = args[0]; const localModTime = target._cacheTimestamps.get(key) ?? 0; if (localModTime < requestTime) { target._cache.set(key, String(res)); target._cacheTimestamps.delete(key); } target._lastError = "0"; } if (isErrorString && args.length >= 1) { const code = String(args[0]); target._cache.set(`error_${code}`, String(res)); } if (isDiagnostic && args.length >= 1) { const code = String(args[0]); target._cache.set(`diag_${code}`, String(res)); } if (methodName === "GetLastError" || methodName === "LMSGetLastError") { target._lastError = String(res); } }).catch(err => target._capture(methodName, err)); if (isGet && args.length >= 1) { return target._cache.get(args[0]) ?? ""; } if (isErrorString && args.length >= 1) { const code = String(args[0]); return target._cache.get(`error_${code}`) ?? ""; } if (isDiagnostic && args.length >= 1) { const code = String(args[0]); return target._cache.get(`diag_${code}`) ?? ""; } if (isInit || isFinish || isCommit || isSet) { const result = "true"; target._post("getFlattenedCMI", []).then(all => { if (all && typeof all === "object") { const entries = Object.entries(all); entries.forEach(_ref => { let [key, val] = _ref; const localModTime = target._cacheTimestamps.get(key) ?? 0; if (localModTime < requestTime) { target._cache.set(key, val); target._cacheTimestamps.delete(key); } }); } target._lastError = "0"; }).catch(err => target._capture("getFlattenedCMI", err)); return result; } if (methodName === "GetLastError" || methodName === "LMSGetLastError") { return target._lastError; } return ""; }; } }); this._origin = targetOrigin; this._targetWindow = targetWindow; this._timeout = options.timeout ?? 5e3; this._heartbeatInterval = options.heartbeatInterval ?? 3e4; this._heartbeatTimeout = options.heartbeatTimeout ?? 6e4; if (targetOrigin === "*") { console.warn("CrossFrameAPI: Using wildcard origin ('*') allows any origin to receive messages. This is insecure for production use. Specify an explicit origin (e.g., 'https://lms.example.com') to restrict message recipients."); } this._boundOnMessage = this._onMessage.bind(this); window.addEventListener("message", this._boundOnMessage); this._startHeartbeat(); return new Proxy(this, this._handler); } /** * Type guard to validate MessageResponse structure */ static _isValidMessageResponse(data) { if (typeof data !== "object" || data === null) return false; const resp = data; if (typeof resp.messageId !== "string" || resp.messageId.length === 0) return false; if (resp.error !== void 0) { if (typeof resp.error !== "object" || resp.error === null) return false; const err = resp.error; if (typeof err.message !== "string") return false; if (err.code !== void 0 && typeof err.code !== "string") return false; } if (resp.isHeartbeat !== void 0 && typeof resp.isHeartbeat !== "boolean") return false; return true; } /** * Validates that args is an array and sanitizes it for safe use */ static _validateArgs(args) { if (!Array.isArray(args)) return false; return true; } /** * Destroys this instance, removing event listeners and preventing further message processing. * Once destroyed, the instance cannot be reused. */ destroy() { if (this._destroyed) return; this._destroyed = true; window.removeEventListener("message", this._boundOnMessage); if (this._heartbeatTimer) { clearInterval(this._heartbeatTimer); this._heartbeatTimer = void 0; } for (const pending of Array.from(this._pending.values())) { clearTimeout(pending.timer); pending.reject(new Error("CrossFrameAPI destroyed")); } this._pending.clear(); this._cache.clear(); this._cacheTimestamps.clear(); this._eventListeners.clear(); } /** * Subscribes to a CrossFrame event. * @param event - Event type to listen for * @param callback - Function to call when event occurs */ on(event, callback) { if (!this._eventListeners.has(event)) { this._eventListeners.set(event, /* @__PURE__ */new Set()); } this._eventListeners.get(event)?.add(callback); } /** * Unsubscribes from a CrossFrame event. * @param event - Event type to stop listening for * @param callback - Function to remove */ off(event, callback) { this._eventListeners.get(event)?.delete(callback); } /** * Returns whether the connection to the LMS frame is currently active. */ get connected() { return this._connected; } /** * Emits an event to all registered listeners. */ _emit(event) { this._eventListeners.get(event.type)?.forEach(cb => cb(event)); } /** * Starts the heartbeat mechanism for connection detection. */ _startHeartbeat() { if (this._heartbeatTimer) { clearInterval(this._heartbeatTimer); } this._heartbeatTimer = setInterval(() => { if (this._destroyed) return; const timeSinceLastResponse = Date.now() - this._lastHeartbeatResponse; if (timeSinceLastResponse > this._heartbeatTimeout && this._connected) { this._connected = false; this._emit({ type: "connectionLost" }); } this._sendHeartbeat(); }, this._heartbeatInterval); } /** * Sends a heartbeat ping to the LMS frame. */ _sendHeartbeat() { const messageId = `hb-${Date.now()}-${this._counter++}`; const msg = { messageId, method: "__heartbeat__", params: [], isHeartbeat: true }; this._targetWindow.postMessage(msg, this._origin); } /** * Send a message to the LMS frame and return a promise for its response. */ _post(method, params) { if (this._destroyed) { return Promise.reject(new Error("CrossFrameAPI destroyed")); } const messageId = `cfapi-${Date.now()}-${this._counter++}`; const requestTime = Date.now(); const safeParams = params.map(p => { if (typeof p === "function") { console.warn("Dropping function param when posting SCORM call:", method); return void 0; } return p; }); return new Promise((resolve, reject) => { const timer = setTimeout(() => { if (this._pending.has(messageId)) { this._pending.delete(messageId); reject(new Error(`Timeout calling ${method}`)); } }, this._timeout); this._pending.set(messageId, { resolve, reject, timer, requestTime, method }); const msg = { messageId, method, params: safeParams }; this._targetWindow.postMessage(msg, this._origin); }); } /** * Handle incoming postMessage responses from the LMS frame. */ _onMessage(ev) { if (this._destroyed) return; if (this._origin !== "*" && ev.origin !== this._origin) { return; } if (ev.source && ev.source !== this._targetWindow) { return; } if (!CrossFrameAPI._isValidMessageResponse(ev.data)) return; const data = ev.data; if (data.isHeartbeat) { this._lastHeartbeatResponse = Date.now(); if (!this._connected) { this._connected = true; this._emit({ type: "connectionRestored" }); } return; } const pending = this._pending.get(data.messageId); if (!pending) return; clearTimeout(pending.timer); this._pending.delete(data.messageId); if (data.error) { if (data.error.message === "Rate limit exceeded") { this._emit({ type: "rateLimited", method: pending.method }); } pending.reject(data.error); } else { pending.resolve(data.result); } } /** * Capture and cache SCORM errors. */ _capture(method, err) { let errorMessage = "Unknown error"; if (err instanceof Error) { errorMessage = err.message; } else if (typeof err === "object" && err !== null && "message" in err) { errorMessage = String(err.message); } console.error(`CrossFrameAPI ${method} error:`, err); const match = /(?:error code|code)?\s*(\d{3})\b/i.exec(errorMessage); const code = match?.[1] ?? String(global_errors.GENERAL); this._lastError = code; this._cache.set(`error_${code}`, errorMessage); } } return CrossFrameAPI; })(); //# sourceMappingURL=cross-frame-api.js.map