@lessonkit/xapi 1.6.0 → 1.7.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.
package/README.md CHANGED
@@ -55,7 +55,7 @@ const { batchSink, exitBatchSink } = createFetchBatchSink({ url: "/api/telemetry
55
55
 
56
56
  ## Docs
57
57
 
58
- [xAPI reference](https://lessonkit.readthedocs.io/en/latest/reference/xapi.html) · [Telemetry & xAPI guide](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/telemetry-and-xapi.html) · [LRS operations](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/lrs-operations.html) · [TypeDoc API index](https://lessonkit.readthedocs.io/en/latest/reference/api.html)
58
+ [5-minute guide](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/getting-started-in-5-minutes.html) · [LMS Go-Live](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/lms-go-live.html) · [Backend proxy cookbook](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/backend-proxy-cookbook.html) · [TypeDoc API index](https://lessonkit.readthedocs.io/en/latest/reference/api.html)
59
59
 
60
60
  ## License
61
61
 
package/dist/index.cjs CHANGED
@@ -259,7 +259,14 @@ function createInMemoryXAPIQueue(opts) {
259
259
  buffer.length = 0;
260
260
  notifyDepth();
261
261
  },
262
- getHeadInFlightId: () => headInFlightId
262
+ getHeadInFlightId: () => headInFlightId,
263
+ drainAll: () => {
264
+ if (!buffer.length) return [];
265
+ const drained = buffer.splice(0, buffer.length);
266
+ headFailureCount = 0;
267
+ notifyDepth();
268
+ return drained;
269
+ }
263
270
  };
264
271
  }
265
272
 
@@ -269,6 +276,19 @@ var import_core2 = require("@lessonkit/core");
269
276
  // src/deadLetter.ts
270
277
  var STORAGE_KEY = "lk-xapi-dead-letter";
271
278
  var MAX_DEAD_LETTER = 200;
279
+ function isDevEnvironment() {
280
+ const g = globalThis;
281
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
282
+ }
283
+ function reportPersistError(err, statement, opts) {
284
+ opts?.onPersistError?.(err, { statement });
285
+ if (!opts?.onPersistError && isDevEnvironment()) {
286
+ console.warn(
287
+ "[lessonkit] xAPI dead-letter persist failed:",
288
+ err instanceof Error ? err.message : err
289
+ );
290
+ }
291
+ }
272
292
  function readStorage() {
273
293
  try {
274
294
  const storage = globalThis.sessionStorage;
@@ -294,7 +314,10 @@ function loadDeadLetterStatements() {
294
314
  }
295
315
  function persistDeadLetterStatement(statement, opts) {
296
316
  const storage = readStorage();
297
- if (!storage) return;
317
+ if (!storage) {
318
+ reportPersistError(new Error("sessionStorage is unavailable"), statement, opts);
319
+ return;
320
+ }
298
321
  try {
299
322
  const existing = loadDeadLetterStatements();
300
323
  if (existing.some((s) => s.id === statement.id)) return;
@@ -304,7 +327,8 @@ function persistDeadLetterStatement(statement, opts) {
304
327
  }
305
328
  const next = combined.slice(-MAX_DEAD_LETTER);
306
329
  storage.setItem(STORAGE_KEY, JSON.stringify(next));
307
- } catch {
330
+ } catch (err) {
331
+ reportPersistError(err, statement, opts);
308
332
  }
309
333
  }
310
334
  function removeDeadLetterStatement(id) {
@@ -632,17 +656,17 @@ function withStatementId2(statement) {
632
656
  statement.id = cryptoRandomId();
633
657
  return statement;
634
658
  }
635
- function isDevEnvironment() {
659
+ function isDevEnvironment2() {
636
660
  const g = globalThis;
637
661
  return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
638
662
  }
639
663
  function defaultQueueCapHandler() {
640
- if (isDevEnvironment()) {
664
+ if (isDevEnvironment2()) {
641
665
  console.warn("[lessonkit] xAPI queue reached capacity; oldest statement(s) dropped.");
642
666
  }
643
667
  }
644
668
  function defaultHeadSkippedHandler(_statement, err) {
645
- if (isDevEnvironment()) {
669
+ if (isDevEnvironment2()) {
646
670
  console.warn(
647
671
  "[lessonkit] xAPI queue skipped statement after repeated transport failures:",
648
672
  err instanceof Error ? err.message : err
@@ -653,20 +677,22 @@ function createXAPIClient(opts) {
653
677
  const transport = opts?.transport;
654
678
  const exitTransport = opts?.exitTransport;
655
679
  const courseId = opts?.courseId;
680
+ const persistDeadLetter = (statement) => {
681
+ persistDeadLetterStatement(statement, {
682
+ onTruncated: opts?.onDeadLetterTruncated,
683
+ onPersistError: opts?.onDeadLetterPersistError
684
+ });
685
+ };
656
686
  const queue = opts?.queue ?? createInMemoryXAPIQueue({
657
687
  maxSize: opts?.maxQueueSize,
658
688
  maxHeadFailures: opts?.maxHeadFailures,
659
689
  onDepth: opts?.onQueueDepth,
660
690
  onCap: opts?.onQueueCap ?? defaultQueueCapHandler,
661
691
  onOverflow: (statement) => {
662
- persistDeadLetterStatement(statement, {
663
- onTruncated: opts?.onDeadLetterTruncated
664
- });
692
+ persistDeadLetter(statement);
665
693
  },
666
694
  onHeadSkipped: (statement, err) => {
667
- persistDeadLetterStatement(statement, {
668
- onTruncated: opts?.onDeadLetterTruncated
669
- });
695
+ persistDeadLetter(statement);
670
696
  (opts?.onHeadSkipped ?? defaultHeadSkippedHandler)(statement, err);
671
697
  }
672
698
  });
@@ -706,7 +732,7 @@ function createXAPIClient(opts) {
706
732
  () => markExitDelivered(statement),
707
733
  () => {
708
734
  exitHandoffIds.delete(statement.id);
709
- persistDeadLetterStatement(statement);
735
+ persistDeadLetter(statement);
710
736
  }
711
737
  );
712
738
  } else {
@@ -714,17 +740,35 @@ function createXAPIClient(opts) {
714
740
  }
715
741
  } catch {
716
742
  exitHandoffIds.delete(statement.id);
717
- persistDeadLetterStatement(statement);
743
+ persistDeadLetter(statement);
718
744
  }
719
745
  };
720
746
  const pendingDuringFlush = [];
721
747
  let flushInProgress = false;
748
+ const requeuePendingDuringFlush = () => {
749
+ const batch = pendingDuringFlush.splice(0, pendingDuringFlush.length);
750
+ for (const statement of batch) {
751
+ queue.enqueue(statement);
752
+ }
753
+ };
754
+ const persistPendingDuringFlush = () => {
755
+ const batch = pendingDuringFlush.splice(0, pendingDuringFlush.length);
756
+ for (const statement of batch) {
757
+ persistDeadLetter(statement);
758
+ }
759
+ };
760
+ const dispatchPendingDuringFlushOnExit = () => {
761
+ const batch = pendingDuringFlush.splice(0, pendingDuringFlush.length);
762
+ for (const statement of batch) {
763
+ dispatchExitStatement(statement);
764
+ }
765
+ };
722
766
  const sendOrQueueInternal = (statement) => {
723
767
  const normalized = withStatementId2(statement);
724
768
  if (exitDeliveredIds.has(normalized.id)) return;
725
769
  if (!deliveryTransport) {
726
770
  queue.enqueue(normalized);
727
- if (isDevEnvironment() && !warnedNoTransport) {
771
+ if (isDevEnvironment2() && !warnedNoTransport) {
728
772
  warnedNoTransport = true;
729
773
  console.warn(
730
774
  "[lessonkit] xAPI statements are queued but no transport is configured; pass config.xapi.transport or config.xapi.client"
@@ -769,7 +813,7 @@ function createXAPIClient(opts) {
769
813
  if (exitDeliveredIds.has(normalized.id) || exitHandoffIds.has(normalized.id)) return;
770
814
  queue.enqueue(normalized);
771
815
  opts?.onTransportError?.(err);
772
- if (isDevEnvironment() && !warnedTransportFailure) {
816
+ if (isDevEnvironment2() && !warnedTransportFailure) {
773
817
  warnedTransportFailure = true;
774
818
  console.warn(
775
819
  "[lessonkit] xAPI transport failed; statement re-queued. Check your LRS endpoint or transport implementation."
@@ -801,7 +845,7 @@ function createXAPIClient(opts) {
801
845
  sendOrQueue(statement);
802
846
  } catch (err) {
803
847
  opts?.onMappingError?.(err);
804
- if (isDevEnvironment()) {
848
+ if (isDevEnvironment2()) {
805
849
  console.warn(
806
850
  "[lessonkit] xAPI mapping skipped:",
807
851
  err instanceof Error ? err.message : err
@@ -825,6 +869,12 @@ function createXAPIClient(opts) {
825
869
  sendOrQueue(statement);
826
870
  },
827
871
  queueSize: () => queue.size(),
872
+ abandonUndelivered: () => {
873
+ persistPendingDuringFlush();
874
+ for (const statement of queue.drainAll()) {
875
+ persistDeadLetter(statement);
876
+ }
877
+ },
828
878
  flush: async () => {
829
879
  if (!deliveryTransport) return;
830
880
  for (; ; ) {
@@ -842,6 +892,9 @@ function createXAPIClient(opts) {
842
892
  }
843
893
  await runFlushLoop();
844
894
  }
895
+ } catch (err) {
896
+ requeuePendingDuringFlush();
897
+ throw err;
845
898
  } finally {
846
899
  flushInProgress = false;
847
900
  }
@@ -867,6 +920,7 @@ function createXAPIClient(opts) {
867
920
  opts.abortInFlight?.(statement.id);
868
921
  dispatchExitStatement(statement);
869
922
  }
923
+ dispatchPendingDuringFlushOnExit();
870
924
  queue.flushOnExit((statement) => {
871
925
  dispatchExitStatement(statement);
872
926
  });
@@ -973,6 +1027,9 @@ function containsPathTraversal(path) {
973
1027
  return false;
974
1028
  }
975
1029
  function assertSafeLrsUrl(url, opts) {
1030
+ if (url.startsWith("//")) {
1031
+ throw new Error(`Unsafe LRS URL: protocol-relative URLs are not allowed "${url}"`);
1032
+ }
976
1033
  if (url.startsWith("/")) {
977
1034
  if (containsPathTraversal(url)) {
978
1035
  throw new Error(`Unsafe LRS URL: path traversal in "${url}"`);
package/dist/index.d.cts CHANGED
@@ -39,6 +39,8 @@ type XAPIQueue = {
39
39
  size: () => number;
40
40
  /** Statement id currently being delivered via flush, if any. */
41
41
  getHeadInFlightId?: () => string | undefined;
42
+ /** Remove and return all queued statements (does not affect in-flight direct transport). */
43
+ drainAll: () => XAPIStatement[];
42
44
  };
43
45
  type XAPIExitTransport = (statement: XAPIStatement) => void | Promise<void>;
44
46
  type XAPIClient = {
@@ -46,6 +48,11 @@ type XAPIClient = {
46
48
  flush: () => Promise<void>;
47
49
  /** Best-effort synchronous flush for pagehide using keepalive transport when configured. */
48
50
  flushOnExit?: () => void;
51
+ /**
52
+ * Persist any queued (undelivered) statements to sessionStorage dead-letter storage.
53
+ * Used when a client is discarded after a failed final flush (e.g. course switch).
54
+ */
55
+ abandonUndelivered?: () => void;
49
56
  queueSize: () => number;
50
57
  startedLesson: (opts: {
51
58
  lessonId: LessonId;
@@ -79,6 +86,24 @@ declare function createInMemoryXAPIQueue(opts?: InMemoryXAPIQueueOptions): XAPIQ
79
86
  /**
80
87
  * Imperative xAPI client with in-memory queue, retry flush, and optional pagehide delivery.
81
88
  * Prefer wiring transport via `LessonkitProvider` config from `@lessonkit/react` in React apps.
89
+ *
90
+ * @example
91
+ * ```ts
92
+ * import { createXAPIClient, createFetchTransport } from "@lessonkit/xapi";
93
+ *
94
+ * const client = createXAPIClient({
95
+ * courseId: "my-course",
96
+ * transport: createFetchTransport({ url: "/api/xapi/statements" }),
97
+ * onTransportError: (err) => console.error("LRS delivery failed", err),
98
+ * });
99
+ *
100
+ * await client.trackTelemetryEvent({
101
+ * name: "quiz_answered",
102
+ * courseId: "my-course",
103
+ * lessonId: "lesson-1",
104
+ * checkId: "q1",
105
+ * });
106
+ * ```
82
107
  */
83
108
  declare function createXAPIClient(opts?: {
84
109
  transport?: XAPITransport;
@@ -96,6 +121,10 @@ declare function createXAPIClient(opts?: {
96
121
  onQueueCap?: () => void;
97
122
  /** Called when dead-letter storage drops older entries beyond the cap (200). */
98
123
  onDeadLetterTruncated?: (droppedCount: number) => void;
124
+ /** Called when a statement cannot be persisted to sessionStorage dead-letter storage. */
125
+ onDeadLetterPersistError?: (err: unknown, ctx: {
126
+ statement: XAPIStatement;
127
+ }) => void;
99
128
  onHeadSkipped?: (statement: XAPIStatement, err: unknown) => void;
100
129
  /** Called when transport fails after retries (statement is re-queued). */
101
130
  onTransportError?: (err: unknown) => void;
@@ -148,6 +177,18 @@ declare function isRetryableFetchError(err: unknown): boolean;
148
177
  /**
149
178
  * Creates an xAPI transport backed by fetch with timeout, retry backoff, and a
150
179
  * keepalive exit transport for pagehide delivery.
180
+ *
181
+ * @example
182
+ * ```ts
183
+ * import { createFetchTransport } from "@lessonkit/xapi";
184
+ *
185
+ * const { transport, exitTransport, abortInFlight } = createFetchTransport({
186
+ * url: import.meta.env.VITE_XAPI_PROXY_URL,
187
+ * headers: () => ({ Authorization: "Bearer …" }),
188
+ * });
189
+ * ```
190
+ *
191
+ * @throws When `url` points at a private/loopback host without `allowPrivateHosts: true`.
151
192
  */
152
193
  declare function createFetchTransport(opts: CreateFetchTransportOptions): FetchTransportBundle;
153
194
  type CreateFetchBatchSinkOptions = CreateFetchTransportOptions;
@@ -158,13 +199,29 @@ type FetchBatchSinkBundle = {
158
199
  };
159
200
  /**
160
201
  * Batch analytics sink with timeout, retry backoff, and keepalive exit delivery.
202
+ * Wire as `config.tracking.batchSink` and `config.tracking.exitBatchSink` in production.
203
+ *
204
+ * @example
205
+ * ```ts
206
+ * import { createFetchBatchSink } from "@lessonkit/xapi";
207
+ *
208
+ * const { batchSink, exitBatchSink } = createFetchBatchSink({
209
+ * url: import.meta.env.VITE_ANALYTICS_URL,
210
+ * });
211
+ * ```
212
+ *
213
+ * @throws When `url` points at a private/loopback host without `allowPrivateHosts: true`.
161
214
  */
162
215
  declare function createFetchBatchSink(opts: CreateFetchBatchSinkOptions): FetchBatchSinkBundle;
163
216
 
164
- declare function loadDeadLetterStatements(): XAPIStatement[];
165
- declare function persistDeadLetterStatement(statement: XAPIStatement, opts?: {
217
+ type PersistDeadLetterOptions = {
166
218
  onTruncated?: (droppedCount: number) => void;
167
- }): void;
219
+ onPersistError?: (err: unknown, ctx: {
220
+ statement: XAPIStatement;
221
+ }) => void;
222
+ };
223
+ declare function loadDeadLetterStatements(): XAPIStatement[];
224
+ declare function persistDeadLetterStatement(statement: XAPIStatement, opts?: PersistDeadLetterOptions): void;
168
225
 
169
226
  /**
170
227
  * Map a LessonKit telemetry event to an xAPI statement, or null if the event should not emit xAPI.
package/dist/index.d.ts CHANGED
@@ -39,6 +39,8 @@ type XAPIQueue = {
39
39
  size: () => number;
40
40
  /** Statement id currently being delivered via flush, if any. */
41
41
  getHeadInFlightId?: () => string | undefined;
42
+ /** Remove and return all queued statements (does not affect in-flight direct transport). */
43
+ drainAll: () => XAPIStatement[];
42
44
  };
43
45
  type XAPIExitTransport = (statement: XAPIStatement) => void | Promise<void>;
44
46
  type XAPIClient = {
@@ -46,6 +48,11 @@ type XAPIClient = {
46
48
  flush: () => Promise<void>;
47
49
  /** Best-effort synchronous flush for pagehide using keepalive transport when configured. */
48
50
  flushOnExit?: () => void;
51
+ /**
52
+ * Persist any queued (undelivered) statements to sessionStorage dead-letter storage.
53
+ * Used when a client is discarded after a failed final flush (e.g. course switch).
54
+ */
55
+ abandonUndelivered?: () => void;
49
56
  queueSize: () => number;
50
57
  startedLesson: (opts: {
51
58
  lessonId: LessonId;
@@ -79,6 +86,24 @@ declare function createInMemoryXAPIQueue(opts?: InMemoryXAPIQueueOptions): XAPIQ
79
86
  /**
80
87
  * Imperative xAPI client with in-memory queue, retry flush, and optional pagehide delivery.
81
88
  * Prefer wiring transport via `LessonkitProvider` config from `@lessonkit/react` in React apps.
89
+ *
90
+ * @example
91
+ * ```ts
92
+ * import { createXAPIClient, createFetchTransport } from "@lessonkit/xapi";
93
+ *
94
+ * const client = createXAPIClient({
95
+ * courseId: "my-course",
96
+ * transport: createFetchTransport({ url: "/api/xapi/statements" }),
97
+ * onTransportError: (err) => console.error("LRS delivery failed", err),
98
+ * });
99
+ *
100
+ * await client.trackTelemetryEvent({
101
+ * name: "quiz_answered",
102
+ * courseId: "my-course",
103
+ * lessonId: "lesson-1",
104
+ * checkId: "q1",
105
+ * });
106
+ * ```
82
107
  */
83
108
  declare function createXAPIClient(opts?: {
84
109
  transport?: XAPITransport;
@@ -96,6 +121,10 @@ declare function createXAPIClient(opts?: {
96
121
  onQueueCap?: () => void;
97
122
  /** Called when dead-letter storage drops older entries beyond the cap (200). */
98
123
  onDeadLetterTruncated?: (droppedCount: number) => void;
124
+ /** Called when a statement cannot be persisted to sessionStorage dead-letter storage. */
125
+ onDeadLetterPersistError?: (err: unknown, ctx: {
126
+ statement: XAPIStatement;
127
+ }) => void;
99
128
  onHeadSkipped?: (statement: XAPIStatement, err: unknown) => void;
100
129
  /** Called when transport fails after retries (statement is re-queued). */
101
130
  onTransportError?: (err: unknown) => void;
@@ -148,6 +177,18 @@ declare function isRetryableFetchError(err: unknown): boolean;
148
177
  /**
149
178
  * Creates an xAPI transport backed by fetch with timeout, retry backoff, and a
150
179
  * keepalive exit transport for pagehide delivery.
180
+ *
181
+ * @example
182
+ * ```ts
183
+ * import { createFetchTransport } from "@lessonkit/xapi";
184
+ *
185
+ * const { transport, exitTransport, abortInFlight } = createFetchTransport({
186
+ * url: import.meta.env.VITE_XAPI_PROXY_URL,
187
+ * headers: () => ({ Authorization: "Bearer …" }),
188
+ * });
189
+ * ```
190
+ *
191
+ * @throws When `url` points at a private/loopback host without `allowPrivateHosts: true`.
151
192
  */
152
193
  declare function createFetchTransport(opts: CreateFetchTransportOptions): FetchTransportBundle;
153
194
  type CreateFetchBatchSinkOptions = CreateFetchTransportOptions;
@@ -158,13 +199,29 @@ type FetchBatchSinkBundle = {
158
199
  };
159
200
  /**
160
201
  * Batch analytics sink with timeout, retry backoff, and keepalive exit delivery.
202
+ * Wire as `config.tracking.batchSink` and `config.tracking.exitBatchSink` in production.
203
+ *
204
+ * @example
205
+ * ```ts
206
+ * import { createFetchBatchSink } from "@lessonkit/xapi";
207
+ *
208
+ * const { batchSink, exitBatchSink } = createFetchBatchSink({
209
+ * url: import.meta.env.VITE_ANALYTICS_URL,
210
+ * });
211
+ * ```
212
+ *
213
+ * @throws When `url` points at a private/loopback host without `allowPrivateHosts: true`.
161
214
  */
162
215
  declare function createFetchBatchSink(opts: CreateFetchBatchSinkOptions): FetchBatchSinkBundle;
163
216
 
164
- declare function loadDeadLetterStatements(): XAPIStatement[];
165
- declare function persistDeadLetterStatement(statement: XAPIStatement, opts?: {
217
+ type PersistDeadLetterOptions = {
166
218
  onTruncated?: (droppedCount: number) => void;
167
- }): void;
219
+ onPersistError?: (err: unknown, ctx: {
220
+ statement: XAPIStatement;
221
+ }) => void;
222
+ };
223
+ declare function loadDeadLetterStatements(): XAPIStatement[];
224
+ declare function persistDeadLetterStatement(statement: XAPIStatement, opts?: PersistDeadLetterOptions): void;
168
225
 
169
226
  /**
170
227
  * Map a LessonKit telemetry event to an xAPI statement, or null if the event should not emit xAPI.
package/dist/index.js CHANGED
@@ -222,7 +222,14 @@ function createInMemoryXAPIQueue(opts) {
222
222
  buffer.length = 0;
223
223
  notifyDepth();
224
224
  },
225
- getHeadInFlightId: () => headInFlightId
225
+ getHeadInFlightId: () => headInFlightId,
226
+ drainAll: () => {
227
+ if (!buffer.length) return [];
228
+ const drained = buffer.splice(0, buffer.length);
229
+ headFailureCount = 0;
230
+ notifyDepth();
231
+ return drained;
232
+ }
226
233
  };
227
234
  }
228
235
 
@@ -232,6 +239,19 @@ import { nowIso } from "@lessonkit/core";
232
239
  // src/deadLetter.ts
233
240
  var STORAGE_KEY = "lk-xapi-dead-letter";
234
241
  var MAX_DEAD_LETTER = 200;
242
+ function isDevEnvironment() {
243
+ const g = globalThis;
244
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
245
+ }
246
+ function reportPersistError(err, statement, opts) {
247
+ opts?.onPersistError?.(err, { statement });
248
+ if (!opts?.onPersistError && isDevEnvironment()) {
249
+ console.warn(
250
+ "[lessonkit] xAPI dead-letter persist failed:",
251
+ err instanceof Error ? err.message : err
252
+ );
253
+ }
254
+ }
235
255
  function readStorage() {
236
256
  try {
237
257
  const storage = globalThis.sessionStorage;
@@ -257,7 +277,10 @@ function loadDeadLetterStatements() {
257
277
  }
258
278
  function persistDeadLetterStatement(statement, opts) {
259
279
  const storage = readStorage();
260
- if (!storage) return;
280
+ if (!storage) {
281
+ reportPersistError(new Error("sessionStorage is unavailable"), statement, opts);
282
+ return;
283
+ }
261
284
  try {
262
285
  const existing = loadDeadLetterStatements();
263
286
  if (existing.some((s) => s.id === statement.id)) return;
@@ -267,7 +290,8 @@ function persistDeadLetterStatement(statement, opts) {
267
290
  }
268
291
  const next = combined.slice(-MAX_DEAD_LETTER);
269
292
  storage.setItem(STORAGE_KEY, JSON.stringify(next));
270
- } catch {
293
+ } catch (err) {
294
+ reportPersistError(err, statement, opts);
271
295
  }
272
296
  }
273
297
  function removeDeadLetterStatement(id) {
@@ -595,17 +619,17 @@ function withStatementId2(statement) {
595
619
  statement.id = cryptoRandomId();
596
620
  return statement;
597
621
  }
598
- function isDevEnvironment() {
622
+ function isDevEnvironment2() {
599
623
  const g = globalThis;
600
624
  return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
601
625
  }
602
626
  function defaultQueueCapHandler() {
603
- if (isDevEnvironment()) {
627
+ if (isDevEnvironment2()) {
604
628
  console.warn("[lessonkit] xAPI queue reached capacity; oldest statement(s) dropped.");
605
629
  }
606
630
  }
607
631
  function defaultHeadSkippedHandler(_statement, err) {
608
- if (isDevEnvironment()) {
632
+ if (isDevEnvironment2()) {
609
633
  console.warn(
610
634
  "[lessonkit] xAPI queue skipped statement after repeated transport failures:",
611
635
  err instanceof Error ? err.message : err
@@ -616,20 +640,22 @@ function createXAPIClient(opts) {
616
640
  const transport = opts?.transport;
617
641
  const exitTransport = opts?.exitTransport;
618
642
  const courseId = opts?.courseId;
643
+ const persistDeadLetter = (statement) => {
644
+ persistDeadLetterStatement(statement, {
645
+ onTruncated: opts?.onDeadLetterTruncated,
646
+ onPersistError: opts?.onDeadLetterPersistError
647
+ });
648
+ };
619
649
  const queue = opts?.queue ?? createInMemoryXAPIQueue({
620
650
  maxSize: opts?.maxQueueSize,
621
651
  maxHeadFailures: opts?.maxHeadFailures,
622
652
  onDepth: opts?.onQueueDepth,
623
653
  onCap: opts?.onQueueCap ?? defaultQueueCapHandler,
624
654
  onOverflow: (statement) => {
625
- persistDeadLetterStatement(statement, {
626
- onTruncated: opts?.onDeadLetterTruncated
627
- });
655
+ persistDeadLetter(statement);
628
656
  },
629
657
  onHeadSkipped: (statement, err) => {
630
- persistDeadLetterStatement(statement, {
631
- onTruncated: opts?.onDeadLetterTruncated
632
- });
658
+ persistDeadLetter(statement);
633
659
  (opts?.onHeadSkipped ?? defaultHeadSkippedHandler)(statement, err);
634
660
  }
635
661
  });
@@ -669,7 +695,7 @@ function createXAPIClient(opts) {
669
695
  () => markExitDelivered(statement),
670
696
  () => {
671
697
  exitHandoffIds.delete(statement.id);
672
- persistDeadLetterStatement(statement);
698
+ persistDeadLetter(statement);
673
699
  }
674
700
  );
675
701
  } else {
@@ -677,17 +703,35 @@ function createXAPIClient(opts) {
677
703
  }
678
704
  } catch {
679
705
  exitHandoffIds.delete(statement.id);
680
- persistDeadLetterStatement(statement);
706
+ persistDeadLetter(statement);
681
707
  }
682
708
  };
683
709
  const pendingDuringFlush = [];
684
710
  let flushInProgress = false;
711
+ const requeuePendingDuringFlush = () => {
712
+ const batch = pendingDuringFlush.splice(0, pendingDuringFlush.length);
713
+ for (const statement of batch) {
714
+ queue.enqueue(statement);
715
+ }
716
+ };
717
+ const persistPendingDuringFlush = () => {
718
+ const batch = pendingDuringFlush.splice(0, pendingDuringFlush.length);
719
+ for (const statement of batch) {
720
+ persistDeadLetter(statement);
721
+ }
722
+ };
723
+ const dispatchPendingDuringFlushOnExit = () => {
724
+ const batch = pendingDuringFlush.splice(0, pendingDuringFlush.length);
725
+ for (const statement of batch) {
726
+ dispatchExitStatement(statement);
727
+ }
728
+ };
685
729
  const sendOrQueueInternal = (statement) => {
686
730
  const normalized = withStatementId2(statement);
687
731
  if (exitDeliveredIds.has(normalized.id)) return;
688
732
  if (!deliveryTransport) {
689
733
  queue.enqueue(normalized);
690
- if (isDevEnvironment() && !warnedNoTransport) {
734
+ if (isDevEnvironment2() && !warnedNoTransport) {
691
735
  warnedNoTransport = true;
692
736
  console.warn(
693
737
  "[lessonkit] xAPI statements are queued but no transport is configured; pass config.xapi.transport or config.xapi.client"
@@ -732,7 +776,7 @@ function createXAPIClient(opts) {
732
776
  if (exitDeliveredIds.has(normalized.id) || exitHandoffIds.has(normalized.id)) return;
733
777
  queue.enqueue(normalized);
734
778
  opts?.onTransportError?.(err);
735
- if (isDevEnvironment() && !warnedTransportFailure) {
779
+ if (isDevEnvironment2() && !warnedTransportFailure) {
736
780
  warnedTransportFailure = true;
737
781
  console.warn(
738
782
  "[lessonkit] xAPI transport failed; statement re-queued. Check your LRS endpoint or transport implementation."
@@ -764,7 +808,7 @@ function createXAPIClient(opts) {
764
808
  sendOrQueue(statement);
765
809
  } catch (err) {
766
810
  opts?.onMappingError?.(err);
767
- if (isDevEnvironment()) {
811
+ if (isDevEnvironment2()) {
768
812
  console.warn(
769
813
  "[lessonkit] xAPI mapping skipped:",
770
814
  err instanceof Error ? err.message : err
@@ -788,6 +832,12 @@ function createXAPIClient(opts) {
788
832
  sendOrQueue(statement);
789
833
  },
790
834
  queueSize: () => queue.size(),
835
+ abandonUndelivered: () => {
836
+ persistPendingDuringFlush();
837
+ for (const statement of queue.drainAll()) {
838
+ persistDeadLetter(statement);
839
+ }
840
+ },
791
841
  flush: async () => {
792
842
  if (!deliveryTransport) return;
793
843
  for (; ; ) {
@@ -805,6 +855,9 @@ function createXAPIClient(opts) {
805
855
  }
806
856
  await runFlushLoop();
807
857
  }
858
+ } catch (err) {
859
+ requeuePendingDuringFlush();
860
+ throw err;
808
861
  } finally {
809
862
  flushInProgress = false;
810
863
  }
@@ -830,6 +883,7 @@ function createXAPIClient(opts) {
830
883
  opts.abortInFlight?.(statement.id);
831
884
  dispatchExitStatement(statement);
832
885
  }
886
+ dispatchPendingDuringFlushOnExit();
833
887
  queue.flushOnExit((statement) => {
834
888
  dispatchExitStatement(statement);
835
889
  });
@@ -935,6 +989,9 @@ function containsPathTraversal(path) {
935
989
  return false;
936
990
  }
937
991
  function assertSafeLrsUrl(url, opts) {
992
+ if (url.startsWith("//")) {
993
+ throw new Error(`Unsafe LRS URL: protocol-relative URLs are not allowed "${url}"`);
994
+ }
938
995
  if (url.startsWith("/")) {
939
996
  if (containsPathTraversal(url)) {
940
997
  throw new Error(`Unsafe LRS URL: path traversal in "${url}"`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessonkit/xapi",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "private": false,
5
5
  "description": "xAPI statement generation primitives for LessonKit.",
6
6
  "license": "Apache-2.0",
@@ -39,8 +39,8 @@
39
39
  "dist"
40
40
  ],
41
41
  "scripts": {
42
- "build": "tsup src/index.ts --format esm,cjs --dts",
43
- "dev": "tsup src/index.ts --format esm,cjs --dts --watch",
42
+ "build": "tsup src/index.ts --format esm,cjs --dts --tsconfig tsconfig.build.json",
43
+ "dev": "tsup src/index.ts --format esm,cjs --dts --watch --tsconfig tsconfig.build.json",
44
44
  "prepublishOnly": "npm run build",
45
45
  "typecheck": "tsc -p tsconfig.json",
46
46
  "test": "vitest run --passWithNoTests",
@@ -48,7 +48,7 @@
48
48
  "lint": "eslint --max-warnings 0 \"src/**/*.{ts,tsx}\" \"test/**/*.{ts,tsx}\""
49
49
  },
50
50
  "dependencies": {
51
- "@lessonkit/core": "1.6.0"
51
+ "@lessonkit/core": "1.7.0"
52
52
  },
53
53
  "devDependencies": {
54
54
  "tsup": "^8.5.0",