scorm-again 3.0.5 → 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 (41) hide show
  1. package/README.md +77 -1
  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 +388 -71
  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 +322 -69
  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 +371 -64
  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 +341 -55
  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 +305 -53
  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 +329 -48
  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/interfaces/services.d.ts +8 -4
  33. package/dist/types/services/AsynchronousHttpService.d.ts +3 -2
  34. package/dist/types/services/EventService.d.ts +2 -2
  35. package/dist/types/services/OfflineStorageService.d.ts +6 -1
  36. package/dist/types/services/SynchronousHttpService.d.ts +2 -2
  37. package/dist/types/services/ValidationService.d.ts +1 -1
  38. package/dist/types/types/api_types.d.ts +22 -2
  39. package/dist/types/utilities/index.d.ts +1 -0
  40. package/dist/types/utilities/url.d.ts +2 -0
  41. package/package.json +21 -21
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);
@@ -2447,8 +2474,9 @@ ${stackTrace}`);
2447
2474
  * @param {string} functionName - The name of the function that triggered the event
2448
2475
  * @param {string} CMIElement - The CMI element that was affected
2449
2476
  * @param {any} value - The value that was set
2477
+ * @param {CommitEventContext} context - Optional context for commit lifecycle events
2450
2478
  */
2451
- processListeners(functionName, CMIElement, value) {
2479
+ processListeners(functionName, CMIElement, value, context) {
2452
2480
  this.apiLog(functionName, value, LogLevelEnum.INFO, CMIElement);
2453
2481
  const listeners = this.listenerMap.get(functionName);
2454
2482
  if (!listeners) return;
@@ -2468,9 +2496,17 @@ ${stackTrace}`);
2468
2496
  if (functionName.startsWith("Sequence")) {
2469
2497
  listener.callback(value);
2470
2498
  } else if (functionName === "CommitError") {
2471
- listener.callback(value);
2499
+ if (context !== void 0) {
2500
+ listener.callback(value, context);
2501
+ } else {
2502
+ listener.callback(value);
2503
+ }
2472
2504
  } else if (functionName === "CommitSuccess") {
2473
- listener.callback();
2505
+ if (context !== void 0) {
2506
+ listener.callback(context);
2507
+ } else {
2508
+ listener.callback();
2509
+ }
2474
2510
  } else {
2475
2511
  listener.callback(CMIElement, value);
2476
2512
  }
@@ -2579,16 +2615,23 @@ ${stackTrace}`);
2579
2615
  * Store commit data offline
2580
2616
  * @param {string} courseId - Identifier for the course
2581
2617
  * @param {CommitObject} commitData - The data to store offline
2618
+ * @param {OfflineCommitMetadata} metadata - Metadata captured with the original commit
2582
2619
  * @returns {ResultObject} - Result of the storage operation
2583
2620
  */
2584
- storeOffline(courseId, commitData) {
2621
+ storeOffline(courseId, commitData, metadata) {
2585
2622
  try {
2586
2623
  const queueItem = {
2587
2624
  id: `${courseId}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
2588
2625
  courseId,
2589
2626
  timestamp: Date.now(),
2590
2627
  data: commitData,
2591
- 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
+ } : {})
2592
2635
  };
2593
2636
  const currentQueue = this.getFromStorage(this.syncQueue) || [];
2594
2637
  currentQueue.push(queueItem);
@@ -2647,7 +2690,14 @@ ${stackTrace}`);
2647
2690
  continue;
2648
2691
  }
2649
2692
  try {
2650
- 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
+ });
2651
2701
  if (syncResult.result === true || syncResult.result === global_constants.SCORM_TRUE) {
2652
2702
  this.apiLog("OfflineStorageService", `Successfully synced item ${item.id}`, LogLevelEnum.INFO);
2653
2703
  } else {
@@ -2674,17 +2724,27 @@ ${stackTrace}`);
2674
2724
  /**
2675
2725
  * Send data to the LMS when online
2676
2726
  * @param {CommitObject} data - The data to send to the LMS
2727
+ * @param {OfflineCommitMetadata} metadata - Metadata captured with the original commit
2677
2728
  * @returns {Promise<ResultObject>} - Result of the sync operation
2678
2729
  */
