scorm-again 3.0.5 → 3.1.1

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 (48) hide show
  1. package/README.md +77 -1
  2. package/dist/cross-frame-api.js +579 -320
  3. package/dist/cross-frame-api.js.map +1 -1
  4. package/dist/cross-frame-api.min.js +1 -1
  5. package/dist/cross-frame-api.min.js.map +1 -1
  6. package/dist/cross-frame-lms.js +226 -163
  7. package/dist/cross-frame-lms.js.map +1 -1
  8. package/dist/cross-frame-lms.min.js +1 -1
  9. package/dist/cross-frame-lms.min.js.map +1 -1
  10. package/dist/esm/cross-frame-api.js.map +1 -1
  11. package/dist/esm/scorm-again.js +391 -86
  12. package/dist/esm/scorm-again.js.map +1 -1
  13. package/dist/esm/scorm-again.min.js +1 -1
  14. package/dist/esm/scorm-again.min.js.map +1 -1
  15. package/dist/esm/scorm12.js +323 -76
  16. package/dist/esm/scorm12.js.map +1 -1
  17. package/dist/esm/scorm12.min.js +1 -1
  18. package/dist/esm/scorm12.min.js.map +1 -1
  19. package/dist/esm/scorm2004.js +373 -72
  20. package/dist/esm/scorm2004.js.map +1 -1
  21. package/dist/esm/scorm2004.min.js +1 -1
  22. package/dist/esm/scorm2004.min.js.map +1 -1
  23. package/dist/scorm-again.js +32522 -19996
  24. package/dist/scorm-again.js.map +1 -1
  25. package/dist/scorm-again.min.js +1 -1
  26. package/dist/scorm-again.min.js.map +1 -1
  27. package/dist/scorm12.js +9130 -5031
  28. package/dist/scorm12.js.map +1 -1
  29. package/dist/scorm12.min.js +1 -1
  30. package/dist/scorm12.min.js.map +1 -1
  31. package/dist/scorm2004.js +30040 -18419
  32. package/dist/scorm2004.js.map +1 -1
  33. package/dist/scorm2004.min.js +1 -1
  34. package/dist/scorm2004.min.js.map +1 -1
  35. package/dist/types/BaseAPI.d.ts +15 -5
  36. package/dist/types/Scorm12API.d.ts +2 -2
  37. package/dist/types/Scorm2004API.d.ts +3 -2
  38. package/dist/types/cmi/common/score.d.ts +2 -0
  39. package/dist/types/interfaces/services.d.ts +8 -4
  40. package/dist/types/services/AsynchronousHttpService.d.ts +3 -2
  41. package/dist/types/services/EventService.d.ts +2 -2
  42. package/dist/types/services/OfflineStorageService.d.ts +6 -1
  43. package/dist/types/services/SynchronousHttpService.d.ts +2 -2
  44. package/dist/types/services/ValidationService.d.ts +1 -1
  45. package/dist/types/types/api_types.d.ts +22 -2
  46. package/dist/types/utilities/index.d.ts +1 -0
  47. package/dist/types/utilities/url.d.ts +2 -0
  48. package/package.json +27 -27
