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
@@ -268,6 +268,15 @@ function parseNavigationRequest(navRequest) {
268
268
  };
269
269
  }
270
270
 
271
+ const appendQueryParam = (url, name, value) => {
272
+ const fragmentIndex = url.indexOf("#");
273
+ const baseUrl = fragmentIndex === -1 ? url : url.slice(0, fragmentIndex);
274
+ const fragment = fragmentIndex === -1 ? "" : url.slice(fragmentIndex);
275
+ const separator = baseUrl.includes("?") ? baseUrl.endsWith("?") || baseUrl.endsWith("&") ? "" : "&" : "?";
276
+ const queryParam = `${encodeURIComponent(name)}=${encodeURIComponent(String(value))}`;
277
+ return `${baseUrl}${separator}${queryParam}${fragment}`;
278
+ };
279
+
271
280
  class BaseCMI {
272
281
  /**
273
282
  * Flag used during JSON serialization to allow getter access without initialization checks.
@@ -659,7 +668,10 @@ const scorm12_errors = {
659
668
  READ_ONLY_ELEMENT: 403,
660
669
  WRITE_ONLY_ELEMENT: 404,
661
670
  TYPE_MISMATCH: 405,
662
- VALUE_OUT_OF_RANGE: 407,
671
+ // SCORM 1.2 has no out-of-range error code; an out-of-range value is reported
672
+ // as 405 (incorrect data type), per the SCORM 1.2 RTE error table and the ADL
673
+ // 1.2 CTS (DataModelValidator.checkScoreDecimal failure -> CMICategory 405).
674
+ VALUE_OUT_OF_RANGE: 405,
663
675
  DEPENDENCY_NOT_ESTABLISHED: 408
664
676
  };
665
677
  const scorm2004_errors = {
@@ -807,6 +819,7 @@ const DefaultSettings = {
807
819
  xhrWithCredentials: false,
808
820
  fetchMode: "cors",
809
821
  asyncModeBeaconBehavior: "never",
822
+ includeCommitSequence: false,
810
823
  responseHandler: async function(response) {
811
824
  if (typeof response !== "undefined") {
812
825
  let httpResult = null;
@@ -1739,7 +1752,7 @@ class ScheduledCommit {
1739
1752
  wrapper() {
1740
1753
  if (!this._cancelled) {
1741
1754
  if (this._API.isInitialized()) {
1742
- (async () => await this._API.commit(this._callback))();
1755
+ (async () => await this._API.commit(this._callback, false, "autocommit"))();
1743
1756
  }
1744
1757
  }
1745
1758
  }
@@ -5084,6 +5097,7 @@ class ActivityDeliveryService {
5084
5097
  }
5085
5098
 
5086
5099
  class AsynchronousHttpService {
5100
+ reportsRequestCompletion = true;
5087
5101
  settings;
5088
5102
  error_codes;
5089
5103
  /**
@@ -5108,10 +5122,20 @@ class AsynchronousHttpService {
5108
5122
  * @param {boolean} immediate - Whether to send the request immediately without waiting
5109
5123
  * @param {Function} apiLog - Function to log API messages with appropriate levels
5110
5124
  * @param {Function} processListeners - Function to trigger event listeners for commit events
5125
+ * @param {CommitMetadata} metadata - Metadata describing the captured commit
5126
+ * @param {Function} onRequestComplete - Callback invoked after the background request settles
5111
5127
  * @return {ResultObject} - Immediate optimistic success result
5112
5128
  */
5113
- processHttpRequest(url, params, immediate = false, apiLog, processListeners) {
5114
- this._performAsyncRequest(url, params, immediate, apiLog, processListeners);
5129
+ processHttpRequest(url, params, immediate = false, apiLog, processListeners, metadata, onRequestComplete) {
5130
+ this._performAsyncRequest(
5131
+ url,
5132
+ params,
5133
+ immediate,
5134
+ apiLog,
5135
+ processListeners,
5136
+ metadata,
5137
+ onRequestComplete
5138
+ );
5115
5139
  return {
5116
5140
  result: global_constants.SCORM_TRUE,
5117
5141
  errorCode: 0
@@ -5124,11 +5148,14 @@ class AsynchronousHttpService {
5124
5148
  * @param {boolean} immediate - Whether this is an immediate request
5125
5149
  * @param apiLog - Function to log API messages
5126
5150
  * @param {Function} processListeners - Function to process event listeners
5151
+ * @param {CommitMetadata} metadata - Metadata describing the captured commit
5152
+ * @param {Function} onRequestComplete - Callback invoked after the request settles
5127
5153
  * @private
5128
5154
  */
5129
- async _performAsyncRequest(url, params, immediate, apiLog, processListeners) {
5155
+ async _performAsyncRequest(url, params, immediate, apiLog, processListeners, metadata, onRequestComplete) {
5130
5156
  try {
5131
- const processedParams = this.settings.requestHandler(params);
5157
+ const handledParams = metadata === void 0 ? this.settings.requestHandler(params) : this.settings.requestHandler(params, metadata);
5158
+ const processedParams = handledParams;
5132
5159
  let response;
5133
5160
  if (immediate && this.settings.asyncModeBeaconBehavior !== "never") {
5134
5161
  response = await this.performBeacon(url, processedParams);
@@ -5144,7 +5171,9 @@ class AsynchronousHttpService {
5144
5171
  } catch (e) {
5145
5172
  const message = e instanceof Error ? e.message : String(e);
5146
5173
  apiLog("processHttpRequest", `Async request failed: ${message}`, LogLevelEnum.ERROR);
5147
- processListeners("CommitError");
5174
+ processListeners("CommitError", void 0, this.error_codes.GENERAL_COMMIT_FAILURE || 391);
5175
+ } finally {
5176
+ onRequestComplete?.();
5148
5177
  }
5149
5178
  }
5150
5179
  /**
@@ -5287,10 +5316,13 @@ class CMIValueAccessService {
5287
5316
  }
5288
5317
  /**
5289
5318
  * Gets the appropriate error code for undefined data model elements.
5290
- * SCORM 2004 uses UNDEFINED_DATA_MODEL, SCORM 1.2 uses GENERAL.
5319
+ * Both SCORM 2004 and SCORM 1.2 use UNDEFINED_DATA_MODEL (401): an
5320
+ * unrecognized element is "Not implemented", not a general exception. SCORM
5321
+ * 1.2 previously returned GENERAL (101) here, which is non-conformant — the
5322
+ * ADL 1.2 CTS and the SCORM 1.2 RTE spec expect 401 for an unknown element.
5291
5323
  */
5292
- getUndefinedDataModelErrorCode(scorm2004) {
5293
- return scorm2004 ? getErrorCode(this.context.errorCodes, "UNDEFINED_DATA_MODEL") : getErrorCode(this.context.errorCodes, "GENERAL");
5324
+ getUndefinedDataModelErrorCode() {
5325
+ return getErrorCode(this.context.errorCodes, "UNDEFINED_DATA_MODEL");
5294
5326
  }
5295
5327
  /**
5296
5328
  * Sets a value on a CMI element path
@@ -5318,7 +5350,7 @@ class CMIValueAccessService {
5318
5350
  let returnValue = global_constants.SCORM_FALSE;
5319
5351
  let foundFirstIndex = false;
5320
5352
  const invalidErrorMessage = `The data model element passed to ${methodName} (${CMIElement}) is not a valid SCORM data model element.`;
5321
- const invalidErrorCode = this.getUndefinedDataModelErrorCode(scorm2004);
5353
+ const invalidErrorCode = this.getUndefinedDataModelErrorCode();
5322
5354
  for (let idx = 0; idx < structure.length; idx++) {
5323
5355
  const attribute = structure[idx];
5324
5356
  if (idx === structure.length - 1) {
@@ -5388,12 +5420,13 @@ class CMIValueAccessService {
5388
5420
  );
5389
5421
  return "";
5390
5422
  }
5423
+ this.context.setLastErrorCode("0");
5391
5424
  const structure = CMIElement.split(".");
5392
5425
  let refObject = this.context.getDataModel();
5393
5426
  let attribute = null;
5394
5427
  const uninitializedErrorMessage = `The data model element passed to ${methodName} (${CMIElement}) has not been initialized.`;
5395
5428
  const invalidErrorMessage = `The data model element passed to ${methodName} (${CMIElement}) is not a valid SCORM data model element.`;
5396
- const invalidErrorCode = this.getUndefinedDataModelErrorCode(scorm2004);
5429
+ const invalidErrorCode = this.getUndefinedDataModelErrorCode();
5397
5430
  for (let idx = 0; idx < structure.length; idx++) {
5398
5431
  attribute = structure[idx];
5399
5432
  const validationResult = this.validateGetAttribute(
@@ -6105,8 +6138,9 @@ class EventService {
6105
6138
  * @param {string} functionName - The name of the function that triggered the event
6106
6139
  * @param {string} CMIElement - The CMI element that was affected
6107
6140
  * @param {any} value - The value that was set
6141
+ * @param {CommitEventContext} context - Optional context for commit lifecycle events
6108
6142
  */
6109
- processListeners(functionName, CMIElement, value) {
6143
+ processListeners(functionName, CMIElement, value, context) {
6110
6144
  this.apiLog(functionName, value, LogLevelEnum.INFO, CMIElement);
6111
6145
  const listeners = this.listenerMap.get(functionName);
6112
6146
  if (!listeners) return;
@@ -6131,9 +6165,17 @@ class EventService {
6131
6165
  if (functionName.startsWith("Sequence")) {
6132
6166
  listener.callback(value);
6133
6167
  } else if (functionName === "CommitError") {
6134
- listener.callback(value);
6168
+ if (context !== void 0) {
6169
+ listener.callback(value, context);
6170
+ } else {
6171
+ listener.callback(value);
6172
+ }
6135
6173
  } else if (functionName === "CommitSuccess") {
6136
- listener.callback();
6174
+ if (context !== void 0) {
6175
+ listener.callback(context);
6176
+ } else {
6177
+ listener.callback();
6178
+ }
6137
6179
  } else {
6138
6180
  listener.callback(CMIElement, value);
6139
6181
  }
@@ -6267,16 +6309,19 @@ class OfflineStorageService {
6267
6309
  * Store commit data offline
6268
6310
  * @param {string} courseId - Identifier for the course
6269
6311
  * @param {CommitObject} commitData - The data to store offline
6312
+ * @param {OfflineCommitMetadata} metadata - Metadata captured with the original commit
6270
6313
  * @returns {ResultObject} - Result of the storage operation
6271
6314
  */
6272
- storeOffline(courseId, commitData) {
6315
+ storeOffline(courseId, commitData, metadata) {
6273
6316
  try {
6274
6317
  const queueItem = {
6275
6318
  id: `${courseId}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
6276
6319
  courseId,
6277
6320
  timestamp: Date.now(),
6278
6321
  data: commitData,
6279
- syncAttempts: 0
6322
+ syncAttempts: 0,
6323
+ ...metadata?.isTerminateCommit !== void 0 ? { isTerminateCommit: metadata.isTerminateCommit } : {},
6324
+ ...metadata?.sequence !== void 0 ? { sequence: metadata.sequence } : {}
6280
6325
  };
6281
6326
  const currentQueue = this.getFromStorage(this.syncQueue) || [];
6282
6327
  currentQueue.push(queueItem);
@@ -6355,7 +6400,10 @@ class OfflineStorageService {
6355
6400
  continue;
6356
6401
  }
6357
6402
  try {
6358
- const syncResult = await this.sendDataToLMS(item.data);
6403
+ const syncResult = await this.sendDataToLMS(item.data, {
6404
+ ...item.isTerminateCommit !== void 0 ? { isTerminateCommit: item.isTerminateCommit } : {},
6405
+ ...item.sequence !== void 0 ? { sequence: item.sequence } : {}
6406
+ });
6359
6407
  if (syncResult.result === true || syncResult.result === global_constants.SCORM_TRUE) {
6360
6408
  this.apiLog(
6361
6409
  "OfflineStorageService",
@@ -6402,17 +6450,25 @@ class OfflineStorageService {
6402
6450
  /**
6403
6451
  * Send data to the LMS when online
6404
6452
  * @param {CommitObject} data - The data to send to the LMS
6453
+ * @param {OfflineCommitMetadata} metadata - Metadata captured with the original commit
6405
6454
  * @returns {Promise<ResultObject>} - Result of the sync operation
6406
6455
  */
6407
- async sendDataToLMS(data) {
6408
- if (!this.settings.lmsCommitUrl) {
6456
+ async sendDataToLMS(data, metadata) {
6457
+ const configuredCommitUrl = this.settings.lmsCommitUrl;
6458
+ if (!configuredCommitUrl) {
6409
6459
  return {
6410
6460
  result: global_constants.SCORM_FALSE,
6411
6461
  errorCode: this.error_codes.GENERAL || 101
6412
6462
  };
6413
6463
  }
6414
6464
  try {
6415
- const processedData = this.settings.requestHandler(data);
6465
+ const lmsCommitUrl = String(configuredCommitUrl);
6466
+ const processedData = this.settings.requestHandler(data, {
6467
+ isTerminateCommit: metadata?.isTerminateCommit ?? false,
6468
+ trigger: "offline-replay",
6469
+ ...metadata?.sequence !== void 0 ? { sequence: metadata.sequence } : {}
6470
+ });
6471
+ const requestUrl = metadata?.isTerminateCommit && this.settings.terminateCommitParam ? appendQueryParam(lmsCommitUrl, this.settings.terminateCommitParam, "true") : lmsCommitUrl;
6416
6472
  const init = {
6417
6473
  method: "POST",
6418
6474
  mode: this.settings.fetchMode,
@@ -6425,7 +6481,7 @@ class OfflineStorageService {
6425
6481
  if (this.settings.xhrWithCredentials) {
6426
6482
  init.credentials = "include";
6427
6483
  }
6428
- const response = await fetch(this.settings.lmsCommitUrl, init);
6484
+ const response = await fetch(requestUrl, init);
6429
6485
  const result = typeof this.settings.responseHandler === "function" ? await this.settings.responseHandler(response) : await response.json();
6430
6486
  if (response.status >= 200 && response.status <= 299 && (result.result === true || result.result === global_constants.SCORM_TRUE)) {
6431
6487
  if (!Object.hasOwnProperty.call(result, "errorCode")) {
@@ -15390,6 +15446,8 @@ class SynchronousHttpService {
15390
15446
  * @param {boolean} immediate - Whether this is a termination commit (use sendBeacon)
15391
15447
  * @param {Function} _apiLog - Function to log API messages (unused in synchronous mode - errors returned directly)
15392
15448
  * @param {Function} _processListeners - Function to trigger event listeners (unused in synchronous mode - no async events)
15449
+ * @param {CommitMetadata} metadata - Metadata describing the captured commit
15450
+ * @param {Function} _onRequestComplete - Completion callback (unused because requests settle before return)
15393
15451
  * @return {ResultObject} - The result of the request (synchronous)
15394
15452
  *
15395
15453
  * @remarks
@@ -15399,21 +15457,23 @@ class SynchronousHttpService {
15399
15457
  * - No async events need to be triggered (CommitSuccess/CommitError) since results are synchronous
15400
15458
  * - AsynchronousHttpService uses these parameters to handle background request results
15401
15459
  */
15402
- processHttpRequest(url, params, immediate = false, _apiLog, _processListeners) {
15460
+ processHttpRequest(url, params, immediate = false, _apiLog, _processListeners, metadata, _onRequestComplete) {
15403
15461
  if (immediate) {
15404
- return this._handleImmediateRequest(url, params);
15462
+ return this._handleImmediateRequest(url, params, metadata);
15405
15463
  }
15406
- return this._performSyncXHR(url, params);
15464
+ return this._performSyncXHR(url, params, metadata);
15407
15465
  }
15408
15466
  /**
15409
15467
  * Handles an immediate request using sendBeacon
15410
15468
  * @param {string} url - The URL to send the request to
15411
15469
  * @param {CommitObject|StringKeyMap|Array} params - The parameters to include in the request
15470
+ * @param {CommitMetadata} metadata - Metadata describing the captured commit
15412
15471
  * @return {ResultObject} - The result based on beacon success
15413
15472
  * @private
15414
15473
  */
15415
- _handleImmediateRequest(url, params) {
15416
- const requestPayload = this.settings.requestHandler(params) ?? params;
15474
+ _handleImmediateRequest(url, params, metadata) {
15475
+ const handledPayload = metadata === void 0 ? this.settings.requestHandler(params) : this.settings.requestHandler(params, metadata);
15476
+ const requestPayload = handledPayload ?? params;
15417
15477
  const { body } = this._prepareRequestBody(requestPayload);
15418
15478
  const beaconSuccess = navigator.sendBeacon(
15419
15479
  url,
@@ -15428,11 +15488,13 @@ class SynchronousHttpService {
15428
15488
  * Performs a synchronous XMLHttpRequest
15429
15489
  * @param {string} url - The URL to send the request to
15430
15490
  * @param {CommitObject|StringKeyMap|Array} params - The parameters to include in the request
15491
+ * @param {CommitMetadata} metadata - Metadata describing the captured commit
15431
15492
  * @return {ResultObject} - The result of the request
15432
15493
  * @private
15433
15494
  */
15434
- _performSyncXHR(url, params) {
15435
- const requestPayload = this.settings.requestHandler(params) ?? params;
15495
+ _performSyncXHR(url, params, metadata) {
15496
+ const handledPayload = metadata === void 0 ? this.settings.requestHandler(params) : this.settings.requestHandler(params, metadata);
15497
+ const requestPayload = handledPayload ?? params;
15436
15498
  const { body, contentType } = this._prepareRequestBody(requestPayload);
15437
15499
  const xhr = new XMLHttpRequest();
15438
15500
  xhr.open("POST", url, false);
@@ -15511,9 +15573,15 @@ class ValidationService {
15511
15573
  * @param {number} invalidTypeCode - The error code for invalid type
15512
15574
  * @param {number} invalidRangeCode - The error code for invalid range
15513
15575
  * @param {typeof BaseScormValidationError} errorClass - The error class to use for validation errors
15576
+ * @param {boolean} allowEmptyString - When true, an empty string is accepted (clears the value).
15577
+ * SCORM 1.2 score elements may be blank per the ADL 1.2 CTS
15578
+ * (DataModelValidator.checkScoreDecimal treats blank as valid).
15514
15579
  * @return {boolean} - True if validation passes, throws an error otherwise
15515
15580
  */
15516
- validateScore(CMIElement, value, decimalRegex, scoreRange, invalidTypeCode, invalidRangeCode, errorClass) {
15581
+ validateScore(CMIElement, value, decimalRegex, scoreRange, invalidTypeCode, invalidRangeCode, errorClass, allowEmptyString = false) {
15582
+ if (allowEmptyString && value === "") {
15583
+ return true;
15584
+ }
15517
15585
  return checkValidFormat(CMIElement, value, decimalRegex, invalidTypeCode, errorClass) && (!scoreRange || checkValidRange(CMIElement, value, scoreRange, invalidRangeCode, errorClass));
15518
15586
  }
15519
15587
  /**
@@ -15587,6 +15655,21 @@ class BaseAPI {
15587
15655
  _offlineStorageService;
15588
15656
  _cmiValueAccessService;
15589
15657
  _courseId = "";
15658
+ _pendingCommitCount = 0;
15659
+ /**
15660
+ * Monotonic sequence for commits captured by this API instance. It is
15661
+ * intentionally not reset by reset().
15662
+ */
15663
+ _commitSequence = 0;
15664
+ _commitSettleWaiters = [];
15665
+ /**
15666
+ * Canonical paths of every CMI element that has been explicitly assigned a
15667
+ * value via SetValue / loadFromJSON (both funnel through _commonSetCMIValue).
15668
+ * Used by standards that must tell "implemented but never set" apart from a
15669
+ * legitimately empty value when answering GetValue (SCORM 2004 error 403).
15670
+ * Cleared on reset so a fresh SCO attempt starts with nothing "set".
15671
+ */
15672
+ _setCMIElements = /* @__PURE__ */ new Set();
15590
15673
  /**
15591
15674
  * Constructor for Base API class. Sets some shared API fields, as well as
15592
15675
  * sets up options for the API.
@@ -15772,6 +15855,7 @@ class BaseAPI {
15772
15855
  this.lastErrorCode = "0";
15773
15856
  this._eventService.reset();
15774
15857
  this.startingData = {};
15858
+ this._setCMIElements.clear();
15775
15859
  if (this._offlineStorageService) {
15776
15860
  this._offlineStorageService.updateSettings(this.settings);
15777
15861
  if (settings?.courseId) {
@@ -15856,6 +15940,52 @@ class BaseAPI {
15856
15940
  this._loggingService?.setLogHandler(settings.onLogMessage);
15857
15941
  }
15858
15942
  }
15943
+ /**
15944
+ * Gets the number of captured commit requests that have not yet settled.
15945
+ *
15946
+ * @return {number} The number of in-flight commits
15947
+ */
15948
+ get pendingCommitCount() {
15949
+ return this._pendingCommitCount;
15950
+ }
15951
+ /**
15952
+ * Resolves when all currently in-flight commits have settled. A timeout is
15953
+ * best-effort: the promise resolves when it elapses even if commits remain,
15954
+ * and callers can inspect pendingCommitCount afterward to detect that case.
15955
+ *
15956
+ * @param {Object} [options] - Settle options
15957
+ * @param {number} [options.timeoutMs] - Maximum time to wait in milliseconds
15958
+ * @return {Promise<void>} A promise that resolves after the drain or timeout
15959
+ */
15960
+ whenCommitsSettled(options) {
15961
+ if (this._pendingCommitCount === 0) {
15962
+ return Promise.resolve();
15963
+ }
15964
+ return new Promise((resolve) => {
15965
+ const waiter = { resolve };
15966
+ if (options?.timeoutMs !== void 0) {
15967
+ waiter.timeoutId = setTimeout(() => {
15968
+ const waiterIndex = this._commitSettleWaiters.indexOf(waiter);
15969
+ if (waiterIndex === -1) {
15970
+ return;
15971
+ }
15972
+ this._commitSettleWaiters.splice(waiterIndex, 1);
15973
+ resolve();
15974
+ }, options.timeoutMs);
15975
+ }
15976
+ this._commitSettleWaiters.push(waiter);
15977
+ });
15978
+ }
15979
+ /** Resolve and clear every waiter after the pending count reaches zero. */
15980
+ _flushCommitSettleWaiters() {
15981
+ const waiters = this._commitSettleWaiters.splice(0);
15982
+ for (const waiter of waiters) {
15983
+ if (waiter.timeoutId !== void 0) {
15984
+ clearTimeout(waiter.timeoutId);
15985
+ }
15986
+ waiter.resolve();
15987
+ }
15988
+ }
15859
15989
  /**
15860
15990
  * Terminates the current run of the API
15861
15991
  * @param {string} callbackName
@@ -15876,7 +16006,7 @@ class BaseAPI {
15876
16006
  } else {
15877
16007
  stateCheckPassed = true;
15878
16008
  this.processListeners("BeforeTerminate");
15879
- const result = this.storeData(true);
16009
+ const result = this.storeData(true, "terminate");
15880
16010
  if ((result.errorCode ?? 0) > 0) {
15881
16011
  if (result.errorMessage) {
15882
16012
  this.apiLog(
@@ -15928,6 +16058,9 @@ class BaseAPI {
15928
16058
  } catch (e) {
15929
16059
  returnValue = this.handleValueAccessException(CMIElement, e, returnValue);
15930
16060
  }
16061
+ if (this.lastErrorCode === "0") {
16062
+ this.checkUninitializedGet(CMIElement, returnValue);
16063
+ }
15931
16064
  this.processListeners(callbackName, CMIElement);
15932
16065
  }
15933
16066
  this.apiLog(callbackName, ": returned: " + returnValue, LogLevelEnum.INFO, CMIElement);
@@ -15937,6 +16070,10 @@ class BaseAPI {
15937
16070
  if (this.lastErrorCode === "0") {
15938
16071
  this.clearSCORMError(returnValue);
15939
16072
  }
16073
+ const rawReturn = returnValue;
16074
+ if (typeof rawReturn === "number" || typeof rawReturn === "boolean") {
16075
+ return String(rawReturn);
16076
+ }
15940
16077
  return returnValue;
15941
16078
  }
15942
16079
  /**
@@ -15989,9 +16126,10 @@ class BaseAPI {
15989
16126
  * Orders LMS to store all content parameters
15990
16127
  * @param {string} callbackName
15991
16128
  * @param {boolean} checkTerminated
16129
+ * @param {CommitTrigger} trigger - What initiated the commit
15992
16130
  * @return {string}
15993
16131
  */
15994
- commit(callbackName, checkTerminated = false) {
16132
+ commit(callbackName, checkTerminated = false, trigger = "manual") {
15995
16133
  this.clearScheduledCommit();
15996
16134
  let returnValue = global_constants.SCORM_TRUE;
15997
16135
  if (this.isNotInitialized()) {
@@ -16003,7 +16141,7 @@ class BaseAPI {
16003
16141
  this.throwSCORMError("api", errorCode);
16004
16142
  if (errorCode === 143) returnValue = global_constants.SCORM_FALSE;
16005
16143
  } else {
16006
- const result = this.storeData(false);
16144
+ const result = this.storeData(false, trigger);
16007
16145
  const errorCode = result.errorCode ?? 0;
16008
16146
  if (errorCode > 0) {
16009
16147
  if (result.errorMessage) {
@@ -16234,7 +16372,16 @@ class BaseAPI {
16234
16372
  * @return {string}
16235
16373
  */
16236
16374
  _commonSetCMIValue(methodName, scorm2004, CMIElement, value) {
16237
- return this._cmiValueAccessService.setCMIValue(methodName, scorm2004, CMIElement, value);
16375
+ const result = this._cmiValueAccessService.setCMIValue(
16376
+ methodName,
16377
+ scorm2004,
16378
+ CMIElement,
16379
+ value
16380
+ );
16381
+ if (result === global_constants.SCORM_TRUE) {
16382
+ this._setCMIElements.add(CMIElement);
16383
+ }
16384
+ return result;
16238
16385
  }
16239
16386
  /**
16240
16387
  * Gets a value from the CMI Object.
@@ -16248,6 +16395,18 @@ class BaseAPI {
16248
16395
  _commonGetCMIValue(methodName, scorm2004, CMIElement) {
16249
16396
  return this._cmiValueAccessService.getCMIValue(methodName, scorm2004, CMIElement);
16250
16397
  }
16398
+ /**
16399
+ * Hook invoked by getValue after a successful resolution. Standards that must
16400
+ * distinguish "implemented but never set, no default value" from a
16401
+ * legitimately empty value override this to raise VALUE_NOT_INITIALIZED.
16402
+ * Default is a no-op, so SCORM 1.2 / AICC keep returning "" with code 0.
16403
+ *
16404
+ * @param {string} _CMIElement - the element that was read
16405
+ * @param {any} _returnValue - the value getCMIValue resolved
16406
+ * @protected
16407
+ */
16408
+ checkUninitializedGet(_CMIElement, _returnValue) {
16409
+ }
16251
16410
  /**
16252
16411
  * Returns true if the API's current state is STATE_INITIALIZED
16253
16412
  *
@@ -16330,9 +16489,14 @@ class BaseAPI {
16330
16489
  * @param {string} functionName - The name of the function/event that occurred
16331
16490
  * @param {string} CMIElement - Optional CMI element involved in the event
16332
16491
  * @param {any} value - Optional value associated with the event
16492
+ * @param {CommitEventContext} context - Optional context for commit lifecycle events
16333
16493
  */
16334
- processListeners(functionName, CMIElement, value) {
16335
- this._eventService.processListeners(functionName, CMIElement, value);
16494
+ processListeners(functionName, CMIElement, value, context) {
16495
+ if (context !== void 0) {
16496
+ this._eventService.processListeners(functionName, CMIElement, value, context);
16497
+ } else {
16498
+ this._eventService.processListeners(functionName, CMIElement, value);
16499
+ }
16336
16500
  }
16337
16501
  /**
16338
16502
  * Throws a SCORM error with the specified error number and optional message.
@@ -16465,36 +16629,106 @@ class BaseAPI {
16465
16629
  * @param {string} url - The URL to send the request to
16466
16630
  * @param {CommitObject | StringKeyMap | Array<any>} params - The parameters to send
16467
16631
  * @param {boolean} immediate - Whether to send the request immediately without waiting
16632
+ * @param {CommitTrigger} [trigger] - What initiated the commit
16468
16633
  * @returns {ResultObject} - The result of the request
16469
16634
  */
16470
- processHttpRequest(url, params, immediate = false) {
16471
- if (this.settings.enableOfflineSupport && this._offlineStorageService && !this._offlineStorageService.isDeviceOnline() && this._courseId) {
16472
- this.apiLog(
16473
- "processHttpRequest",
16474
- "Device is offline, storing data locally",
16475
- LogLevelEnum.INFO
16476
- );
16477
- if (params && typeof params === "object" && "cmi" in params) {
16478
- return this._offlineStorageService.storeOffline(this._courseId, params);
16479
- } else {
16635
+ processHttpRequest(url, params, immediate = false, trigger) {
16636
+ const sequence = ++this._commitSequence;
16637
+ this._pendingCommitCount += 1;
16638
+ let settled = false;
16639
+ let completionDeferred = false;
16640
+ const settle = () => {
16641
+ if (settled) {
16642
+ return;
16643
+ }
16644
+ settled = true;
16645
+ this._pendingCommitCount -= 1;
16646
+ if (this._pendingCommitCount === 0) {
16647
+ this._flushCommitSettleWaiters();
16648
+ }
16649
+ };
16650
+ try {
16651
+ const resolvedTrigger = trigger ?? (immediate ? "terminate" : "manual");
16652
+ let finalParams = params;
16653
+ if (immediate && this.settings.terminateCommitPayloadField) {
16654
+ const field = this.settings.terminateCommitPayloadField;
16655
+ if (Array.isArray(finalParams)) {
16656
+ finalParams = [...finalParams, `${encodeURIComponent(field)}=true`];
16657
+ } else if (finalParams && typeof finalParams === "object") {
16658
+ finalParams = { ...finalParams, [field]: true };
16659
+ }
16660
+ }
16661
+ if (this.settings.includeCommitSequence === true) {
16662
+ if (Array.isArray(finalParams)) {
16663
+ finalParams = [...finalParams, `commitSequence=${sequence}`];
16664
+ } else if (finalParams && typeof finalParams === "object") {
16665
+ finalParams = { ...finalParams, commitSequence: sequence };
16666
+ }
16667
+ }
16668
+ const finalUrl = immediate && this.settings.terminateCommitParam ? appendQueryParam(url, this.settings.terminateCommitParam, "true") : url;
16669
+ const metadata = {
16670
+ isTerminateCommit: immediate,
16671
+ trigger: resolvedTrigger,
16672
+ sequence
16673
+ };
16674
+ const context = {
16675
+ url: finalUrl,
16676
+ trigger: resolvedTrigger,
16677
+ isTerminateCommit: immediate,
16678
+ sequence
16679
+ };
16680
+ if (this.settings.enableOfflineSupport && this._offlineStorageService && !this._offlineStorageService.isDeviceOnline() && this._courseId) {
16480
16681
  this.apiLog(
16481
16682
  "processHttpRequest",
16482
- "Invalid commit data format for offline storage",
16483
- LogLevelEnum.ERROR
16683
+ "Device is offline, storing data locally",
16684
+ LogLevelEnum.INFO
16484
16685
  );
16485
- return {
16486
- result: global_constants.SCORM_FALSE,
16487
- errorCode: this._error_codes.GENERAL ?? 101
16488
- };
16686
+ if (finalParams && typeof finalParams === "object" && "cmi" in finalParams) {
16687
+ return this._offlineStorageService.storeOffline(
16688
+ this._courseId,
16689
+ finalParams,
16690
+ { isTerminateCommit: immediate, sequence }
16691
+ );
16692
+ } else {
16693
+ this.apiLog(
16694
+ "processHttpRequest",
16695
+ "Invalid commit data format for offline storage",
16696
+ LogLevelEnum.ERROR
16697
+ );
16698
+ return {
16699
+ result: global_constants.SCORM_FALSE,
16700
+ errorCode: this._error_codes.GENERAL ?? 101
16701
+ };
16702
+ }
16703
+ }
16704
+ const apiLog = (functionName, message, level, element) => this.apiLog(functionName, message, level, element);
16705
+ const processListeners = (functionName, CMIElement, value) => {
16706
+ if (functionName === "CommitSuccess" || functionName === "CommitError") {
16707
+ if (functionName === "CommitError" && typeof value === "number") {
16708
+ context.errorCode = value;
16709
+ }
16710
+ settle();
16711
+ this.processListeners(functionName, CMIElement, value, context);
16712
+ } else {
16713
+ this.processListeners(functionName, CMIElement, value);
16714
+ }
16715
+ };
16716
+ const result = this._httpService.processHttpRequest(
16717
+ finalUrl,
16718
+ finalParams,
16719
+ immediate,
16720
+ apiLog,
16721
+ processListeners,
16722
+ metadata,
16723
+ settle
16724
+ );
16725
+ completionDeferred = this._httpService.reportsRequestCompletion === true;
16726
+ return result;
16727
+ } finally {
16728
+ if (!completionDeferred) {
16729
+ settle();
16489
16730
  }
16490
16731
  }
16491
- return this._httpService.processHttpRequest(
16492
- url,
16493
- params,
16494
- immediate,
16495
- (functionName, message, level, element) => this.apiLog(functionName, message, level, element),
16496
- (functionName, CMIElement, value) => this.processListeners(functionName, CMIElement, value)
16497
- );
16498
16732
  }
16499
16733
  /**
16500
16734
  * Schedules a commit operation to occur after a specified delay.
@@ -16654,6 +16888,11 @@ class CMIScore extends BaseCMI {
16654
16888
  __invalid_range_code;
16655
16889
  __decimal_regex;
16656
16890
  __error_class;
16891
+ /**
16892
+ * When true, an empty string is a valid value (clears the element). SCORM 1.2
16893
+ * score elements may be blank per the ADL 1.2 CTS; SCORM 2004 leaves this off.
16894
+ */
16895
+ __allow_empty_string;
16657
16896
  _raw = "";
16658
16897
  _min = "";
16659
16898
  _max;
@@ -16688,6 +16927,7 @@ class CMIScore extends BaseCMI {
16688
16927
  this.__invalid_range_code = params.invalidRangeCode || scorm12_errors.VALUE_OUT_OF_RANGE;
16689
16928
  this.__decimal_regex = params.decimalRegex || scorm12_regex.CMIDecimal;
16690
16929
  this.__error_class = params.errorClass;
16930
+ this.__allow_empty_string = params.allowEmptyString ?? false;
16691
16931
  }
16692
16932
  /**
16693
16933
  * Called when the API has been reset
@@ -16734,7 +16974,8 @@ class CMIScore extends BaseCMI {
16734
16974
  this.__score_range,
16735
16975
  this.__invalid_type_code,
16736
16976
  this.__invalid_range_code,
16737
- this.__error_class
16977
+ this.__error_class,
16978
+ this.__allow_empty_string
16738
16979
  )) {
16739
16980
  this._raw = raw;
16740
16981
  }
@@ -16758,7 +16999,8 @@ class CMIScore extends BaseCMI {
16758
16999
  this.__score_range,
16759
17000
  this.__invalid_type_code,
16760
17001
  this.__invalid_range_code,
16761
- this.__error_class
17002
+ this.__error_class,
17003
+ this.__allow_empty_string
16762
17004
  )) {
16763
17005
  this._min = min;
16764
17006
  }
@@ -16782,7 +17024,8 @@ class CMIScore extends BaseCMI {
16782
17024
  this.__score_range,
16783
17025
  this.__invalid_type_code,
16784
17026
  this.__invalid_range_code,
16785
- this.__error_class
17027
+ this.__error_class,
17028
+ this.__allow_empty_string
16786
17029
  )) {
16787
17030
  this._max = max;
16788
17031
  }
@@ -16839,7 +17082,9 @@ class CMICore extends BaseCMI {
16839
17082
  invalidErrorCode: scorm12_errors.INVALID_SET_VALUE,
16840
17083
  invalidTypeCode: scorm12_errors.TYPE_MISMATCH,
16841
17084
  invalidRangeCode: scorm12_errors.VALUE_OUT_OF_RANGE,
16842
- errorClass: Scorm12ValidationError
17085
+ errorClass: Scorm12ValidationError,
17086
+ // SCORM 1.2 score elements may be set blank (clears the value), per ADL 1.2 CTS.
17087
+ allowEmptyString: true
16843
17088
  });
16844
17089
  }
16845
17090
  score;
@@ -17269,7 +17514,9 @@ let CMIObjectivesObject$1 = class CMIObjectivesObject extends BaseCMI {
17269
17514
  invalidErrorCode: scorm12_errors.INVALID_SET_VALUE,
17270
17515
  invalidTypeCode: scorm12_errors.TYPE_MISMATCH,
17271
17516
  invalidRangeCode: scorm12_errors.VALUE_OUT_OF_RANGE,
17272
- errorClass: Scorm12ValidationError
17517
+ errorClass: Scorm12ValidationError,
17518
+ // SCORM 1.2 score elements may be set blank (clears the value), per ADL 1.2 CTS.
17519
+ allowEmptyString: true
17273
17520
  });
17274
17521
  }
17275
17522
  score;
@@ -18739,16 +18986,17 @@ class Scorm12API extends BaseAPI {
18739
18986
  * Attempts to store the data to the LMS
18740
18987
  *
18741
18988
  * @param {boolean} terminateCommit
18989
+ * @param {CommitTrigger} [trigger] - What initiated the commit
18742
18990
  * @return {ResultObject}
18743
18991
  */
18744
- storeData(terminateCommit) {
18992
+ storeData(terminateCommit, trigger) {
18745
18993
  if (terminateCommit) {
18746
18994
  const originalStatus = this.cmi.core.lesson_status;
18747
18995
  if (this.cmi.core.lesson_mode === "browse") {
18748
18996
  const startingStatus = this.startingData?.cmi?.core?.lesson_status || "";
18749
18997
  if (startingStatus === "" && originalStatus === "not attempted") {
18750
18998
  this.cmi.core.lesson_status = "browsed";
18751
- return this.processCommitData(terminateCommit);
18999
+ return this.processCommitData(terminateCommit, trigger);
18752
19000
  }
18753
19001
  }
18754
19002
  if (!this.cmi.core.lesson_status || !this.statusSetByModule && this.cmi.core.lesson_status === "not attempted") {
@@ -18777,12 +19025,17 @@ class Scorm12API extends BaseAPI {
18777
19025
  }
18778
19026
  }
18779
19027
  }
18780
- return this.processCommitData(terminateCommit);
19028
+ return this.processCommitData(terminateCommit, trigger);
18781
19029
  }
18782
- processCommitData(terminateCommit) {
19030
+ processCommitData(terminateCommit, trigger) {
18783
19031
  const commitObject = this.getCommitObject(terminateCommit);
18784
19032
  if (typeof this.settings.lmsCommitUrl === "string") {
18785
- return this.processHttpRequest(this.settings.lmsCommitUrl, commitObject, terminateCommit);
19033
+ return this.processHttpRequest(
19034
+ this.settings.lmsCommitUrl,
19035
+ commitObject,
19036
+ terminateCommit,
19037
+ trigger
19038
+ );
18786
19039
  } else {
18787
19040
  return {
18788
19041
  result: global_constants.SCORM_TRUE,
@@ -24366,6 +24619,35 @@ class Scorm2004DataSerializer {
24366
24619
  }
24367
24620
  }
24368
24621
 
24622
+ const NO_DEFAULT_2004_ELEMENTS = /* @__PURE__ */ new Set([
24623
+ "cmi.suspend_data",
24624
+ "cmi.location",
24625
+ "cmi.scaled_passing_score",
24626
+ "cmi.max_time_allowed",
24627
+ "cmi.completion_threshold",
24628
+ "cmi.progress_measure",
24629
+ "cmi.score.scaled",
24630
+ "cmi.score.raw",
24631
+ "cmi.score.min",
24632
+ "cmi.score.max",
24633
+ "cmi.objectives.N.score.scaled",
24634
+ "cmi.objectives.N.score.raw",
24635
+ "cmi.objectives.N.score.min",
24636
+ "cmi.objectives.N.score.max",
24637
+ "cmi.objectives.N.progress_measure",
24638
+ "cmi.objectives.N.description",
24639
+ "cmi.interactions.N.weighting",
24640
+ "cmi.interactions.N.type",
24641
+ "cmi.interactions.N.timestamp",
24642
+ "cmi.interactions.N.result",
24643
+ "cmi.interactions.N.latency",
24644
+ "cmi.interactions.N.learner_response",
24645
+ "cmi.interactions.N.description",
24646
+ "cmi.comments_from_learner.N.timestamp"
24647
+ ]);
24648
+ function normalizeCMIIndices(CMIElement) {
24649
+ return CMIElement.replace(/\.\d+(?=\.|$)/g, ".N");
24650
+ }
24369
24651
  class Scorm2004API extends BaseAPI {
24370
24652
  _version = "1.0";
24371
24653
  _sequencing;
@@ -24891,6 +25173,39 @@ class Scorm2004API extends BaseAPI {
24891
25173
  getCMIValue(CMIElement) {
24892
25174
  return this._commonGetCMIValue("GetValue", true, CMIElement);
24893
25175
  }
25176
+ /**
25177
+ * Raises 403 (VALUE_NOT_INITIALIZED) when an implemented, no-default element is
25178
+ * read before it has ever been set, per IEEE 1484.11.2 / SCORM 2004 RTE.
25179
+ *
25180
+ * Invoked by BaseAPI.getValue after an otherwise-successful resolution, so it
25181
+ * only sees values the data model already returned cleanly. The decision is
25182
+ * deliberately gated to this public boundary (never the getters) to keep
25183
+ * serialization/commit/rollup — which read the getters directly — unaffected.
25184
+ *
25185
+ * A 403 is raised only when all hold:
25186
+ * - the resolved value is "" — any non-empty value is by definition set,
25187
+ * including values written by internal sequencing/activity-tree paths that
25188
+ * bypass _commonSetCMIValue (those only ever write non-empty values);
25189
+ * - the element was never set — _setCMIElements records every successful
25190
+ * SetValue, loadFromJSON, and global-objective restore (all route through
25191
+ * _commonSetCMIValue), so an explicit SetValue(x, "") still reads back as
25192
+ * "" with code 0; and
25193
+ * - the element has no spec-defined default ({@link NO_DEFAULT_2004_ELEMENTS}).
25194
+ *
25195
+ * @param {string} CMIElement
25196
+ * @param {*} returnValue - the value getCMIValue resolved
25197
+ * @protected
25198
+ */
25199
+ checkUninitializedGet(CMIElement, returnValue) {
25200
+ if (returnValue !== "") return;
25201
+ if (this._setCMIElements.has(CMIElement)) return;
25202
+ if (!NO_DEFAULT_2004_ELEMENTS.has(normalizeCMIIndices(CMIElement))) return;
25203
+ this.throwSCORMError(
25204
+ CMIElement,
25205
+ this._error_codes.VALUE_NOT_INITIALIZED ?? 403,
25206
+ `The data model element passed to GetValue (${CMIElement}) has not been initialized.`
25207
+ );
25208
+ }
24894
25209
  /**
24895
25210
  * Returns the message that corresponds to errorNumber.
24896
25211
  * @param {(string|number)} errorNumber
@@ -24937,9 +25252,10 @@ class Scorm2004API extends BaseAPI {
24937
25252
  /**
24938
25253
  * Attempts to store the data to the LMS
24939
25254
  * @param {boolean} terminateCommit
25255
+ * @param {CommitTrigger} [trigger] - What initiated the commit
24940
25256
  * @return {ResultObject}
24941
25257
  */
24942
- storeData(terminateCommit) {
25258
+ storeData(terminateCommit, trigger) {
24943
25259
  if (terminateCommit) {
24944
25260
  if (this.cmi.mode === "normal") {
24945
25261
  if (this.cmi.credit === "credit") {
@@ -24987,7 +25303,8 @@ class Scorm2004API extends BaseAPI {
24987
25303
  const result = this.processHttpRequest(
24988
25304
  this.settings.lmsCommitUrl,
24989
25305
  commitObject,
24990
- terminateCommit
25306
+ terminateCommit,
25307
+ trigger
24991
25308
  );
24992
25309
  if (navRequest && result.navRequest !== void 0 && result.navRequest !== "" && typeof result.navRequest === "string") {
24993
25310
  const parsed = parseNavigationRequest(result.navRequest);