2679
- async sendDataToLMS(data) {
2680
- if (!this.settings.lmsCommitUrl) {
2730
+ async sendDataToLMS(data, metadata) {
2731
+ const configuredCommitUrl = this.settings.lmsCommitUrl;
2732
+ if (!configuredCommitUrl) {
2681
2733
  return {
2682
2734
  result: global_constants.SCORM_FALSE,
2683
2735
  errorCode: this.error_codes.GENERAL || 101
2684
2736
  };
2685
2737
  }
2686
2738
  try {
2687
- 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;
2688
2748
  const init = {
2689
2749
  method: "POST",
2690
2750
  mode: this.settings.fetchMode,
@@ -2697,7 +2757,7 @@ ${stackTrace}`);
2697
2757
  if (this.settings.xhrWithCredentials) {
2698
2758
  init.credentials = "include";
2699
2759
  }
2700
- const response = await fetch(this.settings.lmsCommitUrl, init);
2760
+ const response = await fetch(requestUrl, init);
2701
2761
  const result = typeof this.settings.responseHandler === "function" ? await this.settings.responseHandler(response) : await response.json();
2702
2762
  if (response.status >= 200 && response.status <= 299 && (result.result === true || result.result === global_constants.SCORM_TRUE)) {
2703
2763
  if (!Object.hasOwnProperty.call(result, "errorCode")) {
@@ -3428,6 +3488,8 @@ ${stackTrace}`);
3428
3488
  * @param {boolean} immediate - Whether this is a termination commit (use sendBeacon)
3429
3489
  * @param {Function} _apiLog - Function to log API messages (unused in synchronous mode - errors returned directly)
3430
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)
3431
3493
  * @return {ResultObject} - The result of the request (synchronous)
3432
3494
  *
3433
3495
  * @remarks
@@ -3439,20 +3501,23 @@ ${stackTrace}`);
3439
3501
  */
3440
3502
  processHttpRequest(url, params) {
3441
3503
  let immediate = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false;
3504
+ let metadata = arguments.length > 5 ? arguments[5] : undefined;
3442
3505
  if (immediate) {
3443
- return this._handleImmediateRequest(url, params);
3506
+ return this._handleImmediateRequest(url, params, metadata);
3444
3507
  }
3445
- return this._performSyncXHR(url, params);
3508
+ return this._performSyncXHR(url, params, metadata);
3446
3509
  }
3447
3510
  /**
3448
3511
  * Handles an immediate request using sendBeacon
3449
3512
  * @param {string} url - The URL to send the request to
3450
3513
  * @param {CommitObject|StringKeyMap|Array} params - The parameters to include in the request
3514
+ * @param {CommitMetadata} metadata - Metadata describing the captured commit
3451
3515
  * @return {ResultObject} - The result based on beacon success
3452
3516
  * @private
3453
3517
  */
3454
- _handleImmediateRequest(url, params) {
3455
- 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;
3456
3521
  const {
3457
3522
  body
3458
3523
  } = this._prepareRequestBody(requestPayload);
@@ -3468,11 +3533,13 @@ ${stackTrace}`);
3468
3533
  * Performs a synchronous XMLHttpRequest
3469
3534
  * @param {string} url - The URL to send the request to
3470
3535
  * @param {CommitObject|StringKeyMap|Array} params - The parameters to include in the request
3536
+ * @param {CommitMetadata} metadata - Metadata describing the captured commit
3471
3537
  * @return {ResultObject} - The result of the request
3472
3538
  * @private
3473
3539
  */
3474
- _performSyncXHR(url, params) {
3475
- 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;
3476
3543
  const {
3477
3544
  body,
3478
3545
  contentType
@@ -3545,9 +3612,16 @@ ${stackTrace}`);
3545
3612
  * @param {number} invalidTypeCode - The error code for invalid type
3546
3613
  * @param {number} invalidRangeCode - The error code for invalid range
3547
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).
3548
3618
  * @return {boolean} - True if validation passes, throws an error otherwise
3549
3619
  */
3550
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
+ }
3551
3625
  return checkValidFormat(CMIElement, value, decimalRegex, invalidTypeCode, errorClass) && (!scoreRange || checkValidRange(CMIElement, value, scoreRange, invalidRangeCode, errorClass));
3552
3626
  }
3553
3627
  /**
@@ -3643,6 +3717,21 @@ ${stackTrace}`);
3643
3717
  __publicField$9(this, "_offlineStorageService");
3644
3718
  __publicField$9(this, "_cmiValueAccessService");
3645
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());
3646
3735
  __publicField$9(this, "startingData");
3647
3736
  __publicField$9(this, "currentState");
3648
3737
  if (new.target === BaseAPI) {
@@ -3789,6 +3878,7 @@ ${stackTrace}`);
3789
3878
  this.lastErrorCode = "0";
3790
3879
  this._eventService.reset();
3791
3880
  this.startingData = {};
3881
+ this._setCMIElements.clear();
3792
3882
  if (this._offlineStorageService) {
3793
3883
  this._offlineStorageService.updateSettings(this.settings);
3794
3884
  if (settings?.courseId) {
@@ -3872,6 +3962,54 @@ ${stackTrace}`);
3872
3962
  this._loggingService?.setLogHandler(settings.onLogMessage);
3873
3963
  }
3874
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
+ }
3875
4013
  /**
3876
4014
  * Terminates the current run of the API
3877
4015
  * @param {string} callbackName
@@ -3892,7 +4030,7 @@ ${stackTrace}`);
3892
4030
  } else {
3893
4031
  stateCheckPassed = true;
3894
4032
  this.processListeners("BeforeTerminate");
3895
- const result = this.storeData(true);
4033
+ const result = this.storeData(true, "terminate");
3896
4034
  if ((result.errorCode ?? 0) > 0) {
3897
4035
  if (result.errorMessage) {
3898
4036
  this.apiLog("terminate", `Terminate failed with error: ${result.errorMessage}`, LogLevelEnum.ERROR);
@@ -3932,6 +4070,9 @@ ${stackTrace}`);
3932
4070
  } catch (e) {
3933
4071
  returnValue = this.handleValueAccessException(CMIElement, e, returnValue);
3934
4072
  }
4073
+ if (this.lastErrorCode === "0") {
4074
+ this.checkUninitializedGet(CMIElement, returnValue);
4075
+ }
3935
4076
  this.processListeners(callbackName, CMIElement);
3936
4077
  }
3937
4078
  this.apiLog(callbackName, ": returned: " + returnValue, LogLevelEnum.INFO, CMIElement);
@@ -3941,6 +4082,10 @@ ${stackTrace}`);
3941
4082
  if (this.lastErrorCode === "0") {
3942
4083
  this.clearSCORMError(returnValue);
3943
4084
  }
4085
+ const rawReturn = returnValue;
4086
+ if (typeof rawReturn === "number" || typeof rawReturn === "boolean") {
4087
+ return String(rawReturn);
4088
+ }
3944
4089
  return returnValue;
3945
4090
  }
3946
4091
  /**
@@ -3984,10 +4129,12 @@ ${stackTrace}`);
3984
4129
  * Orders LMS to store all content parameters
3985
4130
  * @param {string} callbackName
3986
4131
  * @param {boolean} checkTerminated
4132
+ * @param {CommitTrigger} trigger - What initiated the commit
3987
4133
  * @return {string}
3988
4134
  */
