scorm-again
Version:
A modern SCORM JavaScript run-time library for SCORM 1.2 and SCORM 2004
172 lines (170 loc) • 5.6 kB
JavaScript
class CrossFrameLMS {
_api;
_origin;
_rateLimit;
_requestTimes = [];
_destroyed = false;
_boundOnMessage;
/**
* Strict allowlist of methods that can be invoked via cross-frame messages.
* Only SCORM API methods and internal helpers are permitted.
*/
static ALLOWED_METHODS = /* @__PURE__ */ new Set([
// SCORM 1.2 methods
"LMSInitialize",
"LMSFinish",
"LMSGetValue",
"LMSSetValue",
"LMSCommit",
"LMSGetLastError",
"LMSGetErrorString",
"LMSGetDiagnostic",
// SCORM 2004 methods
"Initialize",
"Terminate",
"GetValue",
"SetValue",
"Commit",
"GetLastError",
"GetErrorString",
"GetDiagnostic",
// Internal method for cache warming
"getFlattenedCMI"
]);
/**
* Creates a new CrossFrameLMS instance.
* @param api - The SCORM API instance to delegate calls to
* @param targetOrigin - Origin to accept messages from. Default "*" accepts all origins.
* @param options - Configuration options
*/
constructor(api, targetOrigin = "*", options = {}) {
this._api = api;
this._origin = targetOrigin;
this._rateLimit = options.rateLimit ?? 100;
if (targetOrigin === "*") {
console.warn(
"CrossFrameLMS: Using wildcard origin ('*') allows any origin to send messages. This is insecure for production use. Specify an explicit origin (e.g., 'https://content.example.com') to restrict message sources."
);
}
this._boundOnMessage = this._onMessage.bind(this);
window.addEventListener("message", this._boundOnMessage);
}
/**
* 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);
this._requestTimes.length = 0;
}
/**
* Checks if the rate limit has been exceeded.
* Uses a sliding window of 1 second.
* @returns true if rate limit exceeded, false otherwise
*/
_isRateLimited() {
const now = Date.now();
this._requestTimes = this._requestTimes.filter((t) => now - t < 1e3);
if (this._requestTimes.length >= this._rateLimit) {
return true;
}
this._requestTimes.push(now);
return false;
}
/**
* Type guard to validate MessageData structure
*/
static _isValidMessageData(data) {
if (typeof data !== "object" || data === null) return false;
const msg = data;
if (typeof msg.messageId !== "string" || msg.messageId.length === 0) return false;
if (typeof msg.method !== "string" || msg.method.length === 0) return false;
if (msg.params !== void 0 && !Array.isArray(msg.params)) return false;
if (msg.isHeartbeat !== void 0 && typeof msg.isHeartbeat !== "boolean") return false;
return true;
}
/**
* Handles incoming postMessage events from child frames.
*/
_onMessage(ev) {
if (this._destroyed) return;
if (this._origin !== "*" && ev.origin !== this._origin) {
return;
}
if (!CrossFrameLMS._isValidMessageData(ev.data)) return;
const msg = ev.data;
if (!ev.source) return;
if (!("postMessage" in ev.source)) return;
const source = ev.source;
if (msg.isHeartbeat) {
const resp = {
messageId: msg.messageId,
isHeartbeat: true
};
source.postMessage(resp, this._origin);
return;
}
if (this._isRateLimited()) {
const resp = {
messageId: msg.messageId,
error: { message: "Rate limit exceeded", code: "101" }
};
source.postMessage(resp, this._origin);
return;
}
if (!CrossFrameLMS.ALLOWED_METHODS.has(msg.method)) {
const resp = {
messageId: msg.messageId,
error: { message: `Method not allowed: ${msg.method}`, code: "101" }
};
source.postMessage(resp, this._origin);
return;
}
this._process(msg, source);
}
/**
* Processes a validated message by invoking the requested API method.
*/
_process(msg, source) {
const sendResponse = (result, error) => {
const resp = { messageId: msg.messageId };
if (result !== void 0) resp.result = result;
if (error !== void 0) resp.error = error;
source.postMessage(resp, this._origin);
};
try {
const fn = this._api[msg.method];
if (typeof fn !== "function") {
sendResponse(void 0, { message: `Method ${msg.method} not found` });
return;
}
const params = Array.isArray(msg.params) ? msg.params : [];
const result = fn.apply(this._api, params);
if (result && typeof result.then === "function") {
result.then((r) => sendResponse(r)).catch((e) => {
const message = e instanceof Error ? e.message : "Unknown error";
const code = e && typeof e === "object" && "code" in e && typeof e.code === "string" ? e.code : void 0;
const errorObj = { message };
if (code !== void 0) {
errorObj.code = code;
}
sendResponse(void 0, errorObj);
});
} else {
sendResponse(result);
}
} catch (e) {
const message = e instanceof Error ? e.message : "Unknown error";
const code = e && typeof e === "object" && "code" in e && typeof e.code === "string" ? e.code : void 0;
const errorObj = { message };
if (code !== void 0) {
errorObj.code = code;
}
sendResponse(void 0, errorObj);
}
}
}
export { CrossFrameLMS };
//# sourceMappingURL=cross-frame-lms.js.map