scorm-again 3.0.4 → 3.1.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 (42) hide show
  1. package/README.md +96 -5
  2. package/dist/cross-frame-api.js.map +1 -1
  3. package/dist/esm/cross-frame-api.js.map +1 -1
  4. package/dist/esm/scorm-again.js +503 -138
  5. package/dist/esm/scorm-again.js.map +1 -1
  6. package/dist/esm/scorm-again.min.js +1 -1
  7. package/dist/esm/scorm-again.min.js.map +1 -1
  8. package/dist/esm/scorm12.js +339 -73
  9. package/dist/esm/scorm12.js.map +1 -1
  10. package/dist/esm/scorm12.min.js +1 -1
  11. package/dist/esm/scorm12.min.js.map +1 -1
  12. package/dist/esm/scorm2004.js +486 -131
  13. package/dist/esm/scorm2004.js.map +1 -1
  14. package/dist/esm/scorm2004.min.js +1 -1
  15. package/dist/esm/scorm2004.min.js.map +1 -1
  16. package/dist/scorm-again.js +443 -119
  17. package/dist/scorm-again.js.map +1 -1
  18. package/dist/scorm-again.min.js +1 -1
  19. package/dist/scorm-again.min.js.map +1 -1
  20. package/dist/scorm12.js +316 -57
  21. package/dist/scorm12.js.map +1 -1
  22. package/dist/scorm12.min.js +1 -1
  23. package/dist/scorm12.min.js.map +1 -1
  24. package/dist/scorm2004.js +431 -112
  25. package/dist/scorm2004.js.map +1 -1
  26. package/dist/scorm2004.min.js +1 -1
  27. package/dist/scorm2004.min.js.map +1 -1
  28. package/dist/types/BaseAPI.d.ts +15 -5
  29. package/dist/types/Scorm12API.d.ts +2 -2
  30. package/dist/types/Scorm2004API.d.ts +3 -2
  31. package/dist/types/cmi/common/score.d.ts +2 -0
  32. package/dist/types/cmi/scorm2004/interaction_delimiters.d.ts +4 -0
  33. package/dist/types/interfaces/services.d.ts +8 -4
  34. package/dist/types/services/AsynchronousHttpService.d.ts +3 -2
  35. package/dist/types/services/EventService.d.ts +2 -2
  36. package/dist/types/services/OfflineStorageService.d.ts +6 -1
  37. package/dist/types/services/SynchronousHttpService.d.ts +2 -2
  38. package/dist/types/services/ValidationService.d.ts +1 -1
  39. package/dist/types/types/api_types.d.ts +22 -2
  40. package/dist/types/utilities/index.d.ts +1 -0
  41. package/dist/types/utilities/url.d.ts +2 -0
  42. package/package.json +46 -25
package/dist/scorm12.js CHANGED
@@ -161,6 +161,15 @@ this.Scorm12API = (function () {
161
161
  };
162
162
  }
163
163
 
164
+ const appendQueryParam = (url, name, value) => {
165
+ const fragmentIndex = url.indexOf("#");
166
+ const baseUrl = fragmentIndex === -1 ? url : url.slice(0, fragmentIndex);
167
+ const fragment = fragmentIndex === -1 ? "" : url.slice(fragmentIndex);
168
+ const separator = baseUrl.includes("?") ? baseUrl.endsWith("?") || baseUrl.endsWith("&") ? "" : "&" : "?";
169
+ const queryParam = `${encodeURIComponent(name)}=${encodeURIComponent(String(value))}`;
170
+ return `${baseUrl}${separator}${queryParam}${fragment}`;
171
+ };
172
+
164
173
  var __defProp$m = Object.defineProperty;