3989
4135
  commit(callbackName) {
3990
4136
  let checkTerminated = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false;
4137
+ let trigger = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : "manual";
3991
4138
  this.clearScheduledCommit();
3992
4139
  let returnValue = global_constants.SCORM_TRUE;
3993
4140
  if (this.isNotInitialized()) {
@@ -3999,7 +4146,7 @@ ${stackTrace}`);
3999
4146
  this.throwSCORMError("api", errorCode);
4000
4147
  if (errorCode === 143) returnValue = global_constants.SCORM_FALSE;
4001
4148
  } else {
4002
- const result = this.storeData(false);
4149
+ const result = this.storeData(false, trigger);
4003
4150
  const errorCode = result.errorCode ?? 0;
4004
4151
  if (errorCode > 0) {
4005
4152
  if (result.errorMessage) {
@@ -4220,7 +4367,11 @@ ${stackTrace}`);
4220
4367
  * @return {string}
4221
4368
  */
4222
4369
  _commonSetCMIValue(methodName, scorm2004, CMIElement, value) {
4223
- 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;
4224
4375
  }
4225
4376
  /**
4226
4377
  * Gets a value from the CMI Object.
@@ -4234,6 +4385,17 @@ ${stackTrace}`);
4234
4385
  _commonGetCMIValue(methodName, scorm2004, CMIElement) {
4235
4386
  return this._cmiValueAccessService.getCMIValue(methodName, scorm2004, CMIElement);
4236
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) {}
4237
4399
  /**
4238
4400
  * Returns true if the API's current state is STATE_INITIALIZED
4239
4401
  *
@@ -4316,9 +4478,14 @@ ${stackTrace}`);
4316
4478
  * @param {string} functionName - The name of the function/event that occurred
4317
4479
  * @param {string} CMIElement - Optional CMI element involved in the event
4318
4480
  * @param {any} value - Optional value associated with the event
4481
+ * @param {CommitEventContext} context - Optional context for commit lifecycle events
4319
4482
  */
4320
- processListeners(functionName, CMIElement, value) {
4321
- 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
+ }
4322
4489
  }
