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
@@ -161,6 +161,15 @@ function memoize(fn, keyFn) {
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
  class BaseCMI {
165
174
  /**
166
175
  * Flag used during JSON serialization to allow getter access without initialization checks.
@@ -408,7 +417,10 @@ const scorm12_errors = {
408
417
  READ_ONLY_ELEMENT: 403,
409
418
  WRITE_ONLY_ELEMENT: 404,
410
419
  TYPE_MISMATCH: 405,
411
- VALUE_OUT_OF_RANGE: 407,
420
+ // SCORM 1.2 has no out-of-range error code; an out-of-range value is reported
421
+ // as 405 (incorrect data type), per the SCORM 1.2 RTE error table and the ADL
422
+ // 1.2 CTS (DataModelValidator.checkScoreDecimal failure -> CMICategory 405).
423
+ VALUE_OUT_OF_RANGE: 405,
412
424
  DEPENDENCY_NOT_ESTABLISHED: 408
413
425
  };
414
426
 
@@ -523,6 +535,7 @@ const DefaultSettings = {
523
535
  xhrWithCredentials: false,
524
536
  fetchMode: "cors",
525
537
  asyncModeBeaconBehavior: "never",
538
+ includeCommitSequence: false,
526
539
  responseHandler: async function(response) {
527
540
  if (typeof response !== "undefined") {
528
541
  let httpResult = null;
@@ -907,13 +920,14 @@ class ScheduledCommit {
907
920
  wrapper() {
908
921
  if (!this._cancelled) {
909
922
  if (this._API.isInitialized()) {
910
- (async () => await this._API.commit(this._callback))();
923
+ (async () => await this._API.commit(this._callback, false, "autocommit"))();
911
924
  }
912
925
  }
913
926
  }
914
927
  }
915
928
 
916
929
  class AsynchronousHttpService {
930
+ reportsRequestCompletion = true;
917
931
  settings;
918
932
  error_codes;
919
933
  /**
@@ -938,10 +952,20 @@ class AsynchronousHttpService {
938
952
  * @param {boolean} immediate - Whether to send the request immediately without waiting
939
953
  * @param {Function} apiLog - Function to log API messages with appropriate levels
940
954
  * @param {Function} processListeners - Function to trigger event listeners for commit events
955
+ * @param {CommitMetadata} metadata - Metadata describing the captured commit
956
+ * @param {Function} onRequestComplete - Callback invoked after the background request settles
941
957
  * @return {ResultObject} - Immediate optimistic success result
942
958
  */
943
- processHttpRequest(url, params, immediate = false, apiLog, processListeners) {
944
- this._performAsyncRequest(url, params, immediate, apiLog, processListeners);
959
+ processHttpRequest(url, params, immediate = false, apiLog, processListeners, metadata, onRequestComplete) {
960
+ this._performAsyncRequest(
961
+ url,
962
+ params,
963
+ immediate,
964
+ apiLog,
965
+ processListeners,
966
+ metadata,
967
+ onRequestComplete
968
+ );
945
969
  return {
946
970
  result: global_constants.SCORM_TRUE,
947
971
  errorCode: 0
@@ -954,11 +978,14 @@ class AsynchronousHttpService {
954
978
  * @param {boolean} immediate - Whether this is an immediate request
955
979
  * @param apiLog - Function to log API messages
956
980
  * @param {Function} processListeners - Function to process event listeners
981
+ * @param {CommitMetadata} metadata - Metadata describing the captured commit
982
+ * @param {Function} onRequestComplete - Callback invoked after the request settles
957
983
  * @private
958
984
  */
959
- async _performAsyncRequest(url, params, immediate, apiLog, processListeners) {
985
+ async _performAsyncRequest(url, params, immediate, apiLog, processListeners, metadata, onRequestComplete) {
960
986
  try {
961
- const processedParams = this.settings.requestHandler(params);
987
+ const handledParams = metadata === void 0 ? this.settings.requestHandler(params) : this.settings.requestHandler(params, metadata);
988
+ const processedParams = handledParams;
962
989
  let response;
963
990
  if (immediate && this.settings.asyncModeBeaconBehavior !== "never") {
964
991
  response = await this.performBeacon(url, processedParams);
@@ -974,7 +1001,9 @@ class AsynchronousHttpService {
974
1001
  } catch (e) {
975
1002
  const message = e instanceof Error ? e.message : String(e);
976
1003
  apiLog("processHttpRequest", `Async request failed: ${message}`, LogLevelEnum.ERROR);
977
- processListeners("CommitError");
1004
+ processListeners("CommitError", void 0, this.error_codes.GENERAL_COMMIT_FAILURE || 391);
1005
+ } finally {
1006
+ onRequestComplete?.();
978
1007
  }
979
1008
  }
980
1009
  /**
@@ -1117,10 +1146,13 @@ class CMIValueAccessService {
1117
1146
  }
1118
1147
  /**
1119
1148
  * Gets the appropriate error code for undefined data model elements.
1120
- * SCORM 2004 uses UNDEFINED_DATA_MODEL, SCORM 1.2 uses GENERAL.
1149
+ * Both SCORM 2004 and SCORM 1.2 use UNDEFINED_DATA_MODEL (401): an
1150
+ * unrecognized element is "Not implemented", not a general exception. SCORM
1151
+ * 1.2 previously returned GENERAL (101) here, which is non-conformant — the
1152
+ * ADL 1.2 CTS and the SCORM 1.2 RTE spec expect 401 for an unknown element.
1121
1153
  */
1122
- getUndefinedDataModelErrorCode(scorm2004) {
1123
- return scorm2004 ? getErrorCode(this.context.errorCodes, "UNDEFINED_DATA_MODEL") : getErrorCode(this.context.errorCodes, "GENERAL");
1154
+ getUndefinedDataModelErrorCode() {
1155
+ return getErrorCode(this.context.errorCodes, "UNDEFINED_DATA_MODEL");
1124
1156
  }
1125
1157
  /**
1126
1158
  * Sets a value on a CMI element path
@@ -1148,7 +1180,7 @@ class CMIValueAccessService {
1148
1180
  let returnValue = global_constants.SCORM_FALSE;
1149
1181
  let foundFirstIndex = false;
1150
1182
  const invalidErrorMessage = `The data model element passed to ${methodName} (${CMIElement}) is not a valid SCORM data model element.`;
1151
- const invalidErrorCode = this.getUndefinedDataModelErrorCode(scorm2004);
1183
+ const invalidErrorCode = this.getUndefinedDataModelErrorCode();
1152
1184
  for (let idx = 0; idx < structure.length; idx++) {
1153
1185
  const attribute = structure[idx];
1154
1186
  if (idx === structure.length - 1) {
@@ -1218,12 +1250,13 @@ class CMIValueAccessService {
1218
1250
  );
1219
1251
  return "";
1220
1252
  }
1253
+ this.context.setLastErrorCode("0");
1221
1254
  const structure = CMIElement.split(".");
1222
1255
  let refObject = this.context.getDataModel();
1223
1256
  let attribute = null;
1224
1257
  const uninitializedErrorMessage = `The data model element passed to ${methodName} (${CMIElement}) has not been initialized.`;
1225
1258
  const invalidErrorMessage = `The data model element passed to ${methodName} (${CMIElement}) is not a valid SCORM data model element.`;
1226
- const invalidErrorCode = this.getUndefinedDataModelErrorCode(scorm2004);
1259
+ const invalidErrorCode = this.getUndefinedDataModelErrorCode();
1227
1260
  for (let idx = 0; idx < structure.length; idx++) {
1228
1261
  attribute = structure[idx];
1229
1262
  const validationResult = this.validateGetAttribute(
@@ -1418,7 +1451,19 @@ class CMIValueAccessService {
1418
1451
  if (!scorm2004) {
1419
1452
  if (isFinalAttribute) {
1420
1453
  if (typeof attribute === "undefined" || !this.context.checkObjectHasProperty(refObject, attribute)) {
1421
- this.context.throwSCORMError(CMIElement, invalidErrorCode, invalidErrorMessage);
1454
+ if (attribute === "_children") {
1455
+ this.context.throwSCORMError(
1456
+ CMIElement,
1457
+ getErrorCode(this.context.errorCodes, "CHILDREN_ERROR")
1458
+ );
1459
+ } else if (attribute === "_count") {
1460
+ this.context.throwSCORMError(
1461
+ CMIElement,
1462
+ getErrorCode(this.context.errorCodes, "COUNT_ERROR")
1463
+ );
1464
+ } else {
1465
+ this.context.throwSCORMError(CMIElement, invalidErrorCode, invalidErrorMessage);
1466
+ }
1422
1467
  return { error: true };
1423
1468
  }
1424
1469
  }
@@ -1923,8 +1968,9 @@ class EventService {
1923
1968
  * @param {string} functionName - The name of the function that triggered the event
1924
1969
  * @param {string} CMIElement - The CMI element that was affected
1925
1970
  * @param {any} value - The value that was set
1971
+ * @param {CommitEventContext} context - Optional context for commit lifecycle events
1926
1972
  */
1927
- processListeners(functionName, CMIElement, value) {
1973
+ processListeners(functionName, CMIElement, value, context) {
1928
1974
  this.apiLog(functionName, value, LogLevelEnum.INFO, CMIElement);
1929
1975
  const listeners = this.listenerMap.get(functionName);
1930
1976
  if (!listeners) return;
@@ -1949,9 +1995,17 @@ class EventService {
1949
1995
  if (functionName.startsWith("Sequence")) {
1950
1996
  listener.callback(value);
1951
1997
  } else if (functionName === "CommitError") {
1952
- listener.callback(value);
1998
+ if (context !== void 0) {
1999
+ listener.callback(value, context);
2000
+ } else {
2001
+ listener.callback(value);
2002
+ }
1953
2003
  } else if (functionName === "CommitSuccess") {
1954
- listener.callback();
2004
+ if (context !== void 0) {
2005
+ listener.callback(context);
2006
+ } else {
2007
+ listener.callback();
2008
+ }
1955
2009
  } else {
1956
2010
  listener.callback(CMIElement, value);
1957
2011
  }
@@ -2085,16 +2139,19 @@ class OfflineStorageService {
2085
2139
  * Store commit data offline
2086
2140
  * @param {string} courseId - Identifier for the course
2087
2141
  * @param {CommitObject} commitData - The data to store offline
2142
+ * @param {OfflineCommitMetadata} metadata - Metadata captured with the original commit
2088
2143
  * @returns {ResultObject} - Result of the storage operation
2089
2144
  */
2090
- storeOffline(courseId, commitData) {
2145
+ storeOffline(courseId, commitData, metadata) {
2091
2146
  try {
2092
2147
  const queueItem = {
2093
2148
  id: `${courseId}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
2094
2149
  courseId,
2095
2150
  timestamp: Date.now(),
2096
2151
  data: commitData,
2097
- syncAttempts: 0
2152
+ syncAttempts: 0,
2153
+ ...metadata?.isTerminateCommit !== void 0 ? { isTerminateCommit: metadata.isTerminateCommit } : {},
2154
+ ...metadata?.sequence !== void 0 ? { sequence: metadata.sequence } : {}
2098
2155
  };
2099
2156
  const currentQueue = this.getFromStorage(this.syncQueue) || [];
2100
2157
  currentQueue.push(queueItem);
@@ -2173,7 +2230,10 @@ class OfflineStorageService {
2173
2230
  continue;
2174
2231
  }
2175
2232
  try {
2176
- const syncResult = await this.sendDataToLMS(item.data);
2233
+ const syncResult = await this.sendDataToLMS(item.data, {
2234
+ ...item.isTerminateCommit !== void 0 ? { isTerminateCommit: item.isTerminateCommit } : {},
2235
+ ...item.sequence !== void 0 ? { sequence: item.sequence } : {}
2236
+ });
2177
2237
  if (syncResult.result === true || syncResult.result === global_constants.SCORM_TRUE) {
2178
2238
  this.apiLog(
2179
2239
  "OfflineStorageService",
@@ -2220,17 +2280,25 @@ class OfflineStorageService {
2220
2280
  /**
2221
2281
  * Send data to the LMS when online
2222
2282
  * @param {CommitObject} data - The data to send to the LMS
2283
+ * @param {OfflineCommitMetadata} metadata - Metadata captured with the original commit
2223
2284
  * @returns {Promise<ResultObject>} - Result of the sync operation
2224
2285
  */
2225
- async sendDataToLMS(data) {
2226
- if (!this.settings.lmsCommitUrl) {
2286
+ async sendDataToLMS(data, metadata) {
2287
+ const configuredCommitUrl = this.settings.lmsCommitUrl;
2288
+ if (!configuredCommitUrl) {
2227
2289
  return {
2228
2290
  result: global_constants.SCORM_FALSE,
2229
2291
  errorCode: this.error_codes.GENERAL || 101
2230
2292
  };
2231
2293
  }
2232
2294
  try {
2233
- const processedData = this.settings.requestHandler(data);
2295
+ const lmsCommitUrl = String(configuredCommitUrl);
2296
+ const processedData = this.settings.requestHandler(data, {
2297
+ isTerminateCommit: metadata?.isTerminateCommit ?? false,
2298
+ trigger: "offline-replay",
2299
+ ...metadata?.sequence !== void 0 ? { sequence: metadata.sequence } : {}
2300
+ });
2301
+ const requestUrl = metadata?.isTerminateCommit && this.settings.terminateCommitParam ? appendQueryParam(lmsCommitUrl, this.settings.terminateCommitParam, "true") : lmsCommitUrl;
2234
2302
  const init = {
2235
2303
  method: "POST",
2236
2304
  mode: this.settings.fetchMode,
@@ -2243,7 +2311,7 @@ class OfflineStorageService {
2243
2311
  if (this.settings.xhrWithCredentials) {
2244
2312
  init.credentials = "include";
2245
2313
  }
2246
- const response = await fetch(this.settings.lmsCommitUrl, init);
2314
+ const response = await fetch(requestUrl, init);
2247
2315
  const result = typeof this.settings.responseHandler === "function" ? await this.settings.responseHandler(response) : await response.json();
2248
2316
  if (response.status >= 200 && response.status <= 299 && (result.result === true || result.result === global_constants.SCORM_TRUE)) {
2249
2317
  if (!Object.hasOwnProperty.call(result, "errorCode")) {
@@ -2640,6 +2708,8 @@ class SynchronousHttpService {
2640
2708
  * @param {boolean} immediate - Whether this is a termination commit (use sendBeacon)
2641
2709
  * @param {Function} _apiLog - Function to log API messages (unused in synchronous mode - errors returned directly)
2642
2710
  * @param {Function} _processListeners - Function to trigger event listeners (unused in synchronous mode - no async events)
2711
+ * @param {CommitMetadata} metadata - Metadata describing the captured commit
2712
+ * @param {Function} _onRequestComplete - Completion callback (unused because requests settle before return)
2643
2713
  * @return {ResultObject} - The result of the request (synchronous)
2644
2714
  *
2645
2715
  * @remarks
@@ -2649,21 +2719,23 @@ class SynchronousHttpService {
2649
2719
  * - No async events need to be triggered (CommitSuccess/CommitError) since results are synchronous
2650
2720
  * - AsynchronousHttpService uses these parameters to handle background request results
2651
2721
  */
2652
- processHttpRequest(url, params, immediate = false, _apiLog, _processListeners) {
2722
+ processHttpRequest(url, params, immediate = false, _apiLog, _processListeners, metadata, _onRequestComplete) {
2653
2723
  if (immediate) {
2654
- return this._handleImmediateRequest(url, params);
2724
+ return this._handleImmediateRequest(url, params, metadata);
2655
2725
  }
2656
- return this._performSyncXHR(url, params);
2726
+ return this._performSyncXHR(url, params, metadata);
2657
2727
  }
2658
2728
  /**
2659
2729
  * Handles an immediate request using sendBeacon
2660
2730
  * @param {string} url - The URL to send the request to
2661
2731
  * @param {CommitObject|StringKeyMap|Array} params - The parameters to include in the request
2732
+ * @param {CommitMetadata} metadata - Metadata describing the captured commit
2662
2733
  * @return {ResultObject} - The result based on beacon success
2663
2734
  * @private
2664
2735
  */
2665
- _handleImmediateRequest(url, params) {
2666
- const requestPayload = this.settings.requestHandler(params) ?? params;
2736
+ _handleImmediateRequest(url, params, metadata) {
2737
+ const handledPayload = metadata === void 0 ? this.settings.requestHandler(params) : this.settings.requestHandler(params, metadata);
2738
+ const requestPayload = handledPayload ?? params;
2667
2739
  const { body } = this._prepareRequestBody(requestPayload);
2668
2740
  const beaconSuccess = navigator.sendBeacon(
2669
2741
  url,
@@ -2678,11 +2750,13 @@ class SynchronousHttpService {
2678
2750
  * Performs a synchronous XMLHttpRequest
2679
2751
  * @param {string} url - The URL to send the request to
2680
2752
  * @param {CommitObject|StringKeyMap|Array} params - The parameters to include in the request
2753
+ * @param {CommitMetadata} metadata - Metadata describing the captured commit
2681
2754
  * @return {ResultObject} - The result of the request
2682
2755
  * @private
2683
2756
  */
2684
- _performSyncXHR(url, params) {
2685
- const requestPayload = this.settings.requestHandler(params) ?? params;
2757
+ _performSyncXHR(url, params, metadata) {
2758
+ const handledPayload = metadata === void 0 ? this.settings.requestHandler(params) : this.settings.requestHandler(params, metadata);
2759
+ const requestPayload = handledPayload ?? params;
2686
2760
  const { body, contentType } = this._prepareRequestBody(requestPayload);
2687
2761
  const xhr = new XMLHttpRequest();
2688
2762
  xhr.open("POST", url, false);
@@ -2761,9 +2835,15 @@ class ValidationService {
2761
2835
  * @param {number} invalidTypeCode - The error code for invalid type
2762
2836
  * @param {number} invalidRangeCode - The error code for invalid range
2763
2837
  * @param {typeof BaseScormValidationError} errorClass - The error class to use for validation errors
2838
+ * @param {boolean} allowEmptyString - When true, an empty string is accepted (clears the value).
2839
+ * SCORM 1.2 score elements may be blank per the ADL 1.2 CTS
2840
+ * (DataModelValidator.checkScoreDecimal treats blank as valid).
2764
2841
  * @return {boolean} - True if validation passes, throws an error otherwise
2765
2842
  */
2766
- validateScore(CMIElement, value, decimalRegex, scoreRange, invalidTypeCode, invalidRangeCode, errorClass) {
2843
+ validateScore(CMIElement, value, decimalRegex, scoreRange, invalidTypeCode, invalidRangeCode, errorClass, allowEmptyString = false) {
2844
+ if (allowEmptyString && value === "") {
2845
+ return true;
2846
+ }
2767
2847
  return checkValidFormat(CMIElement, value, decimalRegex, invalidTypeCode, errorClass) && (!scoreRange || checkValidRange(CMIElement, value, scoreRange, invalidRangeCode, errorClass));
2768
2848
  }
2769
2849
  /**
@@ -2837,6 +2917,21 @@ class BaseAPI {
2837
2917
  _offlineStorageService;
2838
2918
  _cmiValueAccessService;
2839
2919
  _courseId = "";
2920
+ _pendingCommitCount = 0;
2921
+ /**
2922
+ * Monotonic sequence for commits captured by this API instance. It is
2923
+ * intentionally not reset by reset().
2924
+ */
2925
+ _commitSequence = 0;
2926
+ _commitSettleWaiters = [];
2927
+ /**
2928
+ * Canonical paths of every CMI element that has been explicitly assigned a
2929
+ * value via SetValue / loadFromJSON (both funnel through _commonSetCMIValue).
2930
+ * Used by standards that must tell "implemented but never set" apart from a
2931
+ * legitimately empty value when answering GetValue (SCORM 2004 error 403).
2932
+ * Cleared on reset so a fresh SCO attempt starts with nothing "set".
2933
+ */
2934
+ _setCMIElements = /* @__PURE__ */ new Set();
2840
2935
  /**
2841
2936
  * Constructor for Base API class. Sets some shared API fields, as well as
2842
2937
  * sets up options for the API.
@@ -3022,6 +3117,7 @@ class BaseAPI {
3022
3117
  this.lastErrorCode = "0";
3023
3118
  this._eventService.reset();
3024
3119
  this.startingData = {};
3120
+ this._setCMIElements.clear();
3025
3121
  if (this._offlineStorageService) {
3026
3122
  this._offlineStorageService.updateSettings(this.settings);
3027
3123
  if (settings?.courseId) {
@@ -3106,6 +3202,52 @@ class BaseAPI {
3106
3202
  this._loggingService?.setLogHandler(settings.onLogMessage);
3107
3203
  }
3108
3204
  }
3205
+ /**
3206
+ * Gets the number of captured commit requests that have not yet settled.
3207
+ *
3208
+ * @return {number} The number of in-flight commits
3209
+ */
3210
+ get pendingCommitCount() {
3211
+ return this._pendingCommitCount;
3212
+ }
3213
+ /**
3214
+ * Resolves when all currently in-flight commits have settled. A timeout is
3215
+ * best-effort: the promise resolves when it elapses even if commits remain,
3216
+ * and callers can inspect pendingCommitCount afterward to detect that case.
3217
+ *
3218
+ * @param {Object} [options] - Settle options
3219
+ * @param {number} [options.timeoutMs] - Maximum time to wait in milliseconds
3220
+ * @return {Promise<void>} A promise that resolves after the drain or timeout
3221
+ */
3222
+ whenCommitsSettled(options) {
3223
+ if (this._pendingCommitCount === 0) {
3224
+ return Promise.resolve();
3225
+ }
3226
+ return new Promise((resolve) => {
3227
+ const waiter = { resolve };
3228
+ if (options?.timeoutMs !== void 0) {
3229
+ waiter.timeoutId = setTimeout(() => {
3230
+ const waiterIndex = this._commitSettleWaiters.indexOf(waiter);
3231
+ if (waiterIndex === -1) {
3232
+ return;
3233
+ }
3234
+ this._commitSettleWaiters.splice(waiterIndex, 1);
3235
+ resolve();
3236
+ }, options.timeoutMs);
3237
+ }
3238
+ this._commitSettleWaiters.push(waiter);
3239
+ });
3240
+ }
3241
+ /** Resolve and clear every waiter after the pending count reaches zero. */
3242
+ _flushCommitSettleWaiters() {
3243
+ const waiters = this._commitSettleWaiters.splice(0);
3244
+ for (const waiter of waiters) {
3245
+ if (waiter.timeoutId !== void 0) {
3246
+ clearTimeout(waiter.timeoutId);
3247
+ }
3248
+ waiter.resolve();
3249
+ }
3250
+ }
3109
3251
  /**
3110
3252
  * Terminates the current run of the API
3111
3253
  * @param {string} callbackName
@@ -3126,7 +3268,7 @@ class BaseAPI {
3126
3268
  } else {
3127
3269
  stateCheckPassed = true;
3128
3270
  this.processListeners("BeforeTerminate");
3129
- const result = this.storeData(true);
3271
+ const result = this.storeData(true, "terminate");
3130
3272
  if ((result.errorCode ?? 0) > 0) {
3131
3273
  if (result.errorMessage) {
3132
3274
  this.apiLog(
@@ -3178,6 +3320,9 @@ class BaseAPI {
3178
3320
  } catch (e) {
3179
3321
  returnValue = this.handleValueAccessException(CMIElement, e, returnValue);
3180
3322
  }
3323
+ if (this.lastErrorCode === "0") {
3324
+ this.checkUninitializedGet(CMIElement, returnValue);
3325
+ }
3181
3326
  this.processListeners(callbackName, CMIElement);
3182
3327
  }
3183
3328
  this.apiLog(callbackName, ": returned: " + returnValue, LogLevelEnum.INFO, CMIElement);
@@ -3187,6 +3332,10 @@ class BaseAPI {
3187
3332
  if (this.lastErrorCode === "0") {
3188
3333
  this.clearSCORMError(returnValue);
3189
3334
  }
3335
+ const rawReturn = returnValue;
3336
+ if (typeof rawReturn === "number" || typeof rawReturn === "boolean") {
3337
+ return String(rawReturn);
3338
+ }
3190
3339
  return returnValue;
3191
3340
  }
3192
3341
  /**
@@ -3239,9 +3388,10 @@ class BaseAPI {
3239
3388
  * Orders LMS to store all content parameters
3240
3389
  * @param {string} callbackName
3241
3390
  * @param {boolean} checkTerminated
3391
+ * @param {CommitTrigger} trigger - What initiated the commit
3242
3392
  * @return {string}
3243
3393
  */
3244
- commit(callbackName, checkTerminated = false) {
3394
+ commit(callbackName, checkTerminated = false, trigger = "manual") {
3245
3395
  this.clearScheduledCommit();
3246
3396
  let returnValue = global_constants.SCORM_TRUE;
3247
3397
  if (this.isNotInitialized()) {
@@ -3253,8 +3403,9 @@ class BaseAPI {
3253
3403
  this.throwSCORMError("api", errorCode);
3254
3404
  if (errorCode === 143) returnValue = global_constants.SCORM_FALSE;
3255
3405
  } else {
3256
- const result = this.storeData(false);
3257
- if ((result.errorCode ?? 0) > 0) {
3406
+ const result = this.storeData(false, trigger);
3407
+ const errorCode = result.errorCode ?? 0;
3408
+ if (errorCode > 0) {
3258
3409
  if (result.errorMessage) {
3259
3410
  this.apiLog(
3260
3411
  "commit",
@@ -3269,12 +3420,12 @@ class BaseAPI {
3269
3420
  LogLevelEnum.DEBUG
3270
3421
  );
3271
3422
  }
3272
- this.throwSCORMError("api", result.errorCode);
3423
+ this.throwSCORMError("api", errorCode);
3273
3424
  }
3274
3425
  const resultValue = result?.result ?? global_constants.SCORM_FALSE;
3275
3426
  returnValue = typeof resultValue === "boolean" ? String(resultValue) : resultValue;
3276
3427
  this.apiLog(callbackName, " Result: " + returnValue, LogLevelEnum.DEBUG, "HttpRequest");
3277
- if (checkTerminated) this.lastErrorCode = "0";
3428
+ if (checkTerminated && errorCode === 0) this.lastErrorCode = "0";
3278
3429
  this.processListeners(callbackName);
3279
3430
  if (this.settings.enableOfflineSupport && this._offlineStorageService && this._offlineStorageService.isDeviceOnline() && this._courseId) {
3280
3431
  this._offlineStorageService.hasPendingOfflineData(this._courseId).then((hasPendingData) => {
@@ -3483,7 +3634,16 @@ class BaseAPI {
3483
3634
  * @return {string}
3484
3635
  */
3485
3636
  _commonSetCMIValue(methodName, scorm2004, CMIElement, value) {
3486
- return this._cmiValueAccessService.setCMIValue(methodName, scorm2004, CMIElement, value);
3637
+ const result = this._cmiValueAccessService.setCMIValue(
3638
+ methodName,
3639
+ scorm2004,
3640
+ CMIElement,
3641
+ value
3642
+ );
3643
+ if (result === global_constants.SCORM_TRUE) {
3644
+ this._setCMIElements.add(CMIElement);
3645
+ }
3646
+ return result;
3487
3647
  }
3488
3648
  /**
3489
3649
  * Gets a value from the CMI Object.
@@ -3497,6 +3657,18 @@ class BaseAPI {
3497
3657
  _commonGetCMIValue(methodName, scorm2004, CMIElement) {
3498
3658
  return this._cmiValueAccessService.getCMIValue(methodName, scorm2004, CMIElement);
3499
3659
  }
3660
+ /**
3661
+ * Hook invoked by getValue after a successful resolution. Standards that must
3662
+ * distinguish "implemented but never set, no default value" from a
3663
+ * legitimately empty value override this to raise VALUE_NOT_INITIALIZED.
3664
+ * Default is a no-op, so SCORM 1.2 / AICC keep returning "" with code 0.
3665
+ *
3666
+ * @param {string} _CMIElement - the element that was read
3667
+ * @param {any} _returnValue - the value getCMIValue resolved
3668
+ * @protected
3669
+ */
3670
+ checkUninitializedGet(_CMIElement, _returnValue) {
3671
+ }
3500
3672
  /**
3501
3673
  * Returns true if the API's current state is STATE_INITIALIZED
3502
3674
  *
@@ -3579,9 +3751,14 @@ class BaseAPI {
3579
3751
  * @param {string} functionName - The name of the function/event that occurred
3580
3752
  * @param {string} CMIElement - Optional CMI element involved in the event
3581
3753
  * @param {any} value - Optional value associated with the event
3754
+ * @param {CommitEventContext} context - Optional context for commit lifecycle events
3582
3755
  */
3583
- processListeners(functionName, CMIElement, value) {
3584
- this._eventService.processListeners(functionName, CMIElement, value);
3756
+ processListeners(functionName, CMIElement, value, context) {
3757
+ if (context !== void 0) {
3758
+ this._eventService.processListeners(functionName, CMIElement, value, context);
3759
+ } else {
3760
+ this._eventService.processListeners(functionName, CMIElement, value);
3761
+ }
3585
3762
  }
3586
3763
  /**
3587
3764
  * Throws a SCORM error with the specified error number and optional message.
@@ -3714,36 +3891,106 @@ class BaseAPI {
3714
3891
  * @param {string} url - The URL to send the request to
3715
3892
  * @param {CommitObject | StringKeyMap | Array<any>} params - The parameters to send
3716
3893
  * @param {boolean} immediate - Whether to send the request immediately without waiting
3894
+ * @param {CommitTrigger} [trigger] - What initiated the commit
3717
3895
  * @returns {ResultObject} - The result of the request
3718
3896
  */
3719
- processHttpRequest(url, params, immediate = false) {
3720
- if (this.settings.enableOfflineSupport && this._offlineStorageService && !this._offlineStorageService.isDeviceOnline() && this._courseId) {
3721
- this.apiLog(
3722
- "processHttpRequest",
3723
- "Device is offline, storing data locally",
3724
- LogLevelEnum.INFO
3725
- );
3726
- if (params && typeof params === "object" && "cmi" in params) {
3727
- return this._offlineStorageService.storeOffline(this._courseId, params);
3728
- } else {
3897
+ processHttpRequest(url, params, immediate = false, trigger) {
3898
+ const sequence = ++this._commitSequence;
3899
+ this._pendingCommitCount += 1;
3900
+ let settled = false;
3901
+ let completionDeferred = false;
3902
+ const settle = () => {
3903
+ if (settled) {
3904
+ return;
3905
+ }
3906
+ settled = true;
3907
+ this._pendingCommitCount -= 1;
3908
+ if (this._pendingCommitCount === 0) {
3909
+ this._flushCommitSettleWaiters();
3910
+ }
3911
+ };
3912
+ try {
3913
+ const resolvedTrigger = trigger ?? (immediate ? "terminate" : "manual");
3914
+ let finalParams = params;
3915
+ if (immediate && this.settings.terminateCommitPayloadField) {
3916
+ const field = this.settings.terminateCommitPayloadField;
3917
+ if (Array.isArray(finalParams)) {
3918
+ finalParams = [...finalParams, `${encodeURIComponent(field)}=true`];
3919
+ } else if (finalParams && typeof finalParams === "object") {
3920
+ finalParams = { ...finalParams, [field]: true };
3921
+ }
3922
+ }
3923
+ if (this.settings.includeCommitSequence === true) {
3924
+ if (Array.isArray(finalParams)) {
3925
+ finalParams = [...finalParams, `commitSequence=${sequence}`];
3926
+ } else if (finalParams && typeof finalParams === "object") {
3927
+ finalParams = { ...finalParams, commitSequence: sequence };
3928
+ }
3929
+ }
3930
+ const finalUrl = immediate && this.settings.terminateCommitParam ? appendQueryParam(url, this.settings.terminateCommitParam, "true") : url;
3931
+ const metadata = {
3932
+ isTerminateCommit: immediate,
3933
+ trigger: resolvedTrigger,
3934
+ sequence
3935
+ };
3936
+ const context = {
3937
+ url: finalUrl,
3938
+ trigger: resolvedTrigger,
3939
+ isTerminateCommit: immediate,
3940
+ sequence
3941
+ };
3942
+ if (this.settings.enableOfflineSupport && this._offlineStorageService && !this._offlineStorageService.isDeviceOnline() && this._courseId) {
3729
3943
  this.apiLog(
3730
3944
  "processHttpRequest",
3731
- "Invalid commit data format for offline storage",
3732
- LogLevelEnum.ERROR
3945
+ "Device is offline, storing data locally",
3946
+ LogLevelEnum.INFO
3733
3947
  );
3734
- return {
3735
- result: global_constants.SCORM_FALSE,
3736
- errorCode: this._error_codes.GENERAL ?? 101
3737
- };
3948
+ if (finalParams && typeof finalParams === "object" && "cmi" in finalParams) {
3949
+ return this._offlineStorageService.storeOffline(
3950
+ this._courseId,
3951
+ finalParams,
3952
+ { isTerminateCommit: immediate, sequence }
3953
+ );
3954
+ } else {
3955
+ this.apiLog(
3956
+ "processHttpRequest",
3957
+ "Invalid commit data format for offline storage",
3958
+ LogLevelEnum.ERROR
3959
+ );
3960
+ return {
3961
+ result: global_constants.SCORM_FALSE,
3962
+ errorCode: this._error_codes.GENERAL ?? 101
3963
+ };
3964
+ }
3965
+ }
3966
+ const apiLog = (functionName, message, level, element) => this.apiLog(functionName, message, level, element);
3967
+ const processListeners = (functionName, CMIElement, value) => {
3968
+ if (functionName === "CommitSuccess" || functionName === "CommitError") {
3969
+ if (functionName === "CommitError" && typeof value === "number") {
3970
+ context.errorCode = value;
3971
+ }
3972
+ settle();
3973
+ this.processListeners(functionName, CMIElement, value, context);
3974
+ } else {
3975
+ this.processListeners(functionName, CMIElement, value);
3976
+ }
3977
+ };
3978
+ const result = this._httpService.processHttpRequest(
3979
+ finalUrl,
3980
+ finalParams,
3981
+ immediate,
3982
+ apiLog,
3983
+ processListeners,
3984
+ metadata,
3985
+ settle
3986
+ );
3987
+ completionDeferred = this._httpService.reportsRequestCompletion === true;
3988
+ return result;
3989
+ } finally {
3990
+ if (!completionDeferred) {
3991
+ settle();
3738
3992
  }
3739
3993
  }
3740
- return this._httpService.processHttpRequest(
3741
- url,
3742
- params,
3743
- immediate,
3744
- (functionName, message, level, element) => this.apiLog(functionName, message, level, element),
3745
- (functionName, CMIElement, value) => this.processListeners(functionName, CMIElement, value)
3746
- );
3747
3994
  }
3748
3995
  /**
3749
3996
  * Schedules a commit operation to occur after a specified delay.
@@ -3903,6 +4150,11 @@ class CMIScore extends BaseCMI {
3903
4150
  __invalid_range_code;
3904
4151
  __decimal_regex;
3905
4152
  __error_class;
4153
+ /**
4154
+ * When true, an empty string is a valid value (clears the element). SCORM 1.2
4155
+ * score elements may be blank per the ADL 1.2 CTS; SCORM 2004 leaves this off.
4156
+ */
4157
+ __allow_empty_string;
3906
4158
  _raw = "";
3907
4159
  _min = "";
3908
4160
  _max;
@@ -3937,6 +4189,7 @@ class CMIScore extends BaseCMI {
3937
4189
  this.__invalid_range_code = params.invalidRangeCode || scorm12_errors.VALUE_OUT_OF_RANGE;
3938
4190
  this.__decimal_regex = params.decimalRegex || scorm12_regex.CMIDecimal;
3939
4191
  this.__error_class = params.errorClass;
4192
+ this.__allow_empty_string = params.allowEmptyString ?? false;
3940
4193
  }
3941
4194
  /**
3942
4195
  * Called when the API has been reset
@@ -3983,7 +4236,8 @@ class CMIScore extends BaseCMI {
3983
4236
  this.__score_range,
3984
4237
  this.__invalid_type_code,
3985
4238
  this.__invalid_range_code,
3986
- this.__error_class
4239
+ this.__error_class,
4240
+ this.__allow_empty_string
3987
4241
  )) {
3988
4242
  this._raw = raw;
3989
4243
  }
@@ -4007,7 +4261,8 @@ class CMIScore extends BaseCMI {
4007
4261
  this.__score_range,
4008
4262
  this.__invalid_type_code,
4009
4263
  this.__invalid_range_code,
4010
- this.__error_class
4264
+ this.__error_class,
4265
+ this.__allow_empty_string
4011
4266
  )) {
4012
4267
  this._min = min;
4013
4268
  }
@@ -4031,7 +4286,8 @@ class CMIScore extends BaseCMI {
4031
4286
  this.__score_range,
4032
4287
  this.__invalid_type_code,
4033
4288
  this.__invalid_range_code,
4034
- this.__error_class
4289
+ this.__error_class,
4290
+ this.__allow_empty_string
4035
4291
  )) {
4036
4292
  this._max = max;
4037
4293
  }
@@ -4088,7 +4344,9 @@ class CMICore extends BaseCMI {
4088
4344
  invalidErrorCode: scorm12_errors.INVALID_SET_VALUE,
4089
4345
  invalidTypeCode: scorm12_errors.TYPE_MISMATCH,
4090
4346
  invalidRangeCode: scorm12_errors.VALUE_OUT_OF_RANGE,
4091
- errorClass: Scorm12ValidationError
4347
+ errorClass: Scorm12ValidationError,
4348
+ // SCORM 1.2 score elements may be set blank (clears the value), per ADL 1.2 CTS.
4349
+ allowEmptyString: true
4092
4350
  });
4093
4351
  }
4094
4352
  score;
@@ -4518,7 +4776,9 @@ class CMIObjectivesObject extends BaseCMI {
4518
4776
  invalidErrorCode: scorm12_errors.INVALID_SET_VALUE,
4519
4777
  invalidTypeCode: scorm12_errors.TYPE_MISMATCH,
4520
4778
  invalidRangeCode: scorm12_errors.VALUE_OUT_OF_RANGE,
4521
- errorClass: Scorm12ValidationError
4779
+ errorClass: Scorm12ValidationError,
4780
+ // SCORM 1.2 score elements may be set blank (clears the value), per ADL 1.2 CTS.
4781
+ allowEmptyString: true
4522
4782
  });
4523
4783
  }
4524
4784
  score;
@@ -5988,16 +6248,17 @@ class Scorm12API extends BaseAPI {
5988
6248
  * Attempts to store the data to the LMS
5989
6249
  *
5990
6250
  * @param {boolean} terminateCommit
6251
+ * @param {CommitTrigger} [trigger] - What initiated the commit
5991
6252
  * @return {ResultObject}
5992
6253
  */
5993
- storeData(terminateCommit) {
6254
+ storeData(terminateCommit, trigger) {
5994
6255
  if (terminateCommit) {
5995
6256
  const originalStatus = this.cmi.core.lesson_status;
5996
6257
  if (this.cmi.core.lesson_mode === "browse") {
5997
6258
  const startingStatus = this.startingData?.cmi?.core?.lesson_status || "";
5998
6259
  if (startingStatus === "" && originalStatus === "not attempted") {
5999
6260
  this.cmi.core.lesson_status = "browsed";
6000
- return this.processCommitData(terminateCommit);
6261
+ return this.processCommitData(terminateCommit, trigger);
6001
6262
  }
6002
6263
  }
6003
6264
  if (!this.cmi.core.lesson_status || !this.statusSetByModule && this.cmi.core.lesson_status === "not attempted") {
@@ -6026,12 +6287,17 @@ class Scorm12API extends BaseAPI {
6026
6287
  }
6027
6288
  }
6028
6289
  }
6029
- return this.processCommitData(terminateCommit);
6290
+ return this.processCommitData(terminateCommit, trigger);
6030
6291
  }
6031
- processCommitData(terminateCommit) {
6292
+ processCommitData(terminateCommit, trigger) {
6032
6293
  const commitObject = this.getCommitObject(terminateCommit);
6033
6294
  if (typeof this.settings.lmsCommitUrl === "string") {
6034
- return this.processHttpRequest(this.settings.lmsCommitUrl, commitObject, terminateCommit);
6295
+ return this.processHttpRequest(
6296
+ this.settings.lmsCommitUrl,
6297
+ commitObject,
6298
+ terminateCommit,
6299
+ trigger
6300
+ );
6035
6301
  } else {
6036
6302
  return {
6037
6303
  result: global_constants.SCORM_TRUE,