165
174
  var __defNormalProp$m = (obj, key, value) => key in obj ? __defProp$m(obj, key, {
166
175
  enumerable: true,
@@ -417,7 +426,10 @@ this.Scorm12API = (function () {
417
426
  READ_ONLY_ELEMENT: 403,
418
427
  WRITE_ONLY_ELEMENT: 404,
419
428
  TYPE_MISMATCH: 405,
420
- VALUE_OUT_OF_RANGE: 407,
429
+ // SCORM 1.2 has no out-of-range error code; an out-of-range value is reported
430
+ // as 405 (incorrect data type), per the SCORM 1.2 RTE error table and the ADL
431
+ // 1.2 CTS (DataModelValidator.checkScoreDecimal failure -> CMICategory 405).
432
+ VALUE_OUT_OF_RANGE: 405,
421
433
  DEPENDENCY_NOT_ESTABLISHED: 408
422
434
  };
423
435
 
@@ -541,6 +553,7 @@ this.Scorm12API = (function () {
541
553
  xhrWithCredentials: false,
542
554
  fetchMode: "cors",
543
555
  asyncModeBeaconBehavior: "never",
556
+ includeCommitSequence: false,
544
557
  responseHandler: async function (response) {
545
558
  if (typeof response !== "undefined") {
546
559
  let httpResult = null;
@@ -940,7 +953,7 @@ this.Scorm12API = (function () {
940
953
  wrapper() {
941
954
  if (!this._cancelled) {
942
955
  if (this._API.isInitialized()) {
943
- (async () => await this._API.commit(this._callback))();
956
+ (async () => await this._API.commit(this._callback, false, "autocommit"))();
944
957
  }
945
958
  }
946
959
  }
@@ -1430,6 +1443,7 @@ this.Scorm12API = (function () {
1430
1443
  * @param {ErrorCode} error_codes - The error codes object
1431
1444
  */
1432
1445
  constructor(settings, error_codes) {
1446
+ __publicField$h(this, "reportsRequestCompletion", true);
1433
1447
  __publicField$h(this, "settings");
1434
1448
  __publicField$h(this, "error_codes");
1435
1449
  this.settings = settings;
@@ -1448,13 +1462,17 @@ this.Scorm12API = (function () {
1448
1462
  * @param {boolean} immediate - Whether to send the request immediately without waiting
1449
1463
  * @param {Function} apiLog - Function to log API messages with appropriate levels
1450
1464
  * @param {Function} processListeners - Function to trigger event listeners for commit events
1465
+ * @param {CommitMetadata} metadata - Metadata describing the captured commit
1466
+ * @param {Function} onRequestComplete - Callback invoked after the background request settles
1451
1467
  * @return {ResultObject} - Immediate optimistic success result
1452
1468
  */
1453
1469
  processHttpRequest(url, params) {
1454
1470
  let immediate = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false;
1455
1471
  let apiLog = arguments.length > 3 ? arguments[3] : undefined;
1456
1472
  let processListeners = arguments.length > 4 ? arguments[4] : undefined;
1457
- this._performAsyncRequest(url, params, immediate, apiLog, processListeners);
1473
+ let metadata = arguments.length > 5 ? arguments[5] : undefined;
1474
+ let onRequestComplete = arguments.length > 6 ? arguments[6] : undefined;
1475
+ this._performAsyncRequest(url, params, immediate, apiLog, processListeners, metadata, onRequestComplete);
1458
1476
  return {
1459
1477
  result: global_constants.SCORM_TRUE,
1460
1478
  errorCode: 0
@@ -1467,11 +1485,14 @@ this.Scorm12API = (function () {
1467
1485
  * @param {boolean} immediate - Whether this is an immediate request
1468
1486
  * @param apiLog - Function to log API messages
1469
1487
  * @param {Function} processListeners - Function to process event listeners
1488
+ * @param {CommitMetadata} metadata - Metadata describing the captured commit
1489
+ * @param {Function} onRequestComplete - Callback invoked after the request settles
1470
1490
  * @private
1471
1491
  */
1472
- async _performAsyncRequest(url, params, immediate, apiLog, processListeners) {
1492
+ async _performAsyncRequest(url, params, immediate, apiLog, processListeners, metadata, onRequestComplete) {
1473
1493
  try {
1474
- const processedParams = this.settings.requestHandler(params);
1494
+ const handledParams = metadata === void 0 ? this.settings.requestHandler(params) : this.settings.requestHandler(params, metadata);
1495
+ const processedParams = handledParams;
1475
1496
  let response;
1476
1497
  if (immediate && this.settings.asyncModeBeaconBehavior !== "never") {
1477
1498
  response = await this.performBeacon(url, processedParams);
@@ -1487,7 +1508,9 @@ this.Scorm12API = (function () {
1487
1508
  } catch (e) {
1488
1509
  const message = e instanceof Error ? e.message : String(e);
1489
1510
  apiLog("processHttpRequest", `Async request failed: ${message}`, LogLevelEnum.ERROR);
1490
- processListeners("CommitError");
1511
+ processListeners("CommitError", void 0, this.error_codes.GENERAL_COMMIT_FAILURE || 391);
1512
+ } finally {
1513
+ onRequestComplete?.();
1491
1514
  }
1492
1515
  }
1493
1516
  /**
@@ -1649,10 +1672,13 @@ this.Scorm12API = (function () {
1649
1672
  }
1650
1673
  /**
1651
1674
  * Gets the appropriate error code for undefined data model elements.
1652
- * SCORM 2004 uses UNDEFINED_DATA_MODEL, SCORM 1.2 uses GENERAL.
1675
+ * Both SCORM 2004 and SCORM 1.2 use UNDEFINED_DATA_MODEL (401): an
1676
+ * unrecognized element is "Not implemented", not a general exception. SCORM
1677
+ * 1.2 previously returned GENERAL (101) here, which is non-conformant — the
1678
+ * ADL 1.2 CTS and the SCORM 1.2 RTE spec expect 401 for an unknown element.
1653
1679
  */
1654
- getUndefinedDataModelErrorCode(scorm2004) {
1655
- return scorm2004 ? getErrorCode(this.context.errorCodes, "UNDEFINED_DATA_MODEL") : getErrorCode(this.context.errorCodes, "GENERAL");
1680
+ getUndefinedDataModelErrorCode() {
1681
+ return getErrorCode(this.context.errorCodes, "UNDEFINED_DATA_MODEL");
1656
1682
  }
1657
1683
  /**
1658
1684
  * Sets a value on a CMI element path
@@ -1676,7 +1702,7 @@ this.Scorm12API = (function () {
1676
1702
  let returnValue = global_constants.SCORM_FALSE;
1677
1703
  let foundFirstIndex = false;
1678
1704
  const invalidErrorMessage = `The data model element passed to ${methodName} (${CMIElement}) is not a valid SCORM data model element.`;
1679
- const invalidErrorCode = this.getUndefinedDataModelErrorCode(scorm2004);
1705
+ const invalidErrorCode = this.getUndefinedDataModelErrorCode();
1680
1706
  for (let idx = 0; idx < structure.length; idx++) {
1681
1707
  const attribute = structure[idx];
1682
1708
  if (idx === structure.length - 1) {
@@ -1716,12 +1742,13 @@ this.Scorm12API = (function () {
1716
1742
  this.context.throwSCORMError(CMIElement, getErrorCode(this.context.errorCodes, "GENERAL_GET_FAILURE"), "The _version keyword was used incorrectly");
1717
1743
  return "";
1718
1744
  }
1745
+ this.context.setLastErrorCode("0");
1719
1746
  const structure = CMIElement.split(".");
1720
1747
  let refObject = this.context.getDataModel();
1721
1748
  let attribute = null;
1722
1749
  const uninitializedErrorMessage = `The data model element passed to ${methodName} (${CMIElement}) has not been initialized.`;
1723
1750
  const invalidErrorMessage = `The data model element passed to ${methodName} (${CMIElement}) is not a valid SCORM data model element.`;
1724
- const invalidErrorCode = this.getUndefinedDataModelErrorCode(scorm2004);
1751
+ const invalidErrorCode = this.getUndefinedDataModelErrorCode();
1725
1752
  for (let idx = 0; idx < structure.length; idx++) {
1726
1753
  attribute = structure[idx];
1727
1754
  const validationResult = this.validateGetAttribute(refObject, attribute, CMIElement, scorm2004, invalidErrorCode, invalidErrorMessage, idx === structure.length - 1);
@@ -1906,7 +1933,13 @@ this.Scorm12API = (function () {
1906
1933
  if (!scorm2004) {
1907
1934
  if (isFinalAttribute) {
1908
1935
  if (typeof attribute === "undefined" || !this.context.checkObjectHasProperty(refObject, attribute)) {
1909
- this.context.throwSCORMError(CMIElement, invalidErrorCode, invalidErrorMessage);
1936
+ if (attribute === "_children") {
1937
+ this.context.throwSCORMError(CMIElement, getErrorCode(this.context.errorCodes, "CHILDREN_ERROR"));
1938
+ } else if (attribute === "_count") {
1939
+ this.context.throwSCORMError(CMIElement, getErrorCode(this.context.errorCodes, "COUNT_ERROR"));
1940
+ } else {
1941
+ this.context.throwSCORMError(CMIElement, invalidErrorCode, invalidErrorMessage);
1942
+ }
1910
1943
  return {
1911
1944
  error: true
1912
1945
  };
@@ -2441,8 +2474,9 @@ ${stackTrace}`);
2441
2474
  * @param {string} functionName - The name of the function that triggered the event
2442
2475
  * @param {string} CMIElement - The CMI element that was affected
2443
2476
  * @param {any} value - The value that was set
2477
+ * @param {CommitEventContext} context - Optional context for commit lifecycle events
2444
2478
  */
2445
- processListeners(functionName, CMIElement, value) {
2479
+ processListeners(functionName, CMIElement, value, context) {
2446
2480
  this.apiLog(functionName, value, LogLevelEnum.INFO, CMIElement);
2447
2481
  const listeners = this.listenerMap.get(functionName);
2448
2482
  if (!listeners) return;
@@ -2462,9 +2496,17 @@ ${stackTrace}`);
2462
2496
  if (functionName.startsWith("Sequence")) {
2463
2497
  listener.callback(value);
2464
2498
  } else if (functionName === "CommitError") {
2465
- listener.callback(value);
2499
+ if (context !== void 0) {
2500
+ listener.callback(value, context);
2501
+ } else {
2502
+ listener.callback(value);
2503
+ }
2466
2504
  } else if (functionName === "CommitSuccess") {
2467
- listener.callback();
2505
+ if (context !== void 0) {
2506
+ listener.callback(context);
2507
+ } else {
2508
+ listener.callback();
2509
+ }
2468
2510
  } else {
2469
2511
  listener.callback(CMIElement, value);
2470
2512
  }
@@ -2573,16 +2615,23 @@ ${stackTrace}`);
2573
2615
  * Store commit data offline
2574
2616
  * @param {string} courseId - Identifier for the course
2575
2617
  * @param {CommitObject} commitData - The data to store offline
2618
+ * @param {OfflineCommitMetadata} metadata - Metadata captured with the original commit
2576
2619
  * @returns {ResultObject} - Result of the storage operation
2577
2620
  */
2578
- storeOffline(courseId, commitData) {
2621
+ storeOffline(courseId, commitData, metadata) {
2579
2622
  try {
2580
2623
  const queueItem = {
2581
2624
  id: `${courseId}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
2582
2625
  courseId,
2583
2626
  timestamp: Date.now(),
2584
2627
  data: commitData,
2585
- syncAttempts: 0
2628
+ syncAttempts: 0,
2629
+ ...(metadata?.isTerminateCommit !== void 0 ? {
2630
+ isTerminateCommit: metadata.isTerminateCommit
2631
+ } : {}),
2632
+ ...(metadata?.sequence !== void 0 ? {
2633
+ sequence: metadata.sequence
2634
+ } : {})
2586
2635
  };
2587
2636
  const currentQueue = this.getFromStorage(this.syncQueue) || [];
2588
2637
  currentQueue.push(queueItem);
@@ -2641,7 +2690,14 @@ ${stackTrace}`);
2641
2690
  continue;
2642
2691
  }
2643
2692
  try {
2644
- const syncResult = await this.sendDataToLMS(item.data);
2693
+ const syncResult = await this.sendDataToLMS(item.data, {
2694
+ ...(item.isTerminateCommit !== void 0 ? {
2695
+ isTerminateCommit: item.isTerminateCommit
2696
+ } : {}),
2697
+ ...(item.sequence !== void 0 ? {
2698
+ sequence: item.sequence
2699
+ } : {})
2700
+ });
2645
2701
  if (syncResult.result === true || syncResult.result === global_constants.SCORM_TRUE) {
2646
2702
  this.apiLog("OfflineStorageService", `Successfully synced item ${item.id}`, LogLevelEnum.INFO);
2647
2703
  } else {
@@ -2668,17 +2724,27 @@ ${stackTrace}`);
2668
2724
  /**
2669
2725
  * Send data to the LMS when online
2670
2726
  * @param {CommitObject} data - The data to send to the LMS
2727
+ * @param {OfflineCommitMetadata} metadata - Metadata captured with the original commit
2671
2728
  * @returns {Promise<ResultObject>} - Result of the sync operation
2672
2729
  */
2673
- async sendDataToLMS(data) {
2674
- if (!this.settings.lmsCommitUrl) {
2730
+ async sendDataToLMS(data, metadata) {
2731
+ const configuredCommitUrl = this.settings.lmsCommitUrl;
2732
+ if (!configuredCommitUrl) {
2675
2733
  return {
2676
2734
  result: global_constants.SCORM_FALSE,
2677
2735
  errorCode: this.error_codes.GENERAL || 101
2678
2736
  };
2679
2737
  }
2680
2738
  try {
2681
- const processedData = this.settings.requestHandler(data);
2739
+ const lmsCommitUrl = String(configuredCommitUrl);
2740
+ const processedData = this.settings.requestHandler(data, {
2741
+ isTerminateCommit: metadata?.isTerminateCommit ?? false,
2742
+ trigger: "offline-replay",
2743
+ ...(metadata?.sequence !== void 0 ? {
2744
+ sequence: metadata.sequence
2745
+ } : {})
2746
+ });
2747
+ const requestUrl = metadata?.isTerminateCommit && this.settings.terminateCommitParam ? appendQueryParam(lmsCommitUrl, this.settings.terminateCommitParam, "true") : lmsCommitUrl;
2682
2748
  const init = {
2683
2749
  method: "POST",
2684
2750
  mode: this.settings.fetchMode,
@@ -2691,7 +2757,7 @@ ${stackTrace}`);
2691
2757
  if (this.settings.xhrWithCredentials) {
2692
2758
  init.credentials = "include";
2693
2759
  }
2694
- const response = await fetch(this.settings.lmsCommitUrl, init);
2760
+ const response = await fetch(requestUrl, init);
2695
2761
  const result = typeof this.settings.responseHandler === "function" ? await this.settings.responseHandler(response) : await response.json();
2696
2762
  if (response.status >= 200 && response.status <= 299 && (result.result === true || result.result === global_constants.SCORM_TRUE)) {
2697
2763
  if (!Object.hasOwnProperty.call(result, "errorCode")) {
@@ -3422,6 +3488,8 @@ ${stackTrace}`);
3422
3488
  * @param {boolean} immediate - Whether this is a termination commit (use sendBeacon)
3423
3489
  * @param {Function} _apiLog - Function to log API messages (unused in synchronous mode - errors returned directly)
3424
3490
  * @param {Function} _processListeners - Function to trigger event listeners (unused in synchronous mode - no async events)
3491
+ * @param {CommitMetadata} metadata - Metadata describing the captured commit
3492
+ * @param {Function} _onRequestComplete - Completion callback (unused because requests settle before return)
3425
3493
  * @return {ResultObject} - The result of the request (synchronous)
3426
3494
  *
3427
3495
  * @remarks
@@ -3433,20 +3501,23 @@ ${stackTrace}`);
3433
3501
  */
3434
3502
  processHttpRequest(url, params) {
3435
3503
  let immediate = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false;
3504
+ let metadata = arguments.length > 5 ? arguments[5] : undefined;
3436
3505
  if (immediate) {
3437
- return this._handleImmediateRequest(url, params);
3506
+ return this._handleImmediateRequest(url, params, metadata);
3438
3507
  }
3439
- return this._performSyncXHR(url, params);
3508
+ return this._performSyncXHR(url, params, metadata);
3440
3509
  }
3441
3510
  /**
3442
3511
  * Handles an immediate request using sendBeacon
3443
3512
  * @param {string} url - The URL to send the request to
3444
3513
  * @param {CommitObject|StringKeyMap|Array} params - The parameters to include in the request
3514
+ * @param {CommitMetadata} metadata - Metadata describing the captured commit
3445
3515
  * @return {ResultObject} - The result based on beacon success
3446
3516
  * @private
3447
3517
  */
3448
- _handleImmediateRequest(url, params) {
3449
- const requestPayload = this.settings.requestHandler(params) ?? params;
3518
+ _handleImmediateRequest(url, params, metadata) {
3519
+ const handledPayload = metadata === void 0 ? this.settings.requestHandler(params) : this.settings.requestHandler(params, metadata);
3520
+ const requestPayload = handledPayload ?? params;
3450
3521
  const {
3451
3522
  body
3452
3523
  } = this._prepareRequestBody(requestPayload);
@@ -3462,11 +3533,13 @@ ${stackTrace}`);
3462
3533
  * Performs a synchronous XMLHttpRequest
3463
3534
  * @param {string} url - The URL to send the request to
3464
3535
  * @param {CommitObject|StringKeyMap|Array} params - The parameters to include in the request
3536
+ * @param {CommitMetadata} metadata - Metadata describing the captured commit
3465
3537
  * @return {ResultObject} - The result of the request
3466
3538
  * @private
3467
3539
  */
3468
- _performSyncXHR(url, params) {
3469
- const requestPayload = this.settings.requestHandler(params) ?? params;
3540
+ _performSyncXHR(url, params, metadata) {
3541
+ const handledPayload = metadata === void 0 ? this.settings.requestHandler(params) : this.settings.requestHandler(params, metadata);
3542
+ const requestPayload = handledPayload ?? params;
3470
3543
  const {
3471
3544
  body,
3472
3545
  contentType
@@ -3539,9 +3612,16 @@ ${stackTrace}`);
3539
3612
  * @param {number} invalidTypeCode - The error code for invalid type
3540
3613
  * @param {number} invalidRangeCode - The error code for invalid range
3541
3614
  * @param {typeof BaseScormValidationError} errorClass - The error class to use for validation errors
3615
+ * @param {boolean} allowEmptyString - When true, an empty string is accepted (clears the value).
3616
+ * SCORM 1.2 score elements may be blank per the ADL 1.2 CTS
3617
+ * (DataModelValidator.checkScoreDecimal treats blank as valid).
3542
3618
  * @return {boolean} - True if validation passes, throws an error otherwise
3543
3619
  */
3544
3620
  validateScore(CMIElement, value, decimalRegex, scoreRange, invalidTypeCode, invalidRangeCode, errorClass) {
3621
+ let allowEmptyString = arguments.length > 7 && arguments[7] !== undefined ? arguments[7] : false;
3622
+ if (allowEmptyString && value === "") {
3623
+ return true;
3624
+ }
3545
3625
  return checkValidFormat(CMIElement, value, decimalRegex, invalidTypeCode, errorClass) && (!scoreRange || checkValidRange(CMIElement, value, scoreRange, invalidRangeCode, errorClass));
3546
3626
  }
3547
3627
  /**
@@ -3637,6 +3717,21 @@ ${stackTrace}`);
3637
3717
  __publicField$9(this, "_offlineStorageService");
3638
3718
  __publicField$9(this, "_cmiValueAccessService");
3639
3719
  __publicField$9(this, "_courseId", "");
3720
+ __publicField$9(this, "_pendingCommitCount", 0);
3721
+ /**
3722
+ * Monotonic sequence for commits captured by this API instance. It is
3723
+ * intentionally not reset by reset().
3724
+ */
3725
+ __publicField$9(this, "_commitSequence", 0);
3726
+ __publicField$9(this, "_commitSettleWaiters", []);
3727
+ /**
3728
+ * Canonical paths of every CMI element that has been explicitly assigned a
3729
+ * value via SetValue / loadFromJSON (both funnel through _commonSetCMIValue).
3730
+ * Used by standards that must tell "implemented but never set" apart from a
3731
+ * legitimately empty value when answering GetValue (SCORM 2004 error 403).
3732
+ * Cleared on reset so a fresh SCO attempt starts with nothing "set".
3733
+ */
3734
+ __publicField$9(this, "_setCMIElements", /* @__PURE__ */new Set());
3640
3735
  __publicField$9(this, "startingData");
3641
3736
  __publicField$9(this, "currentState");
3642
3737
  if (new.target === BaseAPI) {
@@ -3783,6 +3878,7 @@ ${stackTrace}`);
3783
3878
  this.lastErrorCode = "0";
3784
3879
  this._eventService.reset();
3785
3880
  this.startingData = {};
3881
+ this._setCMIElements.clear();
3786
3882
  if (this._offlineStorageService) {
3787
3883
  this._offlineStorageService.updateSettings(this.settings);
3788
3884
  if (settings?.courseId) {
@@ -3866,6 +3962,54 @@ ${stackTrace}`);
3866
3962
  this._loggingService?.setLogHandler(settings.onLogMessage);
3867
3963
  }
3868
3964
  }
3965
+ /**
3966
+ * Gets the number of captured commit requests that have not yet settled.
3967
+ *
3968
+ * @return {number} The number of in-flight commits
3969
+ */
3970
+ get pendingCommitCount() {
3971
+ return this._pendingCommitCount;
3972
+ }
3973
+ /**
3974
+ * Resolves when all currently in-flight commits have settled. A timeout is
3975
+ * best-effort: the promise resolves when it elapses even if commits remain,
3976
+ * and callers can inspect pendingCommitCount afterward to detect that case.
3977
+ *
3978
+ * @param {Object} [options] - Settle options
3979
+ * @param {number} [options.timeoutMs] - Maximum time to wait in milliseconds
3980
+ * @return {Promise<void>} A promise that resolves after the drain or timeout
3981
+ */
3982
+ whenCommitsSettled(options) {
3983
+ if (this._pendingCommitCount === 0) {
3984
+ return Promise.resolve();
3985
+ }
3986
+ return new Promise(resolve => {
3987
+ const waiter = {
3988
+ resolve
3989
+ };
3990
+ if (options?.timeoutMs !== void 0) {
3991
+ waiter.timeoutId = setTimeout(() => {
3992
+ const waiterIndex = this._commitSettleWaiters.indexOf(waiter);
3993
+ if (waiterIndex === -1) {
3994
+ return;
3995
+ }
3996
+ this._commitSettleWaiters.splice(waiterIndex, 1);
3997
+ resolve();
3998
+ }, options.timeoutMs);
3999
+ }
4000
+ this._commitSettleWaiters.push(waiter);
4001
+ });
4002
+ }
4003
+ /** Resolve and clear every waiter after the pending count reaches zero. */
4004
+ _flushCommitSettleWaiters() {
4005
+ const waiters = this._commitSettleWaiters.splice(0);
4006
+ for (const waiter of waiters) {
4007
+ if (waiter.timeoutId !== void 0) {
4008
+ clearTimeout(waiter.timeoutId);
4009
+ }
4010
+ waiter.resolve();
4011
+ }
4012
+ }
3869
4013
  /**
3870
4014
  * Terminates the current run of the API
3871
4015
  * @param {string} callbackName
@@ -3886,7 +4030,7 @@ ${stackTrace}`);
3886
4030
  } else {
3887
4031
  stateCheckPassed = true;
3888
4032
  this.processListeners("BeforeTerminate");
3889
- const result = this.storeData(true);
4033
+ const result = this.storeData(true, "terminate");
3890
4034
  if ((result.errorCode ?? 0) > 0) {
3891
4035
  if (result.errorMessage) {
3892
4036
  this.apiLog("terminate", `Terminate failed with error: ${result.errorMessage}`, LogLevelEnum.ERROR);
@@ -3926,6 +4070,9 @@ ${stackTrace}`);
3926
4070
  } catch (e) {
3927
4071
  returnValue = this.handleValueAccessException(CMIElement, e, returnValue);
3928
4072
  }
4073
+ if (this.lastErrorCode === "0") {
4074
+ this.checkUninitializedGet(CMIElement, returnValue);
4075
+ }
3929
4076
  this.processListeners(callbackName, CMIElement);
3930
4077
  }
3931
4078
  this.apiLog(callbackName, ": returned: " + returnValue, LogLevelEnum.INFO, CMIElement);
@@ -3935,6 +4082,10 @@ ${stackTrace}`);
3935
4082
  if (this.lastErrorCode === "0") {
3936
4083
  this.clearSCORMError(returnValue);
3937
4084
  }
4085
+ const rawReturn = returnValue;
4086
+ if (typeof rawReturn === "number" || typeof rawReturn === "boolean") {
4087
+ return String(rawReturn);
4088
+ }
3938
4089
  return returnValue;
3939
4090
  }
3940
4091
  /**
@@ -3978,10 +4129,12 @@ ${stackTrace}`);
3978
4129
  * Orders LMS to store all content parameters
3979
4130
  * @param {string} callbackName
3980
4131
  * @param {boolean} checkTerminated
4132
+ * @param {CommitTrigger} trigger - What initiated the commit
3981
4133
  * @return {string}
3982
4134
  */
3983
4135
  commit(callbackName) {
3984
4136
  let checkTerminated = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false;
4137
+ let trigger = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : "manual";
3985
4138
  this.clearScheduledCommit();
3986
4139
  let returnValue = global_constants.SCORM_TRUE;
3987
4140
  if (this.isNotInitialized()) {
@@ -3993,20 +4146,21 @@ ${stackTrace}`);
3993
4146
  this.throwSCORMError("api", errorCode);
3994
4147
  if (errorCode === 143) returnValue = global_constants.SCORM_FALSE;
3995
4148
  } else {
3996
- const result = this.storeData(false);
3997
- if ((result.errorCode ?? 0) > 0) {
4149
+ const result = this.storeData(false, trigger);
4150
+ const errorCode = result.errorCode ?? 0;
4151
+ if (errorCode > 0) {
3998
4152
  if (result.errorMessage) {
3999
4153
  this.apiLog("commit", `Commit failed with error: ${result.errorMessage}`, LogLevelEnum.ERROR);
4000
4154
  }
4001
4155
  if (result.errorDetails) {
4002
4156
  this.apiLog("commit", `Error details: ${JSON.stringify(result.errorDetails)}`, LogLevelEnum.DEBUG);
4003
4157
  }
4004
- this.throwSCORMError("api", result.errorCode);
4158
+ this.throwSCORMError("api", errorCode);
4005
4159
  }
4006
4160
  const resultValue = result?.result ?? global_constants.SCORM_FALSE;
4007
4161
  returnValue = typeof resultValue === "boolean" ? String(resultValue) : resultValue;
4008
4162
  this.apiLog(callbackName, " Result: " + returnValue, LogLevelEnum.DEBUG, "HttpRequest");
4009
- if (checkTerminated) this.lastErrorCode = "0";
4163
+ if (checkTerminated && errorCode === 0) this.lastErrorCode = "0";
4010
4164
  this.processListeners(callbackName);
4011
4165
  if (this.settings.enableOfflineSupport && this._offlineStorageService && this._offlineStorageService.isDeviceOnline() && this._courseId) {
4012
4166
  this._offlineStorageService.hasPendingOfflineData(this._courseId).then(hasPendingData => {
@@ -4213,7 +4367,11 @@ ${stackTrace}`);
4213
4367
  * @return {string}
4214
4368
  */
4215
4369
  _commonSetCMIValue(methodName, scorm2004, CMIElement, value) {
4216
- return this._cmiValueAccessService.setCMIValue(methodName, scorm2004, CMIElement, value);
4370
+ const result = this._cmiValueAccessService.setCMIValue(methodName, scorm2004, CMIElement, value);
4371
+ if (result === global_constants.SCORM_TRUE) {
4372
+ this._setCMIElements.add(CMIElement);
4373
+ }
4374
+ return result;
4217
4375
  }
4218
4376
  /**
4219
4377
  * Gets a value from the CMI Object.
@@ -4227,6 +4385,17 @@ ${stackTrace}`);
4227
4385
  _commonGetCMIValue(methodName, scorm2004, CMIElement) {
4228
4386
  return this._cmiValueAccessService.getCMIValue(methodName, scorm2004, CMIElement);
4229
4387
  }
4388
+ /**
4389
+ * Hook invoked by getValue after a successful resolution. Standards that must
4390
+ * distinguish "implemented but never set, no default value" from a
4391
+ * legitimately empty value override this to raise VALUE_NOT_INITIALIZED.
4392
+ * Default is a no-op, so SCORM 1.2 / AICC keep returning "" with code 0.
4393
+ *
4394
+ * @param {string} _CMIElement - the element that was read
4395
+ * @param {any} _returnValue - the value getCMIValue resolved
4396
+ * @protected
4397
+ */
4398
+ checkUninitializedGet(_CMIElement, _returnValue) {}
4230
4399
  /**
4231
4400
  * Returns true if the API's current state is STATE_INITIALIZED
4232
4401
  *
@@ -4309,9 +4478,14 @@ ${stackTrace}`);
4309
4478
  * @param {string} functionName - The name of the function/event that occurred
4310
4479
  * @param {string} CMIElement - Optional CMI element involved in the event
4311
4480
  * @param {any} value - Optional value associated with the event
4481
+ * @param {CommitEventContext} context - Optional context for commit lifecycle events
4312
4482
  */
4313
- processListeners(functionName, CMIElement, value) {
4314
- this._eventService.processListeners(functionName, CMIElement, value);
4483
+ processListeners(functionName, CMIElement, value, context) {
4484
+ if (context !== void 0) {
4485
+ this._eventService.processListeners(functionName, CMIElement, value, context);
4486
+ } else {
4487
+ this._eventService.processListeners(functionName, CMIElement, value);
4488
+ }
4315
4489
  }
4316
4490
  /**
4317
4491
  * Throws a SCORM error with the specified error number and optional message.
@@ -4433,23 +4607,97 @@ ${stackTrace}`);
4433
4607
  * @param {string} url - The URL to send the request to
4434
4608
  * @param {CommitObject | StringKeyMap | Array<any>} params - The parameters to send
4435
4609
  * @param {boolean} immediate - Whether to send the request immediately without waiting
4610
+ * @param {CommitTrigger} [trigger] - What initiated the commit
4436
4611
  * @returns {ResultObject} - The result of the request
4437
4612
  */
4438
4613
  processHttpRequest(url, params) {
4439
4614
  let immediate = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false;
4440
- if (this.settings.enableOfflineSupport && this._offlineStorageService && !this._offlineStorageService.isDeviceOnline() && this._courseId) {
4441
- this.apiLog("processHttpRequest", "Device is offline, storing data locally", LogLevelEnum.INFO);
4442
- if (params && typeof params === "object" && "cmi" in params) {
4443
- return this._offlineStorageService.storeOffline(this._courseId, params);
4444
- } else {
4445
- this.apiLog("processHttpRequest", "Invalid commit data format for offline storage", LogLevelEnum.ERROR);
4446
- return {
4447
- result: global_constants.SCORM_FALSE,
4448
- errorCode: this._error_codes.GENERAL ?? 101
4449
- };
4615
+ let trigger = arguments.length > 3 ? arguments[3] : undefined;
4616
+ const sequence = ++this._commitSequence;
4617
+ this._pendingCommitCount += 1;
4618
+ let settled = false;
4619
+ let completionDeferred = false;
4620
+ const settle = () => {
4621
+ if (settled) {
4622
+ return;
4623
+ }
4624
+ settled = true;
4625
+ this._pendingCommitCount -= 1;
4626
+ if (this._pendingCommitCount === 0) {
4627
+ this._flushCommitSettleWaiters();
4628
+ }
4629
+ };
4630
+ try {
4631
+ const resolvedTrigger = trigger ?? (immediate ? "terminate" : "manual");
4632
+ let finalParams = params;
4633
+ if (immediate && this.settings.terminateCommitPayloadField) {
4634
+ const field = this.settings.terminateCommitPayloadField;
4635
+ if (Array.isArray(finalParams)) {
4636
+ finalParams = [...finalParams, `${encodeURIComponent(field)}=true`];
4637
+ } else if (finalParams && typeof finalParams === "object") {
4638
+ finalParams = {
4639
+ ...finalParams,
4640
+ [field]: true
4641
+ };
4642
+ }
4643
+ }
4644
+ if (this.settings.includeCommitSequence === true) {
4645
+ if (Array.isArray(finalParams)) {
4646
+ finalParams = [...finalParams, `commitSequence=${sequence}`];
4647
+ } else if (finalParams && typeof finalParams === "object") {
4648
+ finalParams = {
4649
+ ...finalParams,
4650
+ commitSequence: sequence
4651
+ };
4652
+ }
4653
+ }
4654
+ const finalUrl = immediate && this.settings.terminateCommitParam ? appendQueryParam(url, this.settings.terminateCommitParam, "true") : url;
4655
+ const metadata = {
4656
+ isTerminateCommit: immediate,
4657
+ trigger: resolvedTrigger,
4658
+ sequence
4659
+ };
4660
+ const context = {
4661
+ url: finalUrl,
4662
+ trigger: resolvedTrigger,
4663
+ isTerminateCommit: immediate,
4664
+ sequence
4665
+ };
4666
+ if (this.settings.enableOfflineSupport && this._offlineStorageService && !this._offlineStorageService.isDeviceOnline() && this._courseId) {
4667
+ this.apiLog("processHttpRequest", "Device is offline, storing data locally", LogLevelEnum.INFO);
4668
+ if (finalParams && typeof finalParams === "object" && "cmi" in finalParams) {
4669
+ return this._offlineStorageService.storeOffline(this._courseId, finalParams, {
4670
+ isTerminateCommit: immediate,
4671
+ sequence
4672
+ });
4673
+ } else {
4674
+ this.apiLog("processHttpRequest", "Invalid commit data format for offline storage", LogLevelEnum.ERROR);
4675
+ return {
4676
+ result: global_constants.SCORM_FALSE,
4677
+ errorCode: this._error_codes.GENERAL ?? 101
4678
+ };
4679
+ }
4680
+ }
4681
+ const apiLog = (functionName, message, level, element) => this.apiLog(functionName, message, level, element);
4682
+ const processListeners = (functionName, CMIElement, value) => {
4683
+ if (functionName === "CommitSuccess" || functionName === "CommitError") {
4684
+ if (functionName === "CommitError" && typeof value === "number") {
4685
+ context.errorCode = value;
4686
+ }
4687
+ settle();
4688
+ this.processListeners(functionName, CMIElement, value, context);
4689
+ } else {
4690
+ this.processListeners(functionName, CMIElement, value);
4691
+ }
4692
+ };
4693
+ const result = this._httpService.processHttpRequest(finalUrl, finalParams, immediate, apiLog, processListeners, metadata, settle);
4694
+ completionDeferred = this._httpService.reportsRequestCompletion === true;
4695
+ return result;
4696
+ } finally {
4697
+ if (!completionDeferred) {
4698
+ settle();
4450
4699
  }
4451
4700
  }
4452
- return this._httpService.processHttpRequest(url, params, immediate, (functionName, message, level, element) => this.apiLog(functionName, message, level, element), (functionName, CMIElement, value) => this.processListeners(functionName, CMIElement, value));
4453
4701
  }
4454
4702
  /**
4455
4703
  * Schedules a commit operation to occur after a specified delay.
@@ -4633,6 +4881,11 @@ ${stackTrace}`);
4633
4881
  __publicField$8(this, "__invalid_range_code");
4634
4882
  __publicField$8(this, "__decimal_regex");
4635
4883
  __publicField$8(this, "__error_class");
4884
+ /**
4885
+ * When true, an empty string is a valid value (clears the element). SCORM 1.2
4886
+ * score elements may be blank per the ADL 1.2 CTS; SCORM 2004 leaves this off.
4887
+ */
4888
+ __publicField$8(this, "__allow_empty_string");
4636
4889
  __publicField$8(this, "_raw", "");
4637
4890
  __publicField$8(this, "_min", "");
4638
4891
  __publicField$8(this, "_max");
@@ -4644,6 +4897,7 @@ ${stackTrace}`);
4644
4897
  this.__invalid_range_code = params.invalidRangeCode || scorm12_errors.VALUE_OUT_OF_RANGE;
4645
4898
  this.__decimal_regex = params.decimalRegex || scorm12_regex.CMIDecimal;
4646
4899
  this.__error_class = params.errorClass;
4900
+ this.__allow_empty_string = params.allowEmptyString ?? false;
4647
4901
  }
4648
4902
  /**
4649
4903
  * Called when the API has been reset
@@ -4683,7 +4937,7 @@ ${stackTrace}`);
4683
4937
  * @param {string} raw
4684
4938
  */
4685
4939
  set raw(raw) {
4686
- if (validationService.validateScore(this._cmi_element + ".raw", raw, this.__decimal_regex, this.__score_range, this.__invalid_type_code, this.__invalid_range_code, this.__error_class)) {
4940
+ if (validationService.validateScore(this._cmi_element + ".raw", raw, this.__decimal_regex, this.__score_range, this.__invalid_type_code, this.__invalid_range_code, this.__error_class, this.__allow_empty_string)) {
4687
4941
  this._raw = raw;
4688
4942
  }
4689
4943
  }
@@ -4699,7 +4953,7 @@ ${stackTrace}`);
4699
4953
  * @param {string} min
4700
4954
  */
4701
4955
  set min(min) {
4702
- if (validationService.validateScore(this._cmi_element + ".min", min, this.__decimal_regex, this.__score_range, this.__invalid_type_code, this.__invalid_range_code, this.__error_class)) {
4956
+ if (validationService.validateScore(this._cmi_element + ".min", min, this.__decimal_regex, this.__score_range, this.__invalid_type_code, this.__invalid_range_code, this.__error_class, this.__allow_empty_string)) {
4703
4957
  this._min = min;
4704
4958
  }
4705
4959
  }
@@ -4715,7 +4969,7 @@ ${stackTrace}`);
4715
4969
  * @param {string} max
4716
4970
  */
4717
4971
  set max(max) {
4718
- if (validationService.validateScore(this._cmi_element + ".max", max, this.__decimal_regex, this.__score_range, this.__invalid_type_code, this.__invalid_range_code, this.__error_class)) {
4972
+ if (validationService.validateScore(this._cmi_element + ".max", max, this.__decimal_regex, this.__score_range, this.__invalid_type_code, this.__invalid_range_code, this.__error_class, this.__allow_empty_string)) {
4719
4973
  this._max = max;
4720
4974
  }
4721
4975
  }
@@ -4792,7 +5046,9 @@ ${stackTrace}`);
4792
5046
  invalidErrorCode: scorm12_errors.INVALID_SET_VALUE,
4793
5047
  invalidTypeCode: scorm12_errors.TYPE_MISMATCH,
4794
5048
  invalidRangeCode: scorm12_errors.VALUE_OUT_OF_RANGE,
4795
- errorClass: Scorm12ValidationError
5049
+ errorClass: Scorm12ValidationError,
5050
+ // SCORM 1.2 score elements may be set blank (clears the value), per ADL 1.2 CTS.
5051
+ allowEmptyString: true
4796
5052
  });
4797
5053
  }
4798
5054
  /**
@@ -5160,7 +5416,9 @@ ${stackTrace}`);
5160
5416
  invalidErrorCode: scorm12_errors.INVALID_SET_VALUE,
5161
5417
  invalidTypeCode: scorm12_errors.TYPE_MISMATCH,
5162
5418
  invalidRangeCode: scorm12_errors.VALUE_OUT_OF_RANGE,
5163
- errorClass: Scorm12ValidationError
5419
+ errorClass: Scorm12ValidationError,
5420
+ // SCORM 1.2 score elements may be set blank (clears the value), per ADL 1.2 CTS.
5421
+ allowEmptyString: true
5164
5422
  });
5165
5423
  }
5166
5424
  /**
@@ -6597,16 +6855,17 @@ ${stackTrace}`);
6597
6855
  * Attempts to store the data to the LMS
6598
6856
  *
6599
6857
  * @param {boolean} terminateCommit
6858
+ * @param {CommitTrigger} [trigger] - What initiated the commit
6600
6859
  * @return {ResultObject}
6601
6860
  */
6602
- storeData(terminateCommit) {
6861
+ storeData(terminateCommit, trigger) {
6603
6862
  if (terminateCommit) {
6604
6863
  const originalStatus = this.cmi.core.lesson_status;
6605
6864
  if (this.cmi.core.lesson_mode === "browse") {
6606
6865
  const startingStatus = this.startingData?.cmi?.core?.lesson_status || "";
6607
6866
  if (startingStatus === "" && originalStatus === "not attempted") {
6608
6867
  this.cmi.core.lesson_status = "browsed";
6609
- return this.processCommitData(terminateCommit);
6868
+ return this.processCommitData(terminateCommit, trigger);
6610
6869
  }
6611
6870
  }
6612
6871
  if (!this.cmi.core.lesson_status || !this.statusSetByModule && this.cmi.core.lesson_status === "not attempted") {
@@ -6635,12 +6894,12 @@ ${stackTrace}`);
6635
6894
  }
6636
6895
  }
6637
6896
  }
6638
- return this.processCommitData(terminateCommit);
6897
+ return this.processCommitData(terminateCommit, trigger);
6639
6898
  }
6640
- processCommitData(terminateCommit) {
6899
+ processCommitData(terminateCommit, trigger) {
6641
6900
  const commitObject = this.getCommitObject(terminateCommit);
6642
6901
  if (typeof this.settings.lmsCommitUrl === "string") {
6643
- return this.processHttpRequest(this.settings.lmsCommitUrl, commitObject, terminateCommit);
6902
+ return this.processHttpRequest(this.settings.lmsCommitUrl, commitObject, terminateCommit, trigger);
6644
6903
  } else {
6645
6904
  return {
6646
6905
  result: global_constants.SCORM_TRUE,