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
@@ -210,6 +210,15 @@ function parseNavigationRequest(navRequest) {
210
210
  };
211
211
  }
212
212
 
213
+ const appendQueryParam = (url, name, value) => {
214
+ const fragmentIndex = url.indexOf("#");
215
+ const baseUrl = fragmentIndex === -1 ? url : url.slice(0, fragmentIndex);
216
+ const fragment = fragmentIndex === -1 ? "" : url.slice(fragmentIndex);
217
+ const separator = baseUrl.includes("?") ? baseUrl.endsWith("?") || baseUrl.endsWith("&") ? "" : "&" : "?";
218
+ const queryParam = `${encodeURIComponent(name)}=${encodeURIComponent(String(value))}`;
219
+ return `${baseUrl}${separator}${queryParam}${fragment}`;
220
+ };
221
+
213
222
  class BaseCMI {
214
223
  /**
215
224
  * Flag used during JSON serialization to allow getter access without initialization checks.
@@ -583,7 +592,10 @@ const scorm12_errors = {
583
592
  INVALID_SET_VALUE: 402,
584
593
  READ_ONLY_ELEMENT: 403,
585
594
  TYPE_MISMATCH: 405,
586
- VALUE_OUT_OF_RANGE: 407};
595
+ // SCORM 1.2 has no out-of-range error code; an out-of-range value is reported
596
+ // as 405 (incorrect data type), per the SCORM 1.2 RTE error table and the ADL
597
+ // 1.2 CTS (DataModelValidator.checkScoreDecimal failure -> CMICategory 405).
598
+ VALUE_OUT_OF_RANGE: 405};
587
599
  const scorm2004_errors = {
588
600
  ...global_errors,
589
601
  INITIALIZATION_FAILED: 102,
@@ -729,6 +741,7 @@ const DefaultSettings = {
729
741
  xhrWithCredentials: false,
730
742
  fetchMode: "cors",
731
743
  asyncModeBeaconBehavior: "never",
744
+ includeCommitSequence: false,
732
745
  responseHandler: async function(response) {
733
746
  if (typeof response !== "undefined") {
734
747
  let httpResult = null;
@@ -1519,7 +1532,7 @@ class ScheduledCommit {
1519
1532
  wrapper() {
1520
1533
  if (!this._cancelled) {
1521
1534
  if (this._API.isInitialized()) {
1522
- (async () => await this._API.commit(this._callback))();
1535
+ (async () => await this._API.commit(this._callback, false, "autocommit"))();
1523
1536
  }
1524
1537
  }
1525
1538
  }
@@ -4864,6 +4877,7 @@ class ActivityDeliveryService {
4864
4877
  }
4865
4878
 
4866
4879
  class AsynchronousHttpService {
4880
+ reportsRequestCompletion = true;
4867
4881
  settings;
4868
4882
  error_codes;
4869
4883
  /**
@@ -4888,10 +4902,20 @@ class AsynchronousHttpService {
4888
4902
  * @param {boolean} immediate - Whether to send the request immediately without waiting
4889
4903
  * @param {Function} apiLog - Function to log API messages with appropriate levels
4890
4904
  * @param {Function} processListeners - Function to trigger event listeners for commit events
4905
+ * @param {CommitMetadata} metadata - Metadata describing the captured commit
4906
+ * @param {Function} onRequestComplete - Callback invoked after the background request settles
4891
4907
  * @return {ResultObject} - Immediate optimistic success result
4892
4908
  */
4893
- processHttpRequest(url, params, immediate = false, apiLog, processListeners) {
4894
- this._performAsyncRequest(url, params, immediate, apiLog, processListeners);
4909
+ processHttpRequest(url, params, immediate = false, apiLog, processListeners, metadata, onRequestComplete) {
4910
+ this._performAsyncRequest(
4911
+ url,
4912
+ params,
4913
+ immediate,
4914
+ apiLog,
4915
+ processListeners,
4916
+ metadata,
4917
+ onRequestComplete
4918
+ );
4895
4919
  return {
4896
4920
  result: global_constants.SCORM_TRUE,
4897
4921
  errorCode: 0
@@ -4904,11 +4928,14 @@ class AsynchronousHttpService {
4904
4928
  * @param {boolean} immediate - Whether this is an immediate request
4905
4929
  * @param apiLog - Function to log API messages
4906
4930
  * @param {Function} processListeners - Function to process event listeners
4931
+ * @param {CommitMetadata} metadata - Metadata describing the captured commit
4932
+ * @param {Function} onRequestComplete - Callback invoked after the request settles
4907
4933
  * @private
4908
4934
  */
4909
- async _performAsyncRequest(url, params, immediate, apiLog, processListeners) {
4935
+ async _performAsyncRequest(url, params, immediate, apiLog, processListeners, metadata, onRequestComplete) {
4910
4936
  try {
4911
- const processedParams = this.settings.requestHandler(params);
4937
+ const handledParams = metadata === void 0 ? this.settings.requestHandler(params) : this.settings.requestHandler(params, metadata);
4938
+ const processedParams = handledParams;
4912
4939
  let response;
4913
4940
  if (immediate && this.settings.asyncModeBeaconBehavior !== "never") {
4914
4941
  response = await this.performBeacon(url, processedParams);
@@ -4924,7 +4951,9 @@ class AsynchronousHttpService {
4924
4951
  } catch (e) {
4925
4952
  const message = e instanceof Error ? e.message : String(e);
4926
4953
  apiLog("processHttpRequest", `Async request failed: ${message}`, LogLevelEnum.ERROR);
4927
- processListeners("CommitError");
4954
+ processListeners("CommitError", void 0, this.error_codes.GENERAL_COMMIT_FAILURE || 391);
4955
+ } finally {
4956
+ onRequestComplete?.();
4928
4957
  }
4929
4958
  }
4930
4959
  /**
@@ -5067,10 +5096,13 @@ class CMIValueAccessService {
5067
5096
  }
5068
5097
  /**
5069
5098
  * Gets the appropriate error code for undefined data model elements.
5070
- * SCORM 2004 uses UNDEFINED_DATA_MODEL, SCORM 1.2 uses GENERAL.
5099
+ * Both SCORM 2004 and SCORM 1.2 use UNDEFINED_DATA_MODEL (401): an
5100
+ * unrecognized element is "Not implemented", not a general exception. SCORM
5101
+ * 1.2 previously returned GENERAL (101) here, which is non-conformant — the
5102
+ * ADL 1.2 CTS and the SCORM 1.2 RTE spec expect 401 for an unknown element.
5071
5103
  */
5072
- getUndefinedDataModelErrorCode(scorm2004) {
5073
- return scorm2004 ? getErrorCode(this.context.errorCodes, "UNDEFINED_DATA_MODEL") : getErrorCode(this.context.errorCodes, "GENERAL");
5104
+ getUndefinedDataModelErrorCode() {
5105
+ return getErrorCode(this.context.errorCodes, "UNDEFINED_DATA_MODEL");
5074
5106
  }
5075
5107
  /**
5076
5108
  * Sets a value on a CMI element path
@@ -5098,7 +5130,7 @@ class CMIValueAccessService {
5098
5130
  let returnValue = global_constants.SCORM_FALSE;
5099
5131
  let foundFirstIndex = false;
5100
5132
  const invalidErrorMessage = `The data model element passed to ${methodName} (${CMIElement}) is not a valid SCORM data model element.`;
5101
- const invalidErrorCode = this.getUndefinedDataModelErrorCode(scorm2004);
5133
+ const invalidErrorCode = this.getUndefinedDataModelErrorCode();
5102
5134
  for (let idx = 0; idx < structure.length; idx++) {
5103
5135
  const attribute = structure[idx];
5104
5136
  if (idx === structure.length - 1) {
@@ -5168,12 +5200,13 @@ class CMIValueAccessService {
5168
5200
  );
5169
5201
  return "";
5170
5202
  }
5203
+ this.context.setLastErrorCode("0");
5171
5204
  const structure = CMIElement.split(".");
5172
5205
  let refObject = this.context.getDataModel();
5173
5206
  let attribute = null;
5174
5207
  const uninitializedErrorMessage = `The data model element passed to ${methodName} (${CMIElement}) has not been initialized.`;
5175
5208
  const invalidErrorMessage = `The data model element passed to ${methodName} (${CMIElement}) is not a valid SCORM data model element.`;
5176
- const invalidErrorCode = this.getUndefinedDataModelErrorCode(scorm2004);
5209
+ const invalidErrorCode = this.getUndefinedDataModelErrorCode();
5177
5210
  for (let idx = 0; idx < structure.length; idx++) {
5178
5211
  attribute = structure[idx];
5179
5212
  const validationResult = this.validateGetAttribute(
@@ -5885,8 +5918,9 @@ class EventService {
5885
5918
  * @param {string} functionName - The name of the function that triggered the event
5886
5919
  * @param {string} CMIElement - The CMI element that was affected
5887
5920
  * @param {any} value - The value that was set
5921
+ * @param {CommitEventContext} context - Optional context for commit lifecycle events
5888
5922
  */
5889
- processListeners(functionName, CMIElement, value) {
5923
+ processListeners(functionName, CMIElement, value, context) {
5890
5924
  this.apiLog(functionName, value, LogLevelEnum.INFO, CMIElement);
5891
5925
  const listeners = this.listenerMap.get(functionName);
5892
5926
  if (!listeners) return;
@@ -5911,9 +5945,17 @@ class EventService {
5911
5945
  if (functionName.startsWith("Sequence")) {
5912
5946
  listener.callback(value);
5913
5947
  } else if (functionName === "CommitError") {
5914
- listener.callback(value);
5948
+ if (context !== void 0) {
5949
+ listener.callback(value, context);
5950
+ } else {
5951
+ listener.callback(value);
5952
+ }
5915
5953
  } else if (functionName === "CommitSuccess") {
5916
- listener.callback();
5954
+ if (context !== void 0) {
5955
+ listener.callback(context);
5956
+ } else {
5957
+ listener.callback();
5958
+ }
5917
5959
  } else {
5918
5960
  listener.callback(CMIElement, value);
5919
5961
  }
@@ -6047,16 +6089,19 @@ class OfflineStorageService {
6047
6089
  * Store commit data offline
6048
6090
  * @param {string} courseId - Identifier for the course
6049
6091
  * @param {CommitObject} commitData - The data to store offline
6092
+ * @param {OfflineCommitMetadata} metadata - Metadata captured with the original commit
6050
6093
  * @returns {ResultObject} - Result of the storage operation
6051
6094
  */
6052
- storeOffline(courseId, commitData) {
6095
+ storeOffline(courseId, commitData, metadata) {
6053
6096
  try {
6054
6097
  const queueItem = {
6055
6098
  id: `${courseId}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
6056
6099
  courseId,
6057
6100
  timestamp: Date.now(),
6058
6101
  data: commitData,
6059
- syncAttempts: 0
6102
+ syncAttempts: 0,
6103
+ ...metadata?.isTerminateCommit !== void 0 ? { isTerminateCommit: metadata.isTerminateCommit } : {},
6104
+ ...metadata?.sequence !== void 0 ? { sequence: metadata.sequence } : {}
6060
6105
  };
6061
6106
  const currentQueue = this.getFromStorage(this.syncQueue) || [];
6062
6107
  currentQueue.push(queueItem);
@@ -6135,7 +6180,10 @@ class OfflineStorageService {
6135
6180
  continue;
6136
6181
  }
6137
6182
  try {
6138
- const syncResult = await this.sendDataToLMS(item.data);
6183
+ const syncResult = await this.sendDataToLMS(item.data, {
6184
+ ...item.isTerminateCommit !== void 0 ? { isTerminateCommit: item.isTerminateCommit } : {},
6185
+ ...item.sequence !== void 0 ? { sequence: item.sequence } : {}
6186
+ });
6139
6187
  if (syncResult.result === true || syncResult.result === global_constants.SCORM_TRUE) {
6140
6188
  this.apiLog(
6141
6189
  "OfflineStorageService",
@@ -6182,17 +6230,25 @@ class OfflineStorageService {
6182
6230
  /**
6183
6231
  * Send data to the LMS when online
6184
6232
  * @param {CommitObject} data - The data to send to the LMS
6233
+ * @param {OfflineCommitMetadata} metadata - Metadata captured with the original commit
6185
6234
  * @returns {Promise<ResultObject>} - Result of the sync operation
6186
6235
  */
6187
- async sendDataToLMS(data) {
6188
- if (!this.settings.lmsCommitUrl) {
6236
+ async sendDataToLMS(data, metadata) {
6237
+ const configuredCommitUrl = this.settings.lmsCommitUrl;
6238
+ if (!configuredCommitUrl) {
6189
6239
  return {
6190
6240
  result: global_constants.SCORM_FALSE,
6191
6241
  errorCode: this.error_codes.GENERAL || 101
6192
6242
  };
6193
6243
  }
6194
6244
  try {
6195
- const processedData = this.settings.requestHandler(data);
6245
+ const lmsCommitUrl = String(configuredCommitUrl);
6246
+ const processedData = this.settings.requestHandler(data, {
6247
+ isTerminateCommit: metadata?.isTerminateCommit ?? false,
6248
+ trigger: "offline-replay",
6249
+ ...metadata?.sequence !== void 0 ? { sequence: metadata.sequence } : {}
6250
+ });
6251
+ const requestUrl = metadata?.isTerminateCommit && this.settings.terminateCommitParam ? appendQueryParam(lmsCommitUrl, this.settings.terminateCommitParam, "true") : lmsCommitUrl;
6196
6252
  const init = {
6197
6253
  method: "POST",
6198
6254
  mode: this.settings.fetchMode,
@@ -6205,7 +6261,7 @@ class OfflineStorageService {
6205
6261
  if (this.settings.xhrWithCredentials) {
6206
6262
  init.credentials = "include";
6207
6263
  }
6208
- const response = await fetch(this.settings.lmsCommitUrl, init);
6264
+ const response = await fetch(requestUrl, init);
6209
6265
  const result = typeof this.settings.responseHandler === "function" ? await this.settings.responseHandler(response) : await response.json();
6210
6266
  if (response.status >= 200 && response.status <= 299 && (result.result === true || result.result === global_constants.SCORM_TRUE)) {
6211
6267
  if (!Object.hasOwnProperty.call(result, "errorCode")) {
@@ -15170,6 +15226,8 @@ class SynchronousHttpService {
15170
15226
  * @param {boolean} immediate - Whether this is a termination commit (use sendBeacon)
15171
15227
  * @param {Function} _apiLog - Function to log API messages (unused in synchronous mode - errors returned directly)
15172
15228
  * @param {Function} _processListeners - Function to trigger event listeners (unused in synchronous mode - no async events)
15229
+ * @param {CommitMetadata} metadata - Metadata describing the captured commit
15230
+ * @param {Function} _onRequestComplete - Completion callback (unused because requests settle before return)
15173
15231
  * @return {ResultObject} - The result of the request (synchronous)
15174
15232
  *
15175
15233
  * @remarks
@@ -15179,21 +15237,23 @@ class SynchronousHttpService {
15179
15237
  * - No async events need to be triggered (CommitSuccess/CommitError) since results are synchronous
15180
15238
  * - AsynchronousHttpService uses these parameters to handle background request results
15181
15239
  */
15182
- processHttpRequest(url, params, immediate = false, _apiLog, _processListeners) {
15240
+ processHttpRequest(url, params, immediate = false, _apiLog, _processListeners, metadata, _onRequestComplete) {
15183
15241
  if (immediate) {
15184
- return this._handleImmediateRequest(url, params);
15242
+ return this._handleImmediateRequest(url, params, metadata);
15185
15243
  }
15186
- return this._performSyncXHR(url, params);
15244
+ return this._performSyncXHR(url, params, metadata);
15187
15245
  }
15188
15246
  /**
15189
15247
  * Handles an immediate request using sendBeacon
15190
15248
  * @param {string} url - The URL to send the request to
15191
15249
  * @param {CommitObject|StringKeyMap|Array} params - The parameters to include in the request
15250
+ * @param {CommitMetadata} metadata - Metadata describing the captured commit
15192
15251
  * @return {ResultObject} - The result based on beacon success
15193
15252
  * @private
15194
15253
  */
15195
- _handleImmediateRequest(url, params) {
15196
- const requestPayload = this.settings.requestHandler(params) ?? params;
15254
+ _handleImmediateRequest(url, params, metadata) {
15255
+ const handledPayload = metadata === void 0 ? this.settings.requestHandler(params) : this.settings.requestHandler(params, metadata);
15256
+ const requestPayload = handledPayload ?? params;
15197
15257
  const { body } = this._prepareRequestBody(requestPayload);
15198
15258
  const beaconSuccess = navigator.sendBeacon(
15199
15259
  url,
@@ -15208,11 +15268,13 @@ class SynchronousHttpService {
15208
15268
  * Performs a synchronous XMLHttpRequest
15209
15269
  * @param {string} url - The URL to send the request to
15210
15270
  * @param {CommitObject|StringKeyMap|Array} params - The parameters to include in the request
15271
+ * @param {CommitMetadata} metadata - Metadata describing the captured commit
15211
15272
  * @return {ResultObject} - The result of the request
15212
15273
  * @private
15213
15274
  */
15214
- _performSyncXHR(url, params) {
15215
- const requestPayload = this.settings.requestHandler(params) ?? params;
15275
+ _performSyncXHR(url, params, metadata) {
15276
+ const handledPayload = metadata === void 0 ? this.settings.requestHandler(params) : this.settings.requestHandler(params, metadata);
15277
+ const requestPayload = handledPayload ?? params;
15216
15278
  const { body, contentType } = this._prepareRequestBody(requestPayload);
15217
15279
  const xhr = new XMLHttpRequest();
15218
15280
  xhr.open("POST", url, false);
@@ -15291,9 +15353,15 @@ class ValidationService {
15291
15353
  * @param {number} invalidTypeCode - The error code for invalid type
15292
15354
  * @param {number} invalidRangeCode - The error code for invalid range
15293
15355
  * @param {typeof BaseScormValidationError} errorClass - The error class to use for validation errors
15356
+ * @param {boolean} allowEmptyString - When true, an empty string is accepted (clears the value).
15357
+ * SCORM 1.2 score elements may be blank per the ADL 1.2 CTS
15358
+ * (DataModelValidator.checkScoreDecimal treats blank as valid).
15294
15359
  * @return {boolean} - True if validation passes, throws an error otherwise
15295
15360
  */
15296
- validateScore(CMIElement, value, decimalRegex, scoreRange, invalidTypeCode, invalidRangeCode, errorClass) {
15361
+ validateScore(CMIElement, value, decimalRegex, scoreRange, invalidTypeCode, invalidRangeCode, errorClass, allowEmptyString = false) {
15362
+ if (allowEmptyString && value === "") {
15363
+ return true;
15364
+ }
15297
15365
  return checkValidFormat(CMIElement, value, decimalRegex, invalidTypeCode, errorClass) && (!scoreRange || checkValidRange(CMIElement, value, scoreRange, invalidRangeCode, errorClass));
15298
15366
  }
15299
15367
  /**
@@ -15367,6 +15435,21 @@ class BaseAPI {
15367
15435
  _offlineStorageService;
15368
15436
  _cmiValueAccessService;
15369
15437
  _courseId = "";
15438
+ _pendingCommitCount = 0;
15439
+ /**
15440
+ * Monotonic sequence for commits captured by this API instance. It is
15441
+ * intentionally not reset by reset().
15442
+ */
15443
+ _commitSequence = 0;
15444
+ _commitSettleWaiters = [];
15445
+ /**
15446
+ * Canonical paths of every CMI element that has been explicitly assigned a
15447
+ * value via SetValue / loadFromJSON (both funnel through _commonSetCMIValue).
15448
+ * Used by standards that must tell "implemented but never set" apart from a
15449
+ * legitimately empty value when answering GetValue (SCORM 2004 error 403).
15450
+ * Cleared on reset so a fresh SCO attempt starts with nothing "set".
15451
+ */
15452
+ _setCMIElements = /* @__PURE__ */ new Set();
15370
15453
  /**
15371
15454
  * Constructor for Base API class. Sets some shared API fields, as well as
15372
15455
  * sets up options for the API.
@@ -15552,6 +15635,7 @@ class BaseAPI {
15552
15635
  this.lastErrorCode = "0";
15553
15636
  this._eventService.reset();
15554
15637
  this.startingData = {};
15638
+ this._setCMIElements.clear();
15555
15639
  if (this._offlineStorageService) {
15556
15640
  this._offlineStorageService.updateSettings(this.settings);
15557
15641
  if (settings?.courseId) {
@@ -15636,6 +15720,52 @@ class BaseAPI {
15636
15720
  this._loggingService?.setLogHandler(settings.onLogMessage);
15637
15721
  }
15638
15722
  }
15723
+ /**
15724
+ * Gets the number of captured commit requests that have not yet settled.
15725
+ *
15726
+ * @return {number} The number of in-flight commits
15727
+ */
15728
+ get pendingCommitCount() {
15729
+ return this._pendingCommitCount;
15730
+ }
15731
+ /**
15732
+ * Resolves when all currently in-flight commits have settled. A timeout is
15733
+ * best-effort: the promise resolves when it elapses even if commits remain,
15734
+ * and callers can inspect pendingCommitCount afterward to detect that case.
15735
+ *
15736
+ * @param {Object} [options] - Settle options
15737
+ * @param {number} [options.timeoutMs] - Maximum time to wait in milliseconds
15738
+ * @return {Promise<void>} A promise that resolves after the drain or timeout
15739
+ */
15740
+ whenCommitsSettled(options) {
15741
+ if (this._pendingCommitCount === 0) {
15742
+ return Promise.resolve();
15743
+ }
15744
+ return new Promise((resolve) => {
15745
+ const waiter = { resolve };
15746
+ if (options?.timeoutMs !== void 0) {
15747
+ waiter.timeoutId = setTimeout(() => {
15748
+ const waiterIndex = this._commitSettleWaiters.indexOf(waiter);
15749
+ if (waiterIndex === -1) {
15750
+ return;
15751
+ }
15752
+ this._commitSettleWaiters.splice(waiterIndex, 1);
15753
+ resolve();
15754
+ }, options.timeoutMs);
15755
+ }
15756
+ this._commitSettleWaiters.push(waiter);
15757
+ });
15758
+ }
15759
+ /** Resolve and clear every waiter after the pending count reaches zero. */
15760
+ _flushCommitSettleWaiters() {
15761
+ const waiters = this._commitSettleWaiters.splice(0);
15762
+ for (const waiter of waiters) {
15763
+ if (waiter.timeoutId !== void 0) {
15764
+ clearTimeout(waiter.timeoutId);
15765
+ }
15766
+ waiter.resolve();
15767
+ }
15768
+ }
15639
15769
  /**
15640
15770
  * Terminates the current run of the API
15641
15771
  * @param {string} callbackName
@@ -15656,7 +15786,7 @@ class BaseAPI {
15656
15786
  } else {
15657
15787
  stateCheckPassed = true;
15658
15788
  this.processListeners("BeforeTerminate");
15659
- const result = this.storeData(true);
15789
+ const result = this.storeData(true, "terminate");
15660
15790
  if ((result.errorCode ?? 0) > 0) {
15661
15791
  if (result.errorMessage) {
15662
15792
  this.apiLog(
@@ -15708,6 +15838,9 @@ class BaseAPI {
15708
15838
  } catch (e) {
15709
15839
  returnValue = this.handleValueAccessException(CMIElement, e, returnValue);
15710
15840
  }
15841
+ if (this.lastErrorCode === "0") {
15842
+ this.checkUninitializedGet(CMIElement, returnValue);
15843
+ }
15711
15844
  this.processListeners(callbackName, CMIElement);
15712
15845
  }
15713
15846
  this.apiLog(callbackName, ": returned: " + returnValue, LogLevelEnum.INFO, CMIElement);
@@ -15717,6 +15850,10 @@ class BaseAPI {
15717
15850
  if (this.lastErrorCode === "0") {
15718
15851
  this.clearSCORMError(returnValue);
15719
15852
  }
15853
+ const rawReturn = returnValue;
15854
+ if (typeof rawReturn === "number" || typeof rawReturn === "boolean") {
15855
+ return String(rawReturn);
15856
+ }
15720
15857
  return returnValue;
15721
15858
  }
15722
15859
  /**
@@ -15769,9 +15906,10 @@ class BaseAPI {
15769
15906
  * Orders LMS to store all content parameters
15770
15907
  * @param {string} callbackName
15771
15908
  * @param {boolean} checkTerminated
15909
+ * @param {CommitTrigger} trigger - What initiated the commit
15772
15910
  * @return {string}
15773
15911
  */
15774
- commit(callbackName, checkTerminated = false) {
15912
+ commit(callbackName, checkTerminated = false, trigger = "manual") {
15775
15913
  this.clearScheduledCommit();
15776
15914
  let returnValue = global_constants.SCORM_TRUE;
15777
15915
  if (this.isNotInitialized()) {
@@ -15783,7 +15921,7 @@ class BaseAPI {
15783
15921
  this.throwSCORMError("api", errorCode);
15784
15922
  if (errorCode === 143) returnValue = global_constants.SCORM_FALSE;
15785
15923
  } else {
15786
- const result = this.storeData(false);
15924
+ const result = this.storeData(false, trigger);
15787
15925
  const errorCode = result.errorCode ?? 0;
15788
15926
  if (errorCode > 0) {
15789
15927
  if (result.errorMessage) {
@@ -16014,7 +16152,16 @@ class BaseAPI {
16014
16152
  * @return {string}
16015
16153
  */
16016
16154
  _commonSetCMIValue(methodName, scorm2004, CMIElement, value) {
16017
- return this._cmiValueAccessService.setCMIValue(methodName, scorm2004, CMIElement, value);
16155
+ const result = this._cmiValueAccessService.setCMIValue(
16156
+ methodName,
16157
+ scorm2004,
16158
+ CMIElement,
16159
+ value
16160
+ );
16161
+ if (result === global_constants.SCORM_TRUE) {
16162
+ this._setCMIElements.add(CMIElement);
16163
+ }
16164
+ return result;
16018
16165
  }
16019
16166
  /**
16020
16167
  * Gets a value from the CMI Object.
@@ -16028,6 +16175,18 @@ class BaseAPI {
16028
16175
  _commonGetCMIValue(methodName, scorm2004, CMIElement) {
16029
16176
  return this._cmiValueAccessService.getCMIValue(methodName, scorm2004, CMIElement);
16030
16177
  }
16178
+ /**
16179
+ * Hook invoked by getValue after a successful resolution. Standards that must
16180
+ * distinguish "implemented but never set, no default value" from a
16181
+ * legitimately empty value override this to raise VALUE_NOT_INITIALIZED.
16182
+ * Default is a no-op, so SCORM 1.2 / AICC keep returning "" with code 0.
16183
+ *
16184
+ * @param {string} _CMIElement - the element that was read
16185
+ * @param {any} _returnValue - the value getCMIValue resolved
16186
+ * @protected
16187
+ */
16188
+ checkUninitializedGet(_CMIElement, _returnValue) {
16189
+ }
16031
16190
  /**
16032
16191
  * Returns true if the API's current state is STATE_INITIALIZED
16033
16192
  *
@@ -16110,9 +16269,14 @@ class BaseAPI {
16110
16269
  * @param {string} functionName - The name of the function/event that occurred
16111
16270
  * @param {string} CMIElement - Optional CMI element involved in the event
16112
16271
  * @param {any} value - Optional value associated with the event
16272
+ * @param {CommitEventContext} context - Optional context for commit lifecycle events
16113
16273
  */
16114
- processListeners(functionName, CMIElement, value) {
16115
- this._eventService.processListeners(functionName, CMIElement, value);
16274
+ processListeners(functionName, CMIElement, value, context) {
16275
+ if (context !== void 0) {
16276
+ this._eventService.processListeners(functionName, CMIElement, value, context);
16277
+ } else {
16278
+ this._eventService.processListeners(functionName, CMIElement, value);
16279
+ }
16116
16280
  }
16117
16281
  /**
16118
16282
  * Throws a SCORM error with the specified error number and optional message.
@@ -16245,36 +16409,106 @@ class BaseAPI {
16245
16409
  * @param {string} url - The URL to send the request to
16246
16410
  * @param {CommitObject | StringKeyMap | Array<any>} params - The parameters to send
16247
16411
  * @param {boolean} immediate - Whether to send the request immediately without waiting
16412
+ * @param {CommitTrigger} [trigger] - What initiated the commit
16248
16413
  * @returns {ResultObject} - The result of the request
16249
16414
  */
16250
- processHttpRequest(url, params, immediate = false) {
16251
- if (this.settings.enableOfflineSupport && this._offlineStorageService && !this._offlineStorageService.isDeviceOnline() && this._courseId) {
16252
- this.apiLog(
16253
- "processHttpRequest",
16254
- "Device is offline, storing data locally",
16255
- LogLevelEnum.INFO
16256
- );
16257
- if (params && typeof params === "object" && "cmi" in params) {
16258
- return this._offlineStorageService.storeOffline(this._courseId, params);
16259
- } else {
16415
+ processHttpRequest(url, params, immediate = false, trigger) {
16416
+ const sequence = ++this._commitSequence;
16417
+ this._pendingCommitCount += 1;
16418
+ let settled = false;
16419
+ let completionDeferred = false;
16420
+ const settle = () => {
16421
+ if (settled) {
16422
+ return;
16423
+ }
16424
+ settled = true;
16425
+ this._pendingCommitCount -= 1;
16426
+ if (this._pendingCommitCount === 0) {
16427
+ this._flushCommitSettleWaiters();
16428
+ }
16429
+ };
16430
+ try {
16431
+ const resolvedTrigger = trigger ?? (immediate ? "terminate" : "manual");
16432
+ let finalParams = params;
16433
+ if (immediate && this.settings.terminateCommitPayloadField) {
16434
+ const field = this.settings.terminateCommitPayloadField;
16435
+ if (Array.isArray(finalParams)) {
16436
+ finalParams = [...finalParams, `${encodeURIComponent(field)}=true`];
16437
+ } else if (finalParams && typeof finalParams === "object") {
16438
+ finalParams = { ...finalParams, [field]: true };
16439
+ }
16440
+ }
16441
+ if (this.settings.includeCommitSequence === true) {
16442
+ if (Array.isArray(finalParams)) {
16443
+ finalParams = [...finalParams, `commitSequence=${sequence}`];
16444
+ } else if (finalParams && typeof finalParams === "object") {
16445
+ finalParams = { ...finalParams, commitSequence: sequence };
16446
+ }
16447
+ }
16448
+ const finalUrl = immediate && this.settings.terminateCommitParam ? appendQueryParam(url, this.settings.terminateCommitParam, "true") : url;
16449
+ const metadata = {
16450
+ isTerminateCommit: immediate,
16451
+ trigger: resolvedTrigger,
16452
+ sequence
16453
+ };
16454
+ const context = {
16455
+ url: finalUrl,
16456
+ trigger: resolvedTrigger,
16457
+ isTerminateCommit: immediate,
16458
+ sequence
16459
+ };
16460
+ if (this.settings.enableOfflineSupport && this._offlineStorageService && !this._offlineStorageService.isDeviceOnline() && this._courseId) {
16260
16461
  this.apiLog(
16261
16462
  "processHttpRequest",
16262
- "Invalid commit data format for offline storage",
16263
- LogLevelEnum.ERROR
16463
+ "Device is offline, storing data locally",
16464
+ LogLevelEnum.INFO
16264
16465
  );
16265
- return {
16266
- result: global_constants.SCORM_FALSE,
16267
- errorCode: this._error_codes.GENERAL ?? 101
16268
- };
16466
+ if (finalParams && typeof finalParams === "object" && "cmi" in finalParams) {
16467
+ return this._offlineStorageService.storeOffline(
16468
+ this._courseId,
16469
+ finalParams,
16470
+ { isTerminateCommit: immediate, sequence }
16471
+ );
16472
+ } else {
16473
+ this.apiLog(
16474
+ "processHttpRequest",
16475
+ "Invalid commit data format for offline storage",
16476
+ LogLevelEnum.ERROR
16477
+ );
16478
+ return {
16479
+ result: global_constants.SCORM_FALSE,
16480
+ errorCode: this._error_codes.GENERAL ?? 101
16481
+ };
16482
+ }
16483
+ }
16484
+ const apiLog = (functionName, message, level, element) => this.apiLog(functionName, message, level, element);
16485
+ const processListeners = (functionName, CMIElement, value) => {
16486
+ if (functionName === "CommitSuccess" || functionName === "CommitError") {
16487
+ if (functionName === "CommitError" && typeof value === "number") {
16488
+ context.errorCode = value;
16489
+ }
16490
+ settle();
16491
+ this.processListeners(functionName, CMIElement, value, context);
16492
+ } else {
16493
+ this.processListeners(functionName, CMIElement, value);
16494
+ }
16495
+ };
16496
+ const result = this._httpService.processHttpRequest(
16497
+ finalUrl,
16498
+ finalParams,
16499
+ immediate,
16500
+ apiLog,
16501
+ processListeners,
16502
+ metadata,
16503
+ settle
16504
+ );
16505
+ completionDeferred = this._httpService.reportsRequestCompletion === true;
16506
+ return result;
16507
+ } finally {
16508
+ if (!completionDeferred) {
16509
+ settle();
16269
16510
  }
16270
16511
  }
16271
- return this._httpService.processHttpRequest(
16272
- url,
16273
- params,
16274
- immediate,
16275
- (functionName, message, level, element) => this.apiLog(functionName, message, level, element),
16276
- (functionName, CMIElement, value) => this.processListeners(functionName, CMIElement, value)
16277
- );
16278
16512
  }
16279
16513
  /**
16280
16514
  * Schedules a commit operation to occur after a specified delay.
@@ -17258,6 +17492,11 @@ class CMIScore extends BaseCMI {
17258
17492
  __invalid_range_code;
17259
17493
  __decimal_regex;
17260
17494
  __error_class;
17495
+ /**
17496
+ * When true, an empty string is a valid value (clears the element). SCORM 1.2
17497
+ * score elements may be blank per the ADL 1.2 CTS; SCORM 2004 leaves this off.
17498
+ */
17499
+ __allow_empty_string;
17261
17500
  _raw = "";
17262
17501
  _min = "";
17263
17502
  _max;
@@ -17292,6 +17531,7 @@ class CMIScore extends BaseCMI {
17292
17531
  this.__invalid_range_code = params.invalidRangeCode || scorm12_errors.VALUE_OUT_OF_RANGE;
17293
17532
  this.__decimal_regex = params.decimalRegex || scorm12_regex.CMIDecimal;
17294
17533
  this.__error_class = params.errorClass;
17534
+ this.__allow_empty_string = params.allowEmptyString ?? false;
17295
17535
  }
17296
17536
  /**
17297
17537
  * Called when the API has been reset
@@ -17338,7 +17578,8 @@ class CMIScore extends BaseCMI {
17338
17578
  this.__score_range,
17339
17579
  this.__invalid_type_code,
17340
17580
  this.__invalid_range_code,
17341
- this.__error_class
17581
+ this.__error_class,
17582
+ this.__allow_empty_string
17342
17583
  )) {
17343
17584
  this._raw = raw;
17344
17585
  }
@@ -17362,7 +17603,8 @@ class CMIScore extends BaseCMI {
17362
17603
  this.__score_range,
17363
17604
  this.__invalid_type_code,
17364
17605
  this.__invalid_range_code,
17365
- this.__error_class
17606
+ this.__error_class,
17607
+ this.__allow_empty_string
17366
17608
  )) {
17367
17609
  this._min = min;
17368
17610
  }
@@ -17386,7 +17628,8 @@ class CMIScore extends BaseCMI {
17386
17628
  this.__score_range,
17387
17629
  this.__invalid_type_code,
17388
17630
  this.__invalid_range_code,
17389
- this.__error_class
17631
+ this.__error_class,
17632
+ this.__allow_empty_string
17390
17633
  )) {
17391
17634
  this._max = max;
17392
17635
  }
@@ -22180,6 +22423,35 @@ class Scorm2004DataSerializer {
22180
22423
  }
22181
22424
  }
22182
22425
 
22426
+ const NO_DEFAULT_2004_ELEMENTS = /* @__PURE__ */ new Set([
22427
+ "cmi.suspend_data",
22428
+ "cmi.location",
22429
+ "cmi.scaled_passing_score",
22430
+ "cmi.max_time_allowed",
22431
+ "cmi.completion_threshold",
22432
+ "cmi.progress_measure",
22433
+ "cmi.score.scaled",
22434
+ "cmi.score.raw",
22435
+ "cmi.score.min",
22436
+ "cmi.score.max",
22437
+ "cmi.objectives.N.score.scaled",
22438
+ "cmi.objectives.N.score.raw",
22439
+ "cmi.objectives.N.score.min",
22440
+ "cmi.objectives.N.score.max",
22441
+ "cmi.objectives.N.progress_measure",
22442
+ "cmi.objectives.N.description",
22443
+ "cmi.interactions.N.weighting",
22444
+ "cmi.interactions.N.type",
22445
+ "cmi.interactions.N.timestamp",
22446
+ "cmi.interactions.N.result",
22447
+ "cmi.interactions.N.latency",
22448
+ "cmi.interactions.N.learner_response",
22449
+ "cmi.interactions.N.description",
22450
+ "cmi.comments_from_learner.N.timestamp"
22451
+ ]);
22452
+ function normalizeCMIIndices(CMIElement) {
22453
+ return CMIElement.replace(/\.\d+(?=\.|$)/g, ".N");
22454
+ }
22183
22455
  class Scorm2004API extends BaseAPI {
22184
22456
  _version = "1.0";
22185
22457
  _sequencing;
@@ -22705,6 +22977,39 @@ class Scorm2004API extends BaseAPI {
22705
22977
  getCMIValue(CMIElement) {
22706
22978
  return this._commonGetCMIValue("GetValue", true, CMIElement);
22707
22979
  }
22980
+ /**
22981
+ * Raises 403 (VALUE_NOT_INITIALIZED) when an implemented, no-default element is
22982
+ * read before it has ever been set, per IEEE 1484.11.2 / SCORM 2004 RTE.
22983
+ *
22984
+ * Invoked by BaseAPI.getValue after an otherwise-successful resolution, so it
22985
+ * only sees values the data model already returned cleanly. The decision is
22986
+ * deliberately gated to this public boundary (never the getters) to keep
22987
+ * serialization/commit/rollup — which read the getters directly — unaffected.
22988
+ *
22989
+ * A 403 is raised only when all hold:
22990
+ * - the resolved value is "" — any non-empty value is by definition set,
22991
+ * including values written by internal sequencing/activity-tree paths that
22992
+ * bypass _commonSetCMIValue (those only ever write non-empty values);
22993
+ * - the element was never set — _setCMIElements records every successful
22994
+ * SetValue, loadFromJSON, and global-objective restore (all route through
22995
+ * _commonSetCMIValue), so an explicit SetValue(x, "") still reads back as
22996
+ * "" with code 0; and
22997
+ * - the element has no spec-defined default ({@link NO_DEFAULT_2004_ELEMENTS}).
22998
+ *
22999
+ * @param {string} CMIElement
23000
+ * @param {*} returnValue - the value getCMIValue resolved
23001
+ * @protected
23002
+ */
23003
+ checkUninitializedGet(CMIElement, returnValue) {
23004
+ if (returnValue !== "") return;
23005
+ if (this._setCMIElements.has(CMIElement)) return;
23006
+ if (!NO_DEFAULT_2004_ELEMENTS.has(normalizeCMIIndices(CMIElement))) return;
23007
+ this.throwSCORMError(
23008
+ CMIElement,
23009
+ this._error_codes.VALUE_NOT_INITIALIZED ?? 403,
23010
+ `The data model element passed to GetValue (${CMIElement}) has not been initialized.`
23011
+ );
23012
+ }
22708
23013
  /**
22709
23014
  * Returns the message that corresponds to errorNumber.
22710
23015
  * @param {(string|number)} errorNumber
@@ -22751,9 +23056,10 @@ class Scorm2004API extends BaseAPI {
22751
23056
  /**
22752
23057
  * Attempts to store the data to the LMS
22753
23058
  * @param {boolean} terminateCommit
23059
+ * @param {CommitTrigger} [trigger] - What initiated the commit
22754
23060
  * @return {ResultObject}
22755
23061
  */
22756
- storeData(terminateCommit) {
23062
+ storeData(terminateCommit, trigger) {
22757
23063
  if (terminateCommit) {
22758
23064
  if (this.cmi.mode === "normal") {
22759
23065
  if (this.cmi.credit === "credit") {
@@ -22801,7 +23107,8 @@ class Scorm2004API extends BaseAPI {
22801
23107
  const result = this.processHttpRequest(
22802
23108
  this.settings.lmsCommitUrl,
22803
23109
  commitObject,
22804
- terminateCommit
23110
+ terminateCommit,
23111
+ trigger
22805
23112
  );
22806
23113
  if (navRequest && result.navRequest !== void 0 && result.navRequest !== "" && typeof result.navRequest === "string") {
22807
23114
  const parsed = parseNavigationRequest(result.navRequest);