@@ -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(
@@ -1935,8 +1968,9 @@ class EventService {
1935
1968
  * @param {string} functionName - The name of the function that triggered the event
1936
1969
  * @param {string} CMIElement - The CMI element that was affected
1937
1970
  * @param {any} value - The value that was set
1971
+ * @param {CommitEventContext} context - Optional context for commit lifecycle events
1938
1972
  */
1939
- processListeners(functionName, CMIElement, value) {
1973
+ processListeners(functionName, CMIElement, value, context) {
1940
1974
  this.apiLog(functionName, value, LogLevelEnum.INFO, CMIElement);
1941
1975
  const listeners = this.listenerMap.get(functionName);
1942
1976
  if (!listeners) return;
@@ -1961,9 +1995,17 @@ class EventService {
1961
1995
  if (functionName.startsWith("Sequence")) {
1962
1996
  listener.callback(value);
1963
1997
  } else if (functionName === "CommitError") {
1964
- listener.callback(value);
1998
+ if (context !== void 0) {
1999
+ listener.callback(value, context);
2000
+ } else {
2001
+ listener.callback(value);
2002
+ }
1965
2003
  } else if (functionName === "CommitSuccess") {
1966
- listener.callback();
2004
+ if (context !== void 0) {
2005
+ listener.callback(context);
2006
+ } else {
2007
+ listener.callback();
2008
+ }
1967
2009
  } else {
1968
2010
  listener.callback(CMIElement, value);
1969
2011
  }
@@ -2097,16 +2139,19 @@ class OfflineStorageService {
2097
2139
  * Store commit data offline
2098
2140
  * @param {string} courseId - Identifier for the course
2099
2141
  * @param {CommitObject} commitData - The data to store offline
2142
+ * @param {OfflineCommitMetadata} metadata - Metadata captured with the original commit
2100
2143
  * @returns {ResultObject} - Result of the storage operation
2101
2144
  */
2102
- storeOffline(courseId, commitData) {
2145
+ storeOffline(courseId, commitData, metadata) {
2103
2146
  try {
2104
2147
  const queueItem = {
2105
2148
  id: `${courseId}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
2106
2149
  courseId,
2107
2150
  timestamp: Date.now(),
2108
2151
  data: commitData,
2109
- syncAttempts: 0
2152
+ syncAttempts: 0,
2153
+ ...metadata?.isTerminateCommit !== void 0 ? { isTerminateCommit: metadata.isTerminateCommit } : {},
2154
+ ...metadata?.sequence !== void 0 ? { sequence: metadata.sequence } : {}
2110
2155
  };
2111
2156
  const currentQueue = this.getFromStorage(this.syncQueue) || [];
2112
2157
  currentQueue.push(queueItem);
@@ -2185,7 +2230,10 @@ class OfflineStorageService {
2185
2230
  continue;
2186
2231
  }
2187
2232
  try {
2188
- 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
+ });
2189
2237
  if (syncResult.result === true || syncResult.result === global_constants.SCORM_TRUE) {
2190
2238
  this.apiLog(
2191
2239
  "OfflineStorageService",
@@ -2232,17 +2280,25 @@ class OfflineStorageService {
2232
2280
  /**
2233
2281
  * Send data to the LMS when online
2234
2282
  * @param {CommitObject} data - The data to send to the LMS
2283
+ * @param {OfflineCommitMetadata} metadata - Metadata captured with the original commit
2235
2284
  * @returns {Promise<ResultObject>} - Result of the sync operation
2236
2285
  */
2237
- async sendDataToLMS(data) {
2238
- if (!this.settings.lmsCommitUrl) {
2286
+ async sendDataToLMS(data, metadata) {
2287
+ const configuredCommitUrl = this.settings.lmsCommitUrl;
2288
+ if (!configuredCommitUrl) {
2239
2289
  return {
2240
2290
  result: global_constants.SCORM_FALSE,
2241
2291
  errorCode: this.error_codes.GENERAL || 101
2242
2292
  };
2243
2293
  }
2244
2294
  try {
2245
- 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;
2246
2302
  const init = {
2247
2303
  method: "POST",
2248
2304
  mode: this.settings.fetchMode,
@@ -2255,7 +2311,7 @@ class OfflineStorageService {
2255
2311
  if (this.settings.xhrWithCredentials) {
2256
2312
  init.credentials = "include";
2257
2313
  }
2258
- const response = await fetch(this.settings.lmsCommitUrl, init);
2314
+ const response = await fetch(requestUrl, init);
2259
2315
  const result = typeof this.settings.responseHandler === "function" ? await this.settings.responseHandler(response) : await response.json();
2260
2316
  if (response.status >= 200 && response.status <= 299 && (result.result === true || result.result === global_constants.SCORM_TRUE)) {
2261
2317
  if (!Object.hasOwnProperty.call(result, "errorCode")) {
@@ -2652,6 +2708,8 @@ class SynchronousHttpService {
2652
2708
  * @param {boolean} immediate - Whether this is a termination commit (use sendBeacon)
2653
2709
  * @param {Function} _apiLog - Function to log API messages (unused in synchronous mode - errors returned directly)
2654
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)
2655
2713
  * @return {ResultObject} - The result of the request (synchronous)
2656
2714
  *
2657
2715
  * @remarks
@@ -2661,21 +2719,23 @@ class SynchronousHttpService {
2661
2719
  * - No async events need to be triggered (CommitSuccess/CommitError) since results are synchronous
2662
2720
  * - AsynchronousHttpService uses these parameters to handle background request results
2663
2721
  */
2664
- processHttpRequest(url, params, immediate = false, _apiLog, _processListeners) {
2722
+ processHttpRequest(url, params, immediate = false, _apiLog, _processListeners, metadata, _onRequestComplete) {
2665
2723
  if (immediate) {
2666
- return this._handleImmediateRequest(url, params);
2724
+ return this._handleImmediateRequest(url, params, metadata);
2667
2725
  }
2668
- return this._performSyncXHR(url, params);
2726
+ return this._performSyncXHR(url, params, metadata);
2669
2727
  }
2670
2728
  /**
2671
2729
  * Handles an immediate request using sendBeacon
2672
2730
  * @param {string} url - The URL to send the request to
2673
2731
  * @param {CommitObject|StringKeyMap|Array} params - The parameters to include in the request
2732
+ * @param {CommitMetadata} metadata - Metadata describing the captured commit
2674
2733
  * @return {ResultObject} - The result based on beacon success
2675
2734
  * @private
2676
2735
  */
2677
- _handleImmediateRequest(url, params) {
2678
- 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;
2679
2739
  const { body } = this._prepareRequestBody(requestPayload);
2680
2740
  const beaconSuccess = navigator.sendBeacon(
2681
2741
  url,
@@ -2690,11 +2750,13 @@ class SynchronousHttpService {
2690
2750
  * Performs a synchronous XMLHttpRequest
2691
2751
  * @param {string} url - The URL to send the request to
2692
2752
  * @param {CommitObject|StringKeyMap|Array} params - The parameters to include in the request
2753
+ * @param {CommitMetadata} metadata - Metadata describing the captured commit
2693
2754
  * @return {ResultObject} - The result of the request
2694
2755
  * @private
2695
2756
  */
2696
- _performSyncXHR(url, params) {
2697
- 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;
2698
2760
  const { body, contentType } = this._prepareRequestBody(requestPayload);
2699
2761
  const xhr = new XMLHttpRequest();
2700
2762
  xhr.open("POST", url, false);
@@ -2773,9 +2835,15 @@ class ValidationService {
2773
2835
  * @param {number} invalidTypeCode - The error code for invalid type
2774
2836
  * @param {number} invalidRangeCode - The error code for invalid range
2775
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).
2776
2841
  * @return {boolean} - True if validation passes, throws an error otherwise
2777
2842
  */
2778
- 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
+ }
2779
2847
  return checkValidFormat(CMIElement, value, decimalRegex, invalidTypeCode, errorClass) && (!scoreRange || checkValidRange(CMIElement, value, scoreRange, invalidRangeCode, errorClass));
2780
2848
  }
2781
2849
  /**
@@ -2849,6 +2917,21 @@ class BaseAPI {
2849
2917
  _offlineStorageService;
2850
2918
  _cmiValueAccessService;
2851
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();
2852
2935
  /**
2853
2936
  * Constructor for Base API class. Sets some shared API fields, as well as
2854
2937
  * sets up options for the API.
@@ -3034,6 +3117,7 @@ class BaseAPI {
3034
3117
  this.lastErrorCode = "0";
3035
3118
  this._eventService.reset();
3036
3119
  this.startingData = {};
3120
+ this._setCMIElements.clear();
3037
3121
  if (this._offlineStorageService) {
3038
3122
  this._offlineStorageService.updateSettings(this.settings);
3039
3123
  if (settings?.courseId) {
@@ -3118,6 +3202,52 @@ class BaseAPI {
3118
3202
  this._loggingService?.setLogHandler(settings.onLogMessage);
3119
3203
  }
3120
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
+ }
3121
3251
  /**
3122
3252
  * Terminates the current run of the API
3123
3253
  * @param {string} callbackName
@@ -3138,7 +3268,7 @@ class BaseAPI {
3138
3268
  } else {
3139
3269
  stateCheckPassed = true;
3140
3270
  this.processListeners("BeforeTerminate");
3141
- const result = this.storeData(true);
3271
+ const result = this.storeData(true, "terminate");
3142
3272
  if ((result.errorCode ?? 0) > 0) {
3143
3273
  if (result.errorMessage) {
3144
3274
  this.apiLog(
@@ -3190,6 +3320,9 @@ class BaseAPI {
3190
3320
  } catch (e) {
3191
3321
  returnValue = this.handleValueAccessException(CMIElement, e, returnValue);
3192
3322
  }
3323
+ if (this.lastErrorCode === "0") {
3324
+ this.checkUninitializedGet(CMIElement, returnValue);
3325
+ }
3193
3326
  this.processListeners(callbackName, CMIElement);
3194
3327
  }
3195
3328
  this.apiLog(callbackName, ": returned: " + returnValue, LogLevelEnum.INFO, CMIElement);
@@ -3199,6 +3332,10 @@ class BaseAPI {
3199
3332
  if (this.lastErrorCode === "0") {
3200
3333
  this.clearSCORMError(returnValue);
3201
3334
  }
3335
+ const rawReturn = returnValue;
3336
+ if (typeof rawReturn === "number" || typeof rawReturn === "boolean") {
3337
+ return String(rawReturn);
3338
+ }
3202
3339
  return returnValue;
3203
3340
  }
3204
3341
  /**
@@ -3251,9 +3388,10 @@ class BaseAPI {
3251
3388
  * Orders LMS to store all content parameters
3252
3389
  * @param {string} callbackName
3253
3390
  * @param {boolean} checkTerminated
3391
+ * @param {CommitTrigger} trigger - What initiated the commit
3254
3392
  * @return {string}
3255
3393
  */
3256
- commit(callbackName, checkTerminated = false) {
3394
+ commit(callbackName, checkTerminated = false, trigger = "manual") {
3257
3395
  this.clearScheduledCommit();
3258
3396
  let returnValue = global_constants.SCORM_TRUE;
3259
3397
  if (this.isNotInitialized()) {
@@ -3265,7 +3403,7 @@ class BaseAPI {
3265
3403
  this.throwSCORMError("api", errorCode);
3266
3404
  if (errorCode === 143) returnValue = global_constants.SCORM_FALSE;
3267
3405
  } else {
3268
- const result = this.storeData(false);
3406
+ const result = this.storeData(false, trigger);
3269
3407
  const errorCode = result.errorCode ?? 0;
3270
3408
  if (errorCode > 0) {
3271
3409
  if (result.errorMessage) {
@@ -3496,7 +3634,16 @@ class BaseAPI {
3496
3634
  * @return {string}
3497
3635
  */
3498
3636
  _commonSetCMIValue(methodName, scorm2004, CMIElement, value) {
3499
- 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;
3500
3647
  }
3501
3648
  /**
3502
3649
  * Gets a value from the CMI Object.
@@ -3510,6 +3657,18 @@ class BaseAPI {
3510
3657
  _commonGetCMIValue(methodName, scorm2004, CMIElement) {
3511
3658
  return this._cmiValueAccessService.getCMIValue(methodName, scorm2004, CMIElement);
3512
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
+ }
3513
3672
  /**
3514
3673
  * Returns true if the API's current state is STATE_INITIALIZED
3515
3674
  *
@@ -3592,9 +3751,14 @@ class BaseAPI {
3592
3751
  * @param {string} functionName - The name of the function/event that occurred
3593
3752
  * @param {string} CMIElement - Optional CMI element involved in the event
3594
3753
  * @param {any} value - Optional value associated with the event
3754
+ * @param {CommitEventContext} context - Optional context for commit lifecycle events
3595
3755
  */
3596
- processListeners(functionName, CMIElement, value) {
3597
- 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
+ }
3598
3762
  }
3599
3763
  /**
3600
3764
  * Throws a SCORM error with the specified error number and optional message.
@@ -3727,36 +3891,106 @@ class BaseAPI {
3727
3891
  * @param {string} url - The URL to send the request to
3728
3892
  * @param {CommitObject | StringKeyMap | Array<any>} params - The parameters to send
3729
3893
  * @param {boolean} immediate - Whether to send the request immediately without waiting
3894
+ * @param {CommitTrigger} [trigger] - What initiated the commit
3730
3895
  * @returns {ResultObject} - The result of the request
3731
3896
  */
3732
- processHttpRequest(url, params, immediate = false) {
3733
- if (this.settings.enableOfflineSupport && this._offlineStorageService && !this._offlineStorageService.isDeviceOnline() && this._courseId) {
3734
- this.apiLog(
3735
- "processHttpRequest",
3736
- "Device is offline, storing data locally",
3737
- LogLevelEnum.INFO
3738
- );
3739
- if (params && typeof params === "object" && "cmi" in params) {
3740
- return this._offlineStorageService.storeOffline(this._courseId, params);
3741
- } 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) {
3742
3943
  this.apiLog(
3743
3944
  "processHttpRequest",
3744
- "Invalid commit data format for offline storage",
3745
- LogLevelEnum.ERROR
3945
+ "Device is offline, storing data locally",
3946
+ LogLevelEnum.INFO
3746
3947
  );
3747
- return {
3748
- result: global_constants.SCORM_FALSE,
3749
- errorCode: this._error_codes.GENERAL ?? 101
3750
- };
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();
3751
3992
  }
3752
3993
  }
3753
- return this._httpService.processHttpRequest(
3754
- url,
3755
- params,
3756
- immediate,
3757
- (functionName, message, level, element) => this.apiLog(functionName, message, level, element),
3758
- (functionName, CMIElement, value) => this.processListeners(functionName, CMIElement, value)
3759
- );
3760
3994
  }
3761
3995
  /**
3762
3996
  * Schedules a commit operation to occur after a specified delay.
@@ -3916,6 +4150,11 @@ class CMIScore extends BaseCMI {
3916
4150
  __invalid_range_code;
3917
4151
  __decimal_regex;
3918
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;
3919
4158
  _raw = "";
3920
4159
  _min = "";
3921
4160
  _max;
@@ -3950,6 +4189,7 @@ class CMIScore extends BaseCMI {
3950
4189
  this.__invalid_range_code = params.invalidRangeCode || scorm12_errors.VALUE_OUT_OF_RANGE;
3951
4190
  this.__decimal_regex = params.decimalRegex || scorm12_regex.CMIDecimal;
3952
4191
  this.__error_class = params.errorClass;
4192
+ this.__allow_empty_string = params.allowEmptyString ?? false;
3953
4193
  }
3954
4194
  /**
3955
4195
  * Called when the API has been reset
@@ -3996,7 +4236,8 @@ class CMIScore extends BaseCMI {
3996
4236
  this.__score_range,
3997
4237
  this.__invalid_type_code,
3998
4238
  this.__invalid_range_code,
3999
- this.__error_class
4239
+ this.__error_class,
4240
+ this.__allow_empty_string
4000
4241
  )) {
4001
4242
  this._raw = raw;
4002
4243
  }
@@ -4020,7 +4261,8 @@ class CMIScore extends BaseCMI {
4020
4261
  this.__score_range,
4021
4262
  this.__invalid_type_code,
4022
4263
  this.__invalid_range_code,
4023
- this.__error_class
4264
+ this.__error_class,
4265
+ this.__allow_empty_string
4024
4266
  )) {
4025
4267
  this._min = min;
4026
4268
  }
@@ -4044,7 +4286,8 @@ class CMIScore extends BaseCMI {
4044
4286
  this.__score_range,
4045
4287
  this.__invalid_type_code,
4046
4288
  this.__invalid_range_code,
4047
- this.__error_class
4289
+ this.__error_class,
4290
+ this.__allow_empty_string
4048
4291
  )) {
4049
4292
  this._max = max;
4050
4293
  }
@@ -4101,7 +4344,9 @@ class CMICore extends BaseCMI {
4101
4344
  invalidErrorCode: scorm12_errors.INVALID_SET_VALUE,
4102
4345
  invalidTypeCode: scorm12_errors.TYPE_MISMATCH,
4103
4346
  invalidRangeCode: scorm12_errors.VALUE_OUT_OF_RANGE,
4104
- 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
4105
4350
  });
4106
4351
  }
4107
4352
  score;
@@ -4531,7 +4776,9 @@ class CMIObjectivesObject extends BaseCMI {
4531
4776
  invalidErrorCode: scorm12_errors.INVALID_SET_VALUE,
4532
4777
  invalidTypeCode: scorm12_errors.TYPE_MISMATCH,
4533
4778
  invalidRangeCode: scorm12_errors.VALUE_OUT_OF_RANGE,
4534
- 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
4535
4782
  });
4536
4783
  }
4537
4784
  score;
@@ -5932,18 +6179,12 @@ class Scorm12API extends BaseAPI {
5932
6179
  if (terminateCommit || includeTotalTime) {
5933
6180
  cmiExport.cmi.core.total_time = this.cmi.getCurrentTotalTime();
5934
6181
  }
5935
- const result = [];
5936
6182
  const flattened = flatten(cmiExport);
5937
6183
  switch (this.settings.dataCommitFormat) {
5938
6184
  case "flattened":
5939
6185
  return flattened;
5940
6186
  case "params":
5941
- for (const item in flattened) {
5942
- if ({}.hasOwnProperty.call(flattened, item)) {
5943
- result.push(`${item}=${flattened[item]}`);
5944
- }
5945
- }
5946
- return result;
6187
+ return Object.entries(flattened).map(([item, value]) => `${item}=${value}`);
5947
6188
  case "json":
5948
6189
  default:
5949
6190
  return cmiExport;
@@ -6001,16 +6242,17 @@ class Scorm12API extends BaseAPI {
6001
6242
  * Attempts to store the data to the LMS
6002
6243
  *
6003
6244
  * @param {boolean} terminateCommit
6245
+ * @param {CommitTrigger} [trigger] - What initiated the commit
6004
6246
  * @return {ResultObject}
6005
6247
  */
6006
- storeData(terminateCommit) {
6248
+ storeData(terminateCommit, trigger) {
6007
6249
  if (terminateCommit) {
6008
6250
  const originalStatus = this.cmi.core.lesson_status;
6009
6251
  if (this.cmi.core.lesson_mode === "browse") {
6010
6252
  const startingStatus = this.startingData?.cmi?.core?.lesson_status || "";
6011
6253
  if (startingStatus === "" && originalStatus === "not attempted") {
6012
6254
  this.cmi.core.lesson_status = "browsed";
6013
- return this.processCommitData(terminateCommit);
6255
+ return this.processCommitData(terminateCommit, trigger);
6014
6256
  }
6015
6257
  }
6016
6258
  if (!this.cmi.core.lesson_status || !this.statusSetByModule && this.cmi.core.lesson_status === "not attempted") {
@@ -6039,12 +6281,17 @@ class Scorm12API extends BaseAPI {
6039
6281
  }
6040
6282
  }
6041
6283
  }
6042
- return this.processCommitData(terminateCommit);
6284
+ return this.processCommitData(terminateCommit, trigger);
6043
6285
  }
6044
- processCommitData(terminateCommit) {
6286
+ processCommitData(terminateCommit, trigger) {
6045
6287
  const commitObject = this.getCommitObject(terminateCommit);
6046
6288
  if (typeof this.settings.lmsCommitUrl === "string") {
6047
- return this.processHttpRequest(this.settings.lmsCommitUrl, commitObject, terminateCommit);
6289
+ return this.processHttpRequest(
6290
+ this.settings.lmsCommitUrl,
6291
+ commitObject,
6292
+ terminateCommit,
6293
+ trigger
6294
+ );
6048
6295
  } else {
6049
6296
  return {
6050
6297
  result: global_constants.SCORM_TRUE,