4323
4490
  /**
4324
4491
  * Throws a SCORM error with the specified error number and optional message.
@@ -4440,23 +4607,97 @@ ${stackTrace}`);
4440
4607
  * @param {string} url - The URL to send the request to
4441
4608
  * @param {CommitObject | StringKeyMap | Array<any>} params - The parameters to send
4442
4609
  * @param {boolean} immediate - Whether to send the request immediately without waiting
4610
+ * @param {CommitTrigger} [trigger] - What initiated the commit
4443
4611
  * @returns {ResultObject} - The result of the request
4444
4612
  */
4445
4613
  processHttpRequest(url, params) {
4446
4614
  let immediate = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false;
4447
- if (this.settings.enableOfflineSupport && this._offlineStorageService && !this._offlineStorageService.isDeviceOnline() && this._courseId) {
4448
- this.apiLog("processHttpRequest", "Device is offline, storing data locally", LogLevelEnum.INFO);
4449
- if (params && typeof params === "object" && "cmi" in params) {
4450
- return this._offlineStorageService.storeOffline(this._courseId, params);
4451
- } else {
4452
- this.apiLog("processHttpRequest", "Invalid commit data format for offline storage", LogLevelEnum.ERROR);
4453
- return {
4454
- result: global_constants.SCORM_FALSE,
4455
- errorCode: this._error_codes.GENERAL ?? 101
4456
- };
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();
4457
4699
  }
4458
4700
  }
4459
- 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));
4460
4701
  }
4461
4702
  /**
4462
4703
  * Schedules a commit operation to occur after a specified delay.
@@ -4640,6 +4881,11 @@ ${stackTrace}`);
4640
4881
  __publicField$8(this, "__invalid_range_code");
4641
4882
  __publicField$8(this, "__decimal_regex");
4642
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");
4643
4889
  __publicField$8(this, "_raw", "");
4644
4890
  __publicField$8(this, "_min", "");
4645
4891
  __publicField$8(this, "_max");
@@ -4651,6 +4897,7 @@ ${stackTrace}`);
4651
4897
  this.__invalid_range_code = params.invalidRangeCode || scorm12_errors.VALUE_OUT_OF_RANGE;
4652
4898
  this.__decimal_regex = params.decimalRegex || scorm12_regex.CMIDecimal;
4653
4899
  this.__error_class = params.errorClass;
4900
+ this.__allow_empty_string = params.allowEmptyString ?? false;
4654
4901
  }
4655
4902
  /**
4656
4903
  * Called when the API has been reset
@@ -4690,7 +4937,7 @@ ${stackTrace}`);
4690
4937
  * @param {string} raw
4691
4938
  */
