scorm-again 3.1.4 → 3.2.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.
Files changed (43) hide show
  1. package/README.md +3 -1
  2. package/dist/cross-frame-api.js +6 -13
  3. package/dist/cross-frame-api.js.map +1 -1
  4. package/dist/cross-frame-api.min.js +1 -1
  5. package/dist/cross-frame-lms.js +2 -6
  6. package/dist/cross-frame-lms.js.map +1 -1
  7. package/dist/esm/scorm-again.js +1128 -198
  8. package/dist/esm/scorm-again.js.map +1 -1
  9. package/dist/esm/scorm-again.min.js +1 -1
  10. package/dist/esm/scorm-again.min.js.map +1 -1
  11. package/dist/esm/scorm12.js +60 -13
  12. package/dist/esm/scorm12.js.map +1 -1
  13. package/dist/esm/scorm12.min.js +1 -1
  14. package/dist/esm/scorm12.min.js.map +1 -1
  15. package/dist/esm/scorm2004.js +1128 -198
  16. package/dist/esm/scorm2004.js.map +1 -1
  17. package/dist/esm/scorm2004.min.js +1 -1
  18. package/dist/esm/scorm2004.min.js.map +1 -1
  19. package/dist/scorm-again.js +2088 -1465
  20. package/dist/scorm-again.js.map +1 -1
  21. package/dist/scorm-again.min.js +1 -1
  22. package/dist/scorm12.js +347 -420
  23. package/dist/scorm12.js.map +1 -1
  24. package/dist/scorm12.min.js +1 -1
  25. package/dist/scorm2004.js +1983 -1301
  26. package/dist/scorm2004.js.map +1 -1
  27. package/dist/scorm2004.min.js +1 -1
  28. package/dist/types/BaseAPI.d.ts +1 -1
  29. package/dist/types/Scorm2004API.d.ts +11 -0
  30. package/dist/types/cmi/scorm2004/sequencing/activity.d.ts +47 -5
  31. package/dist/types/cmi/scorm2004/sequencing/handlers/rte_data_transfer.d.ts +1 -0
  32. package/dist/types/cmi/scorm2004/sequencing/objectives/global_objective_synchronizer.d.ts +17 -1
  33. package/dist/types/cmi/scorm2004/sequencing/sequencing_rules.d.ts +6 -1
  34. package/dist/types/cmi/scorm2004/sequencing/traversal/flow_traversal_service.d.ts +5 -2
  35. package/dist/types/constants/regex.d.ts +1 -0
  36. package/dist/types/interfaces/services.d.ts +1 -1
  37. package/dist/types/services/AsynchronousHttpService.d.ts +1 -0
  38. package/dist/types/services/ErrorHandlingService.d.ts +4 -4
  39. package/dist/types/services/SynchronousHttpService.d.ts +1 -0
  40. package/dist/types/types/api_types.d.ts +16 -0
  41. package/dist/types/types/sequencing_types.d.ts +7 -0
  42. package/dist/types/utilities/core.d.ts +6 -1
  43. package/package.json +4 -4
@@ -1,5 +1,22 @@
1
1
  const SECONDS_PER_MINUTE = 60;
2
2
  const SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE;
