@lessonkit/xapi 1.5.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
@@ -218,16 +218,20 @@ function createInMemoryXAPIQueue(opts) {
218
218
  if (buffer.length >= maxSize) {
219
219
  if (headInFlight) {
220
220
  if (buffer.length > 1) {
221
+ const evicted = buffer[1];
221
222
  buffer.splice(1, 1);
222
223
  opts?.onCap?.();
224
+ opts?.onOverflow?.(evicted);
223
225
  } else {
224
226
  opts?.onCap?.();
225
227
  opts?.onOverflow?.(normalized);
226
228
  return;
227
229
  }
228
230
  } else {
231
+ const evicted = buffer[0];
229
232
  buffer.shift();
230
233
  opts?.onCap?.();
234
+ opts?.onOverflow?.(evicted);
231
235
  }
232
236
  }
233
237
  buffer.push(normalized);
@@ -244,7 +248,9 @@ function createInMemoryXAPIQueue(opts) {
244
248
  return flushInFlight;
245
249
  },
246
250
  flushOnExit: (exitTransport) => {
251
+ const skipId = headInFlightId;
247
252
  for (const statement of buffer) {
253
+ if (statement.id === skipId) continue;
248
254
  try {
249
255
  exitTransport(statement);
250
256
  } catch {
@@ -253,7 +259,14 @@ function createInMemoryXAPIQueue(opts) {
253
259
  buffer.length = 0;
254
260
  notifyDepth();
255
261
  },
256
- 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
+ }
257
270
  };
258
271
  }
259
272
 
@@ -263,6 +276,19 @@ var import_core2 = require("@lessonkit/core");
263
276
  // src/deadLetter.ts
264
277
  var STORAGE_KEY = "lk-xapi-dead-letter";
265
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
+ }
266
292
  function readStorage() {
267
293
  try {
268
294
  const storage = globalThis.sessionStorage;
@@ -286,15 +312,23 @@ function loadDeadLetterStatements() {
286
312
  return [];
287
313
  }
288
314
  }
289
- function persistDeadLetterStatement(statement) {
315
+ function persistDeadLetterStatement(statement, opts) {
290
316
  const storage = readStorage();
291
- if (!storage) return;
317
+ if (!storage) {
318
+ reportPersistError(new Error("sessionStorage is unavailable"), statement, opts);
319
+ return;
320
+ }
292
321
  try {
293
322
  const existing = loadDeadLetterStatements();
294
323
  if (existing.some((s) => s.id === statement.id)) return;
295
- const next = [...existing, statement].slice(-MAX_DEAD_LETTER);
324
+ const combined = [...existing, statement];
325
+ if (combined.length > MAX_DEAD_LETTER) {
326
+ opts?.onTruncated?.(combined.length - MAX_DEAD_LETTER);
327
+ }
328
+ const next = combined.slice(-MAX_DEAD_LETTER);
296
329
  storage.setItem(STORAGE_KEY, JSON.stringify(next));
297
- } catch {
330
+ } catch (err) {
331
+ reportPersistError(err, statement, opts);
298
332
  }
299
333
  }
300
334
  function removeDeadLetterStatement(id) {
@@ -426,6 +460,7 @@ var TELEMETRY_XAPI_MAPPERS = {
426
460
  lesson_completed: (event, ctx) => {
427
461
  if (event.name !== "lesson_completed") return null;
428
462
  const lessonId = event.lessonId;
463
+ if (!lessonId) return null;
429
464
  const data = event.data;
430
465
  const result = {};
431
466
  if (typeof data?.durationMs === "number") {
@@ -553,6 +588,50 @@ var TELEMETRY_XAPI_MAPPERS = {
553
588
  XAPIVerbs.experienced,
554
589
  ctx.timestamp
555
590
  );
591
+ },
592
+ image_juxtaposition_changed: experiencedBlockMapper,
593
+ timeline_event_viewed: experiencedBlockMapper,
594
+ image_sequence_changed: experiencedBlockMapper,
595
+ audio_recording_started: experiencedBlockMapper,
596
+ audio_recording_completed: (event, ctx) => {
597
+ if (event.name !== "audio_recording_completed") return null;
598
+ const lessonId = event.lessonId;
599
+ const blockId = event.data.blockId;
600
+ if (!blockId) return null;
601
+ return statementFor(
602
+ event,
603
+ (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId }),
604
+ XAPIVerbs.completed,
605
+ ctx.timestamp
606
+ );
607
+ },
608
+ qr_content_revealed: experiencedBlockMapper,
609
+ advent_door_opened: experiencedBlockMapper,
610
+ map_stage_viewed: (event, ctx) => {
611
+ if (event.name !== "map_stage_viewed") return null;
612
+ const lessonId = event.lessonId;
613
+ const blockId = event.data.blockId;
614
+ const stageId = event.data.stageId;
615
+ if (!lessonId || !blockId || !stageId) return null;
616
+ return statementFor(
617
+ event,
618
+ (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId, nodeId: stageId }),
619
+ XAPIVerbs.experienced,
620
+ ctx.timestamp
621
+ );
622
+ },
623
+ map_exit_selected: (event, ctx) => {
624
+ if (event.name !== "map_exit_selected") return null;
625
+ const lessonId = event.lessonId;
626
+ const blockId = event.data.blockId;
627
+ const toStageId = event.data.toStageId;
628
+ if (!lessonId || !blockId || !toStageId) return null;
629
+ return statementFor(
630
+ event,
631
+ (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId, nodeId: toStageId }),
632
+ XAPIVerbs.experienced,
633
+ ctx.timestamp
634
+ );
556
635
  }
557
636
  };
558
637
  function telemetryEventToXAPIStatement(event) {
@@ -577,17 +656,17 @@ function withStatementId2(statement) {
577
656
  statement.id = cryptoRandomId();
578
657
  return statement;
579
658
  }
580
- function isDevEnvironment() {
659
+ function isDevEnvironment2() {
581
660
  const g = globalThis;
582
661
  return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
583
662
  }
584
663
  function defaultQueueCapHandler() {
585
- if (isDevEnvironment()) {
664
+ if (isDevEnvironment2()) {
586
665
  console.warn("[lessonkit] xAPI queue reached capacity; oldest statement(s) dropped.");
587
666
  }
588
667
  }
589
668
  function defaultHeadSkippedHandler(_statement, err) {
590
- if (isDevEnvironment()) {
669
+ if (isDevEnvironment2()) {
591
670
  console.warn(
592
671
  "[lessonkit] xAPI queue skipped statement after repeated transport failures:",
593
672
  err instanceof Error ? err.message : err
@@ -598,16 +677,22 @@ function createXAPIClient(opts) {
598
677
  const transport = opts?.transport;
599
678
  const exitTransport = opts?.exitTransport;
600
679
  const courseId = opts?.courseId;
680
+ const persistDeadLetter = (statement) => {
681
+ persistDeadLetterStatement(statement, {
682
+ onTruncated: opts?.onDeadLetterTruncated,
683
+ onPersistError: opts?.onDeadLetterPersistError
684
+ });
685
+ };
601
686
  const queue = opts?.queue ?? createInMemoryXAPIQueue({
602
687
  maxSize: opts?.maxQueueSize,
603
688
  maxHeadFailures: opts?.maxHeadFailures,
604
689
  onDepth: opts?.onQueueDepth,
605
690
  onCap: opts?.onQueueCap ?? defaultQueueCapHandler,
606
691
  onOverflow: (statement) => {
607
- persistDeadLetterStatement(statement);
692
+ persistDeadLetter(statement);
608
693
  },
609
694
  onHeadSkipped: (statement, err) => {
610
- persistDeadLetterStatement(statement);
695
+ persistDeadLetter(statement);
611
696
  (opts?.onHeadSkipped ?? defaultHeadSkippedHandler)(statement, err);
612
697
  }
613
698
  });
@@ -647,7 +732,7 @@ function createXAPIClient(opts) {
647
732
  () => markExitDelivered(statement),
648
733
  () => {
649
734
  exitHandoffIds.delete(statement.id);
650
- persistDeadLetterStatement(statement);
735
+ persistDeadLetter(statement);
651
736
  }
652
737
  );
653
738
  } else {
@@ -655,17 +740,35 @@ function createXAPIClient(opts) {
655
740
  }
656
741
  } catch {
657
742
  exitHandoffIds.delete(statement.id);
658
- persistDeadLetterStatement(statement);
743
+ persistDeadLetter(statement);
659
744
  }
660
745
  };
661
746
  const pendingDuringFlush = [];
662
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
+ };
663
766
  const sendOrQueueInternal = (statement) => {
664
767
  const normalized = withStatementId2(statement);
665
768
  if (exitDeliveredIds.has(normalized.id)) return;
666
769
  if (!deliveryTransport) {
667
770
  queue.enqueue(normalized);
668
- if (isDevEnvironment() && !warnedNoTransport) {
771
+ if (isDevEnvironment2() && !warnedNoTransport) {
669
772
  warnedNoTransport = true;
670
773
  console.warn(
671
774
  "[lessonkit] xAPI statements are queued but no transport is configured; pass config.xapi.transport or config.xapi.client"
@@ -700,6 +803,7 @@ function createXAPIClient(opts) {
700
803
  }
701
804
  return;
702
805
  }
806
+ queue.removeById(normalized.id);
703
807
  inflightStatements.set(normalized.id, normalized);
704
808
  inflightPayload.set(normalized.id, normalized);
705
809
  const flight = Promise.resolve().then(async () => {
@@ -709,7 +813,7 @@ function createXAPIClient(opts) {
709
813
  if (exitDeliveredIds.has(normalized.id) || exitHandoffIds.has(normalized.id)) return;
710
814
  queue.enqueue(normalized);
711
815
  opts?.onTransportError?.(err);
712
- if (isDevEnvironment() && !warnedTransportFailure) {
816
+ if (isDevEnvironment2() && !warnedTransportFailure) {
713
817
  warnedTransportFailure = true;
714
818
  console.warn(
715
819
  "[lessonkit] xAPI transport failed; statement re-queued. Check your LRS endpoint or transport implementation."
@@ -741,7 +845,7 @@ function createXAPIClient(opts) {
741
845
  sendOrQueue(statement);
742
846
  } catch (err) {
743
847
  opts?.onMappingError?.(err);
744
- if (isDevEnvironment()) {
848
+ if (isDevEnvironment2()) {
745
849
  console.warn(
746
850
  "[lessonkit] xAPI mapping skipped:",
747
851
  err instanceof Error ? err.message : err
@@ -765,6 +869,12 @@ function createXAPIClient(opts) {
765
869
  sendOrQueue(statement);
766
870
  },
767
871
  queueSize: () => queue.size(),
872
+ abandonUndelivered: () => {
873
+ persistPendingDuringFlush();
874
+ for (const statement of queue.drainAll()) {
875
+ persistDeadLetter(statement);
876
+ }
877
+ },
768
878
  flush: async () => {
769
879
  if (!deliveryTransport) return;
770
880
  for (; ; ) {
@@ -782,6 +892,9 @@ function createXAPIClient(opts) {
782
892
  }
783
893
  await runFlushLoop();
784
894
  }
895
+ } catch (err) {
896
+ requeuePendingDuringFlush();
897
+ throw err;
785
898
  } finally {
786
899
  flushInProgress = false;
787
900
  }
@@ -807,6 +920,7 @@ function createXAPIClient(opts) {
807
920
  opts.abortInFlight?.(statement.id);
808
921
  dispatchExitStatement(statement);
809
922
  }
923
+ dispatchPendingDuringFlushOnExit();
810
924
  queue.flushOnExit((statement) => {
811
925
  dispatchExitStatement(statement);
812
926
  });
@@ -913,6 +1027,9 @@ function containsPathTraversal(path) {
913
1027
  return false;
914
1028
  }
915
1029
  function assertSafeLrsUrl(url, opts) {
1030
+ if (url.startsWith("//")) {
1031
+ throw new Error(`Unsafe LRS URL: protocol-relative URLs are not allowed "${url}"`);
1032
+ }
916
1033
  if (url.startsWith("/")) {
917
1034
  if (containsPathTraversal(url)) {
918
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;
@@ -94,6 +119,12 @@ declare function createXAPIClient(opts?: {
94
119
  maxHeadFailures?: number;
95
120
  onQueueDepth?: (size: number) => void;
96
121
  onQueueCap?: () => void;
122
+ /** Called when dead-letter storage drops older entries beyond the cap (200). */
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;
97
128
  onHeadSkipped?: (statement: XAPIStatement, err: unknown) => void;
98
129
  /** Called when transport fails after retries (statement is re-queued). */
99
130
  onTransportError?: (err: unknown) => void;
@@ -146,6 +177,18 @@ declare function isRetryableFetchError(err: unknown): boolean;
146
177
  /**
147
178
  * Creates an xAPI transport backed by fetch with timeout, retry backoff, and a
148
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`.
149
192
  */
150
193
  declare function createFetchTransport(opts: CreateFetchTransportOptions): FetchTransportBundle;
151
194
  type CreateFetchBatchSinkOptions = CreateFetchTransportOptions;
@@ -156,11 +199,29 @@ type FetchBatchSinkBundle = {
156
199
  };
157
200
  /**
158
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`.
159
214
  */
160
215
  declare function createFetchBatchSink(opts: CreateFetchBatchSinkOptions): FetchBatchSinkBundle;
161
216
 
217
+ type PersistDeadLetterOptions = {
218
+ onTruncated?: (droppedCount: number) => void;
219
+ onPersistError?: (err: unknown, ctx: {
220
+ statement: XAPIStatement;
221
+ }) => void;
222
+ };
162
223
  declare function loadDeadLetterStatements(): XAPIStatement[];
163
- declare function persistDeadLetterStatement(statement: XAPIStatement): void;
224
+ declare function persistDeadLetterStatement(statement: XAPIStatement, opts?: PersistDeadLetterOptions): void;
164
225
 
165
226
  /**
166
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;
@@ -94,6 +119,12 @@ declare function createXAPIClient(opts?: {
94
119
  maxHeadFailures?: number;
95
120
  onQueueDepth?: (size: number) => void;
96
121
  onQueueCap?: () => void;
122
+ /** Called when dead-letter storage drops older entries beyond the cap (200). */
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;
97
128
  onHeadSkipped?: (statement: XAPIStatement, err: unknown) => void;
98
129
  /** Called when transport fails after retries (statement is re-queued). */
99
130
  onTransportError?: (err: unknown) => void;
@@ -146,6 +177,18 @@ declare function isRetryableFetchError(err: unknown): boolean;
146
177
  /**
147
178
  * Creates an xAPI transport backed by fetch with timeout, retry backoff, and a
148
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`.
149
192
  */
150
193
  declare function createFetchTransport(opts: CreateFetchTransportOptions): FetchTransportBundle;
151
194
  type CreateFetchBatchSinkOptions = CreateFetchTransportOptions;
@@ -156,11 +199,29 @@ type FetchBatchSinkBundle = {
156
199
  };
157
200
  /**
158
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`.
159
214
  */
160
215
  declare function createFetchBatchSink(opts: CreateFetchBatchSinkOptions): FetchBatchSinkBundle;
161
216
 
217
+ type PersistDeadLetterOptions = {
218
+ onTruncated?: (droppedCount: number) => void;
219
+ onPersistError?: (err: unknown, ctx: {
220
+ statement: XAPIStatement;
221
+ }) => void;
222
+ };
162
223
  declare function loadDeadLetterStatements(): XAPIStatement[];
163
- declare function persistDeadLetterStatement(statement: XAPIStatement): void;
224
+ declare function persistDeadLetterStatement(statement: XAPIStatement, opts?: PersistDeadLetterOptions): void;
164
225
 
165
226
  /**
166
227
  * Map a LessonKit telemetry event to an xAPI statement, or null if the event should not emit xAPI.
package/dist/index.js CHANGED
@@ -181,16 +181,20 @@ function createInMemoryXAPIQueue(opts) {
181
181
  if (buffer.length >= maxSize) {
182
182
  if (headInFlight) {
183
183
  if (buffer.length > 1) {
184
+ const evicted = buffer[1];
184
185
  buffer.splice(1, 1);
185
186
  opts?.onCap?.();
187
+ opts?.onOverflow?.(evicted);
186
188
  } else {
187
189
  opts?.onCap?.();
188
190
  opts?.onOverflow?.(normalized);
189
191
  return;
190
192
  }
191
193
  } else {
194
+ const evicted = buffer[0];
192
195
  buffer.shift();
193
196
  opts?.onCap?.();
197
+ opts?.onOverflow?.(evicted);
194
198
  }
195
199
  }
196
200
  buffer.push(normalized);
@@ -207,7 +211,9 @@ function createInMemoryXAPIQueue(opts) {
207
211
  return flushInFlight;
208
212
  },
209
213
  flushOnExit: (exitTransport) => {
214
+ const skipId = headInFlightId;
210
215
  for (const statement of buffer) {
216
+ if (statement.id === skipId) continue;
211
217
  try {
212
218
  exitTransport(statement);
213
219
  } catch {
@@ -216,7 +222,14 @@ function createInMemoryXAPIQueue(opts) {
216
222
  buffer.length = 0;
217
223
  notifyDepth();
218
224
  },
219
- 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
+ }
220
233
  };
221
234
  }
222
235
 
@@ -226,6 +239,19 @@ import { nowIso } from "@lessonkit/core";
226
239
  // src/deadLetter.ts
227
240
  var STORAGE_KEY = "lk-xapi-dead-letter";
228
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
+ }
229
255
  function readStorage() {
230
256
  try {
231
257
  const storage = globalThis.sessionStorage;
@@ -249,15 +275,23 @@ function loadDeadLetterStatements() {
249
275
  return [];
250
276
  }
251
277
  }
252
- function persistDeadLetterStatement(statement) {
278
+ function persistDeadLetterStatement(statement, opts) {
253
279
  const storage = readStorage();
254
- if (!storage) return;
280
+ if (!storage) {
281
+ reportPersistError(new Error("sessionStorage is unavailable"), statement, opts);
282
+ return;
283
+ }
255
284
  try {
256
285
  const existing = loadDeadLetterStatements();
257
286
  if (existing.some((s) => s.id === statement.id)) return;
258
- const next = [...existing, statement].slice(-MAX_DEAD_LETTER);
287
+ const combined = [...existing, statement];
288
+ if (combined.length > MAX_DEAD_LETTER) {
289
+ opts?.onTruncated?.(combined.length - MAX_DEAD_LETTER);
290
+ }
291
+ const next = combined.slice(-MAX_DEAD_LETTER);
259
292
  storage.setItem(STORAGE_KEY, JSON.stringify(next));
260
- } catch {
293
+ } catch (err) {
294
+ reportPersistError(err, statement, opts);
261
295
  }
262
296
  }
263
297
  function removeDeadLetterStatement(id) {
@@ -389,6 +423,7 @@ var TELEMETRY_XAPI_MAPPERS = {
389
423
  lesson_completed: (event, ctx) => {
390
424
  if (event.name !== "lesson_completed") return null;
391
425
  const lessonId = event.lessonId;
426
+ if (!lessonId) return null;
392
427
  const data = event.data;
393
428
  const result = {};
394
429
  if (typeof data?.durationMs === "number") {
@@ -516,6 +551,50 @@ var TELEMETRY_XAPI_MAPPERS = {
516
551
  XAPIVerbs.experienced,
517
552
  ctx.timestamp
518
553
  );
554
+ },
555
+ image_juxtaposition_changed: experiencedBlockMapper,
556
+ timeline_event_viewed: experiencedBlockMapper,
557
+ image_sequence_changed: experiencedBlockMapper,
558
+ audio_recording_started: experiencedBlockMapper,
559
+ audio_recording_completed: (event, ctx) => {
560
+ if (event.name !== "audio_recording_completed") return null;
561
+ const lessonId = event.lessonId;
562
+ const blockId = event.data.blockId;
563
+ if (!blockId) return null;
564
+ return statementFor(
565
+ event,
566
+ buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId }),
567
+ XAPIVerbs.completed,
568
+ ctx.timestamp
569
+ );
570
+ },
571
+ qr_content_revealed: experiencedBlockMapper,
572
+ advent_door_opened: experiencedBlockMapper,
573
+ map_stage_viewed: (event, ctx) => {
574
+ if (event.name !== "map_stage_viewed") return null;
575
+ const lessonId = event.lessonId;
576
+ const blockId = event.data.blockId;
577
+ const stageId = event.data.stageId;
578
+ if (!lessonId || !blockId || !stageId) return null;
579
+ return statementFor(
580
+ event,
581
+ buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId, nodeId: stageId }),
582
+ XAPIVerbs.experienced,
583
+ ctx.timestamp
584
+ );
585
+ },
586
+ map_exit_selected: (event, ctx) => {
587
+ if (event.name !== "map_exit_selected") return null;
588
+ const lessonId = event.lessonId;
589
+ const blockId = event.data.blockId;
590
+ const toStageId = event.data.toStageId;
591
+ if (!lessonId || !blockId || !toStageId) return null;
592
+ return statementFor(
593
+ event,
594
+ buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId, nodeId: toStageId }),
595
+ XAPIVerbs.experienced,
596
+ ctx.timestamp
597
+ );
519
598
  }
520
599
  };
521
600
  function telemetryEventToXAPIStatement(event) {
@@ -540,17 +619,17 @@ function withStatementId2(statement) {
540
619
  statement.id = cryptoRandomId();
541
620
  return statement;
542
621
  }
543
- function isDevEnvironment() {
622
+ function isDevEnvironment2() {
544
623
  const g = globalThis;
545
624
  return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
546
625
  }
547
626
  function defaultQueueCapHandler() {
548
- if (isDevEnvironment()) {
627
+ if (isDevEnvironment2()) {
549
628
  console.warn("[lessonkit] xAPI queue reached capacity; oldest statement(s) dropped.");
550
629
  }
551
630
  }
552
631
  function defaultHeadSkippedHandler(_statement, err) {
553
- if (isDevEnvironment()) {
632
+ if (isDevEnvironment2()) {
554
633
  console.warn(
555
634
  "[lessonkit] xAPI queue skipped statement after repeated transport failures:",
556
635
  err instanceof Error ? err.message : err
@@ -561,16 +640,22 @@ function createXAPIClient(opts) {
561
640
  const transport = opts?.transport;
562
641
  const exitTransport = opts?.exitTransport;
563
642
  const courseId = opts?.courseId;
643
+ const persistDeadLetter = (statement) => {
644
+ persistDeadLetterStatement(statement, {
645
+ onTruncated: opts?.onDeadLetterTruncated,
646
+ onPersistError: opts?.onDeadLetterPersistError
647
+ });
648
+ };
564
649
  const queue = opts?.queue ?? createInMemoryXAPIQueue({
565
650
  maxSize: opts?.maxQueueSize,
566
651
  maxHeadFailures: opts?.maxHeadFailures,
567
652
  onDepth: opts?.onQueueDepth,
568
653
  onCap: opts?.onQueueCap ?? defaultQueueCapHandler,
569
654
  onOverflow: (statement) => {
570
- persistDeadLetterStatement(statement);
655
+ persistDeadLetter(statement);
571
656
  },
572
657
  onHeadSkipped: (statement, err) => {
573
- persistDeadLetterStatement(statement);
658
+ persistDeadLetter(statement);
574
659
  (opts?.onHeadSkipped ?? defaultHeadSkippedHandler)(statement, err);
575
660
  }
576
661
  });
@@ -610,7 +695,7 @@ function createXAPIClient(opts) {
610
695
  () => markExitDelivered(statement),
611
696
  () => {
612
697
  exitHandoffIds.delete(statement.id);
613
- persistDeadLetterStatement(statement);
698
+ persistDeadLetter(statement);
614
699
  }
615
700
  );
616
701
  } else {
@@ -618,17 +703,35 @@ function createXAPIClient(opts) {
618
703
  }
619
704
  } catch {
620
705
  exitHandoffIds.delete(statement.id);
621
- persistDeadLetterStatement(statement);
706
+ persistDeadLetter(statement);
622
707
  }
623
708
  };
624
709
  const pendingDuringFlush = [];
625
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
+ };
626
729
  const sendOrQueueInternal = (statement) => {
627
730
  const normalized = withStatementId2(statement);
628
731
  if (exitDeliveredIds.has(normalized.id)) return;
629
732
  if (!deliveryTransport) {
630
733
  queue.enqueue(normalized);
631
- if (isDevEnvironment() && !warnedNoTransport) {
734
+ if (isDevEnvironment2() && !warnedNoTransport) {
632
735
  warnedNoTransport = true;
633
736
  console.warn(
634
737
  "[lessonkit] xAPI statements are queued but no transport is configured; pass config.xapi.transport or config.xapi.client"
@@ -663,6 +766,7 @@ function createXAPIClient(opts) {
663
766
  }
664
767
  return;
665
768
  }
769
+ queue.removeById(normalized.id);
666
770
  inflightStatements.set(normalized.id, normalized);
667
771
  inflightPayload.set(normalized.id, normalized);
668
772
  const flight = Promise.resolve().then(async () => {
@@ -672,7 +776,7 @@ function createXAPIClient(opts) {
672
776
  if (exitDeliveredIds.has(normalized.id) || exitHandoffIds.has(normalized.id)) return;
673
777
  queue.enqueue(normalized);
674
778
  opts?.onTransportError?.(err);
675
- if (isDevEnvironment() && !warnedTransportFailure) {
779
+ if (isDevEnvironment2() && !warnedTransportFailure) {
676
780
  warnedTransportFailure = true;
677
781
  console.warn(
678
782
  "[lessonkit] xAPI transport failed; statement re-queued. Check your LRS endpoint or transport implementation."
@@ -704,7 +808,7 @@ function createXAPIClient(opts) {
704
808
  sendOrQueue(statement);
705
809
  } catch (err) {
706
810
  opts?.onMappingError?.(err);
707
- if (isDevEnvironment()) {
811
+ if (isDevEnvironment2()) {
708
812
  console.warn(
709
813
  "[lessonkit] xAPI mapping skipped:",
710
814
  err instanceof Error ? err.message : err
@@ -728,6 +832,12 @@ function createXAPIClient(opts) {
728
832
  sendOrQueue(statement);
729
833
  },
730
834
  queueSize: () => queue.size(),
835
+ abandonUndelivered: () => {
836
+ persistPendingDuringFlush();
837
+ for (const statement of queue.drainAll()) {
838
+ persistDeadLetter(statement);
839
+ }
840
+ },
731
841
  flush: async () => {
732
842
  if (!deliveryTransport) return;
733
843
  for (; ; ) {
@@ -745,6 +855,9 @@ function createXAPIClient(opts) {
745
855
  }
746
856
  await runFlushLoop();
747
857
  }
858
+ } catch (err) {
859
+ requeuePendingDuringFlush();
860
+ throw err;
748
861
  } finally {
749
862
  flushInProgress = false;
750
863
  }
@@ -770,6 +883,7 @@ function createXAPIClient(opts) {
770
883
  opts.abortInFlight?.(statement.id);
771
884
  dispatchExitStatement(statement);
772
885
  }
886
+ dispatchPendingDuringFlushOnExit();
773
887
  queue.flushOnExit((statement) => {
774
888
  dispatchExitStatement(statement);
775
889
  });
@@ -875,6 +989,9 @@ function containsPathTraversal(path) {
875
989
  return false;
876
990
  }
877
991
  function assertSafeLrsUrl(url, opts) {
992
+ if (url.startsWith("//")) {
993
+ throw new Error(`Unsafe LRS URL: protocol-relative URLs are not allowed "${url}"`);
994
+ }
878
995
  if (url.startsWith("/")) {
879
996
  if (containsPathTraversal(url)) {
880
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.5.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.5.0"
51
+ "@lessonkit/core": "1.7.0"
52
52
  },
53
53
  "devDependencies": {
54
54
  "tsup": "^8.5.0",