4692
4939
  set raw(raw) {
4693
- 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)) {
4694
4941
  this._raw = raw;
4695
4942
  }
4696
4943
  }
@@ -4706,7 +4953,7 @@ ${stackTrace}`);
4706
4953
  * @param {string} min
4707
4954
  */
4708
4955
  set min(min) {
4709
- 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)) {
4710
4957
  this._min = min;
4711
4958
  }
4712
4959
  }
@@ -4722,7 +4969,7 @@ ${stackTrace}`);
4722
4969
  * @param {string} max
4723
4970
  */
4724
4971
  set max(max) {
4725
- 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)) {
4726
4973
  this._max = max;
4727
4974
  }
4728
4975
  }
@@ -4799,7 +5046,9 @@ ${stackTrace}`);
4799
5046
  invalidErrorCode: scorm12_errors.INVALID_SET_VALUE,
4800
5047
  invalidTypeCode: scorm12_errors.TYPE_MISMATCH,
4801
5048
  invalidRangeCode: scorm12_errors.VALUE_OUT_OF_RANGE,
4802
- 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
4803
5052
  });
4804
5053
  }
4805
5054
  /**
@@ -5167,7 +5416,9 @@ ${stackTrace}`);
5167
5416
  invalidErrorCode: scorm12_errors.INVALID_SET_VALUE,
5168
5417
  invalidTypeCode: scorm12_errors.TYPE_MISMATCH,
5169
5418
  invalidRangeCode: scorm12_errors.VALUE_OUT_OF_RANGE,
5170
- 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
5171
5422
  });
5172
5423
  }
5173
5424
  /**
@@ -6604,16 +6855,17 @@ ${stackTrace}`);
6604
6855
  * Attempts to store the data to the LMS
6605
6856
  *
6606
6857
  * @param {boolean} terminateCommit
6858
+ * @param {CommitTrigger} [trigger] - What initiated the commit
6607
6859
  * @return {ResultObject}
6608
6860
  */
6609
- storeData(terminateCommit) {
6861
+ storeData(terminateCommit, trigger) {
6610
6862
  if (terminateCommit) {
6611
6863
  const originalStatus = this.cmi.core.lesson_status;
6612
6864
  if (this.cmi.core.lesson_mode === "browse") {
6613
6865
  const startingStatus = this.startingData?.cmi?.core?.lesson_status || "";
6614
6866
  if (startingStatus === "" && originalStatus === "not attempted") {
6615
6867
  this.cmi.core.lesson_status = "browsed";
6616
- return this.processCommitData(terminateCommit);
6868
+ return this.processCommitData(terminateCommit, trigger);
6617
6869
  }
6618
6870
  }
6619
6871
  if (!this.cmi.core.lesson_status || !this.statusSetByModule && this.cmi.core.lesson_status === "not attempted") {
@@ -6642,12 +6894,12 @@ ${stackTrace}`);
6642
6894
  }
6643
6895
  }
6644
6896
  }
6645
- return this.processCommitData(terminateCommit);
6897
+ return this.processCommitData(terminateCommit, trigger);
6646
6898
  }
6647
- processCommitData(terminateCommit) {
6899
+ processCommitData(terminateCommit, trigger) {
6648
6900
  const commitObject = this.getCommitObject(terminateCommit);
6649
6901
  if (typeof this.settings.lmsCommitUrl === "string") {
6650
- return this.processHttpRequest(this.settings.lmsCommitUrl, commitObject, terminateCommit);
6902
+ return this.processHttpRequest(this.settings.lmsCommitUrl, commitObject, terminateCommit, trigger);
6651
6903
  } else {
6652
6904
  return {
6653
6905
  result: global_constants.SCORM_TRUE,