3
+ const CORS_SAFELISTED_CONTENT_TYPES = [
4
+ "text/plain",
5
+ "application/x-www-form-urlencoded",
6
+ "multipart/form-data"
7
+ ];
8
+ function isCorsSafelistedContentType(contentType) {
9
+ const essence = ((contentType || "").split(";")[0] ?? "").trim().toLowerCase();
10
+ return CORS_SAFELISTED_CONTENT_TYPES.includes(essence);
11
+ }
12
+ function isCrossOriginUrl(url) {
13
+ if (typeof location === "undefined" || !location || !location.origin) return false;
14
+ try {
15
+ return new URL(url, location.href).origin !== location.origin;
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
3
20
  const getSecondsAsHHMMSS = (totalSeconds) => {
4
21
  if (!totalSeconds || totalSeconds <= 0) {
5
22
  return "00:00:00";
@@ -149,12 +166,18 @@ function stringMatches(str, tester) {
149
166
  }
150
167
  return new RegExp(tester).test(str);
151
168
  }
152
- function memoize(fn, keyFn) {
169
+ function memoize(fn, keyFn, options) {
153
170
  const cache = /* @__PURE__ */ new Map();
154
171
  return ((...args) => {
155
172
  const key = keyFn ? keyFn(...args) : JSON.stringify(args);
173
+ if (options?.maxKeyLength !== void 0 && key.length > options.maxKeyLength) {
174
+ return fn(...args);
175
+ }
156
176
  return cache.has(key) ? cache.get(key) : (() => {
157
177
  const result = fn(...args);
178
+ if (options?.maxEntries !== void 0 && cache.size >= options.maxEntries) {
179
+ cache.delete(cache.keys().next().value);
180
+ }
158
181
  cache.set(key, result);
159
182
  return result;
160
183
  })();
@@ -524,8 +547,10 @@ const DefaultSettings = {
524
547
  lmsCommitUrl: false,
525
548
  dataCommitFormat: "json",
526
549
  commitRequestDataType: "application/json;charset=UTF-8",
550
+ terminationCommitContentType: "text/plain;charset=UTF-8",
527
551
  autoProgress: false,
528
552
  logLevel: LogLevelEnum.ERROR,
553
+ uninitializedGetLogLevel: LogLevelEnum.WARN,
529
554
  selfReportSessionTime: false,
530
555
  alwaysSendTotalTime: false,
531
556
  renderCommonCommitFields: false,
@@ -819,6 +844,8 @@ const scorm2004_regex = {
819
844
  CMILang: "^([a-zA-Z]{1,8}|i|x)(-[a-zA-Z0-9-]{2,8})?$|^$",
820
845
  /** CMILangString250 - String with optional language tag, max 250 chars (RTE C.1.3) */
821
846
  CMILangString250: "^({lang=([a-zA-Z]{1,8}|i|x)(-[a-zA-Z0-9-]{2,8})?})?((?!{.*$).{0,250}$)?$",
847
+ /** CMILangString - Optional language tag, no length cap; RTE C.1.3 SPM is a floor and is deliberately not enforced */
848
+ CMILangString: "^({lang=([a-zA-Z]{1,8}|i|x)(-[a-zA-Z0-9-]{2,8})?})?((?!{.*$).*$)?$",
822
849
  /** CMILangcr - Language tag pattern with content */
823
850
  CMILangcr: "^(({lang=([a-zA-Z]{1,8}|i|x)?(-[a-zA-Z0-9-]{2,8})?}))(.*?)$",
824
851
  /** CMILangString250cr - String with optional language tag (carriage return variant) */
@@ -1053,7 +1080,9 @@ class AsynchronousHttpService {
1053
1080
  */
1054
1081
  async performBeacon(url, params) {
1055
1082
  const { body, contentType } = this._prepareRequestBody(params);
1056
- const beaconSuccess = navigator.sendBeacon(url, new Blob([body], { type: contentType }));
1083
+ const beaconContentType = Array.isArray(params) ? contentType : this.settings.terminationCommitContentType;
1084
+ this._warnIfBeaconContentTypeUnsafe(url, beaconContentType);
1085
+ const beaconSuccess = navigator.sendBeacon(url, new Blob([body], { type: beaconContentType }));
1057
1086
  return Promise.resolve({
1058
1087
  status: beaconSuccess ? 200 : 0,
1059
1088
  ok: beaconSuccess,
@@ -1067,6 +1096,14 @@ class AsynchronousHttpService {
1067
1096
  })
1068
1097
  });
1069
1098
  }
1099
+ _warnIfBeaconContentTypeUnsafe(url, contentType) {
1100
+ if (isCrossOriginUrl(url) && !isCorsSafelistedContentType(contentType)) {
1101
+ this.settings.onLogMessage?.(
1102
+ LogLevelEnum.WARN,
1103
+ `sendBeacon to cross-origin URL with non-CORS-safelisted Content-Type "${contentType}" may be silently dropped by the browser (Beacon cannot preflight). Use fetch (useAsynchronousCommits + asyncModeBeaconBehavior:"never") for cross-origin JSON/auth on terminate.`
1104
+ );
1105
+ }
1106
+ }
1070
1107
  /**
1071
1108
  * Transforms the response from the LMS to a ResultObject
1072
1109
  * @param {Response} response - The response from the LMS
@@ -1734,14 +1771,14 @@ class ErrorHandlingService {
1734
1771
  * @param {string} message - The error message
1735
1772
  * @throws {ValidationError} - If throwException is true, throws a ValidationError
1736
1773
  */
1737
- throwSCORMError(CMIElement, errorNumber, message) {
1774
+ throwSCORMError(CMIElement, errorNumber, message, messageLevel = LogLevelEnum.ERROR) {
1738
1775
  this._lastDiagnostic = message || "";
1739
1776
  if (!message) {
1740
1777
  message = this._getLmsErrorMessageDetails(errorNumber, true);
1741
1778
  }
1742
1779
  const formattedMessage = `SCORM Error ${errorNumber}: ${message}${CMIElement ? ` [Element: ${CMIElement}]` : ""}`;
1743
- this._apiLog("throwSCORMError", errorNumber + ": " + message, LogLevelEnum.ERROR, CMIElement);
1744
- this._loggingService.error(formattedMessage);
1780
+ this._apiLog("throwSCORMError", errorNumber + ": " + message, messageLevel, CMIElement);
1781
+ this._loggingService.log(messageLevel, formattedMessage);
1745
1782
  this._lastErrorCode = String(errorNumber);
1746
1783
  }
1747
1784
  /**
@@ -2424,7 +2461,9 @@ const checkValidFormat = memoize(
2424
2461
  (CMIElement, value, regexPattern, errorCode, _errorClass, allowEmptyString) => {
2425
2462
  const valueKey = typeof value === "string" ? value : `[${typeof value}]`;
2426
2463
  return `${CMIElement}:${valueKey}:${regexPattern}:${errorCode}:${allowEmptyString || false}`;
2427
- }
2464
+ },
2465
+ // Normal capped CMI values and regexes fit within 2000 characters; large uncapped values bypass caching.
2466
+ { maxEntries: 1e3, maxKeyLength: 2e3 }
2428
2467
  );
2429
2468
  const checkValidRange = memoize(
2430
2469
  (CMIElement, value, rangePattern, errorCode, errorClass) => {
@@ -2447,7 +2486,8 @@ const checkValidRange = memoize(
2447
2486
  },
2448
2487
  // Custom key function that excludes the error class from the cache key
2449
2488
  // since it can't be stringified and doesn't affect the validation result
2450
- (CMIElement, value, rangePattern, errorCode, _errorClass) => `${CMIElement}:${value}:${rangePattern}:${errorCode}`
2489
+ (CMIElement, value, rangePattern, errorCode, _errorClass) => `${CMIElement}:${value}:${rangePattern}:${errorCode}`,
2490
+ { maxEntries: 1e3 }
2451
2491
  );
2452
2492
 
2453
2493
  class SerializationService {
@@ -2737,15 +2777,22 @@ class SynchronousHttpService {
2737
2777
  const handledPayload = metadata === void 0 ? this.settings.requestHandler(params) : this.settings.requestHandler(params, metadata);
2738
2778
  const requestPayload = handledPayload ?? params;
2739
2779
  const { body } = this._prepareRequestBody(requestPayload);
2740
- const beaconSuccess = navigator.sendBeacon(
2741
- url,
2742
- new Blob([body], { type: "text/plain;charset=UTF-8" })
2743
- );
2780
+ const beaconContentType = this.settings.terminationCommitContentType;
2781
+ this._warnIfBeaconContentTypeUnsafe(url, beaconContentType);
2782
+ const beaconSuccess = navigator.sendBeacon(url, new Blob([body], { type: beaconContentType }));
2744
2783
  return {
2745
2784
  result: beaconSuccess ? "true" : "false",
2746
2785
  errorCode: beaconSuccess ? 0 : this.error_codes.GENERAL_COMMIT_FAILURE || 391
2747
2786
  };
2748
2787
  }
2788
+ _warnIfBeaconContentTypeUnsafe(url, contentType) {
2789
+ if (isCrossOriginUrl(url) && !isCorsSafelistedContentType(contentType)) {
2790
+ this.settings.onLogMessage?.(
2791
+ LogLevelEnum.WARN,
2792
+ `sendBeacon to cross-origin URL with non-CORS-safelisted Content-Type "${contentType}" may be silently dropped by the browser (Beacon cannot preflight). Use fetch (useAsynchronousCommits + asyncModeBeaconBehavior:"never") for cross-origin JSON/auth on terminate.`
2793
+ );
2794
+ }
2795
+ }
2749
2796
  /**
2750
2797
  * Performs a synchronous XMLHttpRequest
2751
2798
  * @param {string} url - The URL to send the request to
@@ -3772,8 +3819,8 @@ class BaseAPI {
3772
3819
  * // Throw a "not initialized" error
3773
3820
  * this.throwSCORMError(301, "The API must be initialized before calling GetValue");
3774
3821
  */
3775
- throwSCORMError(CMIElement, errorNumber, message) {
3776
- this._errorHandlingService.throwSCORMError(CMIElement, errorNumber ?? 0, message);
3822
+ throwSCORMError(CMIElement, errorNumber, message, messageLevel) {
3823
+ this._errorHandlingService.throwSCORMError(CMIElement, errorNumber ?? 0, message, messageLevel);
3777
3824
  }
3778
3825
  /**
3779
3826
  * Clears the last SCORM error code when an operation succeeds.