@lessonkit/xapi 1.4.0 → 1.6.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/dist/index.cjs CHANGED
@@ -21,21 +21,133 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
23
  FetchHttpError: () => FetchHttpError,
24
+ assertSafeLrsUrl: () => assertSafeLrsUrl,
24
25
  createFetchBatchSink: () => createFetchBatchSink,
25
26
  createFetchTransport: () => createFetchTransport,
26
27
  createInMemoryXAPIQueue: () => createInMemoryXAPIQueue,
27
28
  createXAPIClient: () => createXAPIClient,
28
29
  isRetryableFetchError: () => isRetryableFetchError,
29
30
  isRetryableFetchHttpStatus: () => isRetryableFetchHttpStatus,
31
+ loadDeadLetterStatements: () => loadDeadLetterStatements,
32
+ persistDeadLetterStatement: () => persistDeadLetterStatement,
33
+ resetXAPIDeadLetterForTests: () => resetXAPIDeadLetterForTests,
30
34
  telemetryEventToXAPIStatement: () => telemetryEventToXAPIStatement
31
35
  });
32
36
  module.exports = __toCommonJS(index_exports);
33
37
 
34
38
  // src/id.ts
39
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
40
+ function randomIdFallback() {
41
+ const g = globalThis;
42
+ if (g.crypto?.getRandomValues) {
43
+ const bytes = new Uint8Array(16);
44
+ g.crypto.getRandomValues(bytes);
45
+ return formatUuid(bytes);
46
+ }
47
+ throw new Error(
48
+ "[lessonkit] cryptoRandomId requires crypto.randomUUID or crypto.getRandomValues"
49
+ );
50
+ }
51
+ function formatUuid(bytes) {
52
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
53
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
54
+ }
55
+ function hashSeedToUuid(seed) {
56
+ const bytes = new Uint8Array(16);
57
+ let h0 = 2166136261;
58
+ let h1 = 2166136261 ^ 2654435769;
59
+ let h2 = 2166136261 ^ 2246822507;
60
+ let h3 = 2166136261 ^ 3266489909;
61
+ for (let i = 0; i < seed.length; i += 1) {
62
+ const c = seed.charCodeAt(i);
63
+ h0 = Math.imul(h0 ^ c, 16777619);
64
+ h1 = Math.imul(h1 ^ c + 1, 16777619);
65
+ h2 = Math.imul(h2 ^ c + 2, 16777619);
66
+ h3 = Math.imul(h3 ^ c + 3, 16777619);
67
+ }
68
+ bytes[0] = h0 >>> 24 & 255;
69
+ bytes[1] = h0 >>> 16 & 255;
70
+ bytes[2] = h0 >>> 8 & 255;
71
+ bytes[3] = h0 & 255;
72
+ bytes[4] = h1 >>> 24 & 255;
73
+ bytes[5] = h1 >>> 16 & 255;
74
+ bytes[6] = h1 >>> 8 & 15 | 80;
75
+ bytes[7] = h1 & 255;
76
+ bytes[8] = h2 >>> 24 & 63 | 128;
77
+ bytes[9] = h2 >>> 16 & 255;
78
+ bytes[10] = h2 >>> 8 & 255;
79
+ bytes[11] = h2 & 255;
80
+ bytes[12] = h3 >>> 24 & 255;
81
+ bytes[13] = h3 >>> 16 & 255;
82
+ bytes[14] = h3 >>> 8 & 255;
83
+ bytes[15] = h3 & 255;
84
+ return formatUuid(bytes);
85
+ }
35
86
  function cryptoRandomId() {
36
87
  const g = globalThis;
37
88
  if (g.crypto?.randomUUID) return g.crypto.randomUUID();
38
- return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 11)}`;
89
+ return randomIdFallback();
90
+ }
91
+ var LIFECYCLE_EVENTS_FOR_STABLE_ID = /* @__PURE__ */ new Set([
92
+ "course_started",
93
+ "course_completed",
94
+ "lesson_started",
95
+ "lesson_completed"
96
+ ]);
97
+ var ASSESSMENT_COMPLETION_EVENTS_FOR_STABLE_ID = /* @__PURE__ */ new Set([
98
+ "assessment_completed",
99
+ "quiz_completed"
100
+ ]);
101
+ function stableTelemetryEventId(event) {
102
+ const seed = [
103
+ event.name,
104
+ event.courseId,
105
+ event.lessonId ?? "",
106
+ event.sessionId ?? "",
107
+ event.attemptId ?? ""
108
+ ].join("|");
109
+ return hashSeedToUuid(seed);
110
+ }
111
+ function stableAssessmentCompletionEventId(event) {
112
+ const checkId = event.data && typeof event.data === "object" && "checkId" in event.data ? String(event.data.checkId ?? "") : "";
113
+ const seed = [
114
+ event.name,
115
+ event.courseId,
116
+ event.lessonId ?? "",
117
+ event.sessionId ?? "",
118
+ event.attemptId ?? "",
119
+ checkId
120
+ ].join("|");
121
+ return hashSeedToUuid(seed);
122
+ }
123
+ function enrichTelemetryEventForXapi(event) {
124
+ if (event.id?.trim()) return event;
125
+ if (LIFECYCLE_EVENTS_FOR_STABLE_ID.has(event.name)) {
126
+ return { ...event, id: stableTelemetryEventId(event) };
127
+ }
128
+ if (ASSESSMENT_COMPLETION_EVENTS_FOR_STABLE_ID.has(event.name)) {
129
+ return {
130
+ ...event,
131
+ id: stableAssessmentCompletionEventId(
132
+ event
133
+ )
134
+ };
135
+ }
136
+ return event;
137
+ }
138
+ function deriveStatementId(event, objectId, verb) {
139
+ const eventId = event.id?.trim();
140
+ if (eventId && UUID_RE.test(eventId)) return eventId;
141
+ const seed = [
142
+ event.name,
143
+ event.courseId,
144
+ event.lessonId ?? "",
145
+ event.sessionId ?? "",
146
+ objectId,
147
+ verb,
148
+ event.timestamp
149
+ ].join("|");
150
+ return hashSeedToUuid(seed);
39
151
  }
40
152
 
41
153
  // src/queue.ts
@@ -97,16 +209,30 @@ function createInMemoryXAPIQueue(opts) {
97
209
  return {
98
210
  enqueue: (statement) => {
99
211
  const normalized = withStatementId(statement);
100
- if (buffer.some((s) => s.id === normalized.id)) return;
212
+ const existingIdx = buffer.findIndex((s) => s.id === normalized.id);
213
+ if (existingIdx >= 0) {
214
+ buffer[existingIdx] = normalized;
215
+ notifyDepth();
216
+ return;
217
+ }
101
218
  if (buffer.length >= maxSize) {
102
219
  if (headInFlight) {
103
220
  if (buffer.length > 1) {
221
+ const evicted = buffer[1];
104
222
  buffer.splice(1, 1);
223
+ opts?.onCap?.();
224
+ opts?.onOverflow?.(evicted);
225
+ } else {
226
+ opts?.onCap?.();
227
+ opts?.onOverflow?.(normalized);
228
+ return;
105
229
  }
106
230
  } else {
231
+ const evicted = buffer[0];
107
232
  buffer.shift();
233
+ opts?.onCap?.();
234
+ opts?.onOverflow?.(evicted);
108
235
  }
109
- opts?.onCap?.();
110
236
  }
111
237
  buffer.push(normalized);
112
238
  notifyDepth();
@@ -122,7 +248,9 @@ function createInMemoryXAPIQueue(opts) {
122
248
  return flushInFlight;
123
249
  },
124
250
  flushOnExit: (exitTransport) => {
251
+ const skipId = headInFlightId;
125
252
  for (const statement of buffer) {
253
+ if (statement.id === skipId) continue;
126
254
  try {
127
255
  exitTransport(statement);
128
256
  } catch {
@@ -138,6 +266,64 @@ function createInMemoryXAPIQueue(opts) {
138
266
  // src/client.ts
139
267
  var import_core2 = require("@lessonkit/core");
140
268
 
269
+ // src/deadLetter.ts
270
+ var STORAGE_KEY = "lk-xapi-dead-letter";
271
+ var MAX_DEAD_LETTER = 200;
272
+ function readStorage() {
273
+ try {
274
+ const storage = globalThis.sessionStorage;
275
+ return storage ?? null;
276
+ } catch {
277
+ return null;
278
+ }
279
+ }
280
+ function loadDeadLetterStatements() {
281
+ const storage = readStorage();
282
+ if (!storage) return [];
283
+ try {
284
+ const raw = storage.getItem(STORAGE_KEY);
285
+ if (!raw) return [];
286
+ const parsed = JSON.parse(raw);
287
+ if (!Array.isArray(parsed)) return [];
288
+ return parsed.filter(
289
+ (item) => typeof item === "object" && item !== null && typeof item.id === "string"
290
+ );
291
+ } catch {
292
+ return [];
293
+ }
294
+ }
295
+ function persistDeadLetterStatement(statement, opts) {
296
+ const storage = readStorage();
297
+ if (!storage) return;
298
+ try {
299
+ const existing = loadDeadLetterStatements();
300
+ if (existing.some((s) => s.id === statement.id)) return;
301
+ const combined = [...existing, statement];
302
+ if (combined.length > MAX_DEAD_LETTER) {
303
+ opts?.onTruncated?.(combined.length - MAX_DEAD_LETTER);
304
+ }
305
+ const next = combined.slice(-MAX_DEAD_LETTER);
306
+ storage.setItem(STORAGE_KEY, JSON.stringify(next));
307
+ } catch {
308
+ }
309
+ }
310
+ function removeDeadLetterStatement(id) {
311
+ const storage = readStorage();
312
+ if (!storage) return;
313
+ try {
314
+ const next = loadDeadLetterStatements().filter((s) => s.id !== id);
315
+ if (next.length === 0) {
316
+ storage.removeItem(STORAGE_KEY);
317
+ } else {
318
+ storage.setItem(STORAGE_KEY, JSON.stringify(next));
319
+ }
320
+ } catch {
321
+ }
322
+ }
323
+ function clearDeadLetterStorage() {
324
+ readStorage()?.removeItem(STORAGE_KEY);
325
+ }
326
+
141
327
  // src/telemetryMap.ts
142
328
  var import_core = require("@lessonkit/core");
143
329
 
@@ -174,9 +360,9 @@ function buildXapiScoreResult(opts) {
174
360
  }
175
361
  return result;
176
362
  }
177
- function statementFor(objectId, verb, timestamp, extra) {
363
+ function statementFor(event, objectId, verb, timestamp, extra) {
178
364
  return {
179
- id: cryptoRandomId(),
365
+ id: deriveStatementId(event, objectId, verb),
180
366
  timestamp,
181
367
  verb,
182
368
  object: { id: objectId },
@@ -184,8 +370,21 @@ function statementFor(objectId, verb, timestamp, extra) {
184
370
  context: extra?.context
185
371
  };
186
372
  }
187
- function experiencedBlockStatement(courseId, lessonId, blockId, timestamp) {
373
+ function sanitizeTelemetryEmbedSrc(src) {
374
+ try {
375
+ const url = new URL(src);
376
+ url.username = "";
377
+ url.password = "";
378
+ url.search = "";
379
+ url.hash = "";
380
+ return `${url.origin}${url.pathname}`;
381
+ } catch {
382
+ return src;
383
+ }
384
+ }
385
+ function experiencedBlockStatement(event, courseId, lessonId, blockId, timestamp) {
188
386
  return statementFor(
387
+ event,
189
388
  (0, import_core.buildLessonkitUrn)({ courseId, lessonId, blockId }),
190
389
  XAPIVerbs.experienced,
191
390
  timestamp
@@ -196,20 +395,39 @@ var experiencedBlockMapper = (event, ctx) => {
196
395
  const lessonId2 = event.lessonId;
197
396
  const blockId2 = event.data?.blockId;
198
397
  if (!lessonId2 || !blockId2 || typeof blockId2 !== "string") return null;
199
- return experiencedBlockStatement(ctx.courseId, lessonId2, blockId2, ctx.timestamp);
398
+ const kind = event.data?.kind;
399
+ const extensions = {};
400
+ if (kind === "embed_viewed" || kind === "chart_viewed") {
401
+ extensions["https://lessonkit.dev/xapi/interactionKind"] = kind;
402
+ const data = event.data;
403
+ if (kind === "embed_viewed" && data && typeof data.src === "string") {
404
+ extensions["https://lessonkit.dev/xapi/embedSrc"] = sanitizeTelemetryEmbedSrc(data.src);
405
+ }
406
+ if (kind === "chart_viewed" && data && typeof data.chartType === "string") {
407
+ extensions["https://lessonkit.dev/xapi/chartType"] = data.chartType;
408
+ }
409
+ }
410
+ return statementFor(
411
+ event,
412
+ (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: lessonId2, blockId: blockId2 }),
413
+ XAPIVerbs.experienced,
414
+ ctx.timestamp,
415
+ Object.keys(extensions).length > 0 ? { context: { extensions } } : void 0
416
+ );
200
417
  }
201
418
  const lessonId = event.lessonId;
202
419
  const blockId = "data" in event && event.data && "blockId" in event.data ? event.data.blockId : void 0;
203
420
  if (!lessonId || !blockId || typeof blockId !== "string") return null;
204
- return experiencedBlockStatement(ctx.courseId, lessonId, blockId, ctx.timestamp);
421
+ return experiencedBlockStatement(event, ctx.courseId, lessonId, blockId, ctx.timestamp);
205
422
  };
206
423
  var TELEMETRY_XAPI_MAPPERS = {
207
- course_started: (_event, ctx) => statementFor((0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId }), XAPIVerbs.initialized, ctx.timestamp),
208
- course_completed: (_event, ctx) => statementFor((0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId }), XAPIVerbs.completed, ctx.timestamp),
424
+ course_started: (event, ctx) => statementFor(event, (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId }), XAPIVerbs.initialized, ctx.timestamp),
425
+ course_completed: (event, ctx) => statementFor(event, (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId }), XAPIVerbs.completed, ctx.timestamp),
209
426
  lesson_started: (event, ctx) => {
210
427
  const lessonId = event.name === "lesson_started" ? event.lessonId : void 0;
211
428
  if (!lessonId) return null;
212
429
  return statementFor(
430
+ event,
213
431
  (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId }),
214
432
  XAPIVerbs.initialized,
215
433
  ctx.timestamp
@@ -218,6 +436,7 @@ var TELEMETRY_XAPI_MAPPERS = {
218
436
  lesson_completed: (event, ctx) => {
219
437
  if (event.name !== "lesson_completed") return null;
220
438
  const lessonId = event.lessonId;
439
+ if (!lessonId) return null;
221
440
  const data = event.data;
222
441
  const result = {};
223
442
  if (typeof data?.durationMs === "number") {
@@ -227,9 +446,15 @@ var TELEMETRY_XAPI_MAPPERS = {
227
446
  if (typeof data?.success === "boolean") result.success = data.success;
228
447
  const score = buildXapiScoreResult({ score: data?.score, maxScore: data?.maxScore });
229
448
  if (score) result.score = score;
230
- return statementFor((0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId }), XAPIVerbs.completed, ctx.timestamp, {
231
- result: Object.keys(result).length ? result : void 0
232
- });
449
+ return statementFor(
450
+ event,
451
+ (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId }),
452
+ XAPIVerbs.completed,
453
+ ctx.timestamp,
454
+ {
455
+ result: Object.keys(result).length ? result : void 0
456
+ }
457
+ );
233
458
  },
234
459
  lesson_time_on_task: () => null,
235
460
  quiz_answered: (event, ctx) => {
@@ -237,6 +462,7 @@ var TELEMETRY_XAPI_MAPPERS = {
237
462
  const result = {};
238
463
  if (typeof event.data.correct === "boolean") result.success = event.data.correct;
239
464
  return statementFor(
465
+ event,
240
466
  (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
241
467
  XAPIVerbs.answered,
242
468
  ctx.timestamp,
@@ -247,6 +473,7 @@ var TELEMETRY_XAPI_MAPPERS = {
247
473
  if (event.name !== "quiz_completed") return null;
248
474
  const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
249
475
  return statementFor(
476
+ event,
250
477
  (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
251
478
  XAPIVerbs.completed,
252
479
  ctx.timestamp,
@@ -258,6 +485,7 @@ var TELEMETRY_XAPI_MAPPERS = {
258
485
  const result = {};
259
486
  if (typeof event.data.correct === "boolean") result.success = event.data.correct;
260
487
  return statementFor(
488
+ event,
261
489
  (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
262
490
  XAPIVerbs.answered,
263
491
  ctx.timestamp,
@@ -268,6 +496,7 @@ var TELEMETRY_XAPI_MAPPERS = {
268
496
  if (event.name !== "assessment_completed") return null;
269
497
  const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
270
498
  return statementFor(
499
+ event,
271
500
  (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
272
501
  XAPIVerbs.completed,
273
502
  ctx.timestamp,
@@ -289,6 +518,7 @@ var TELEMETRY_XAPI_MAPPERS = {
289
518
  const blockId = event.data.blockId;
290
519
  if (!lessonId || !blockId) return null;
291
520
  return statementFor(
521
+ event,
292
522
  (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId }),
293
523
  XAPIVerbs.completed,
294
524
  ctx.timestamp
@@ -303,20 +533,92 @@ var TELEMETRY_XAPI_MAPPERS = {
303
533
  const blockId = event.data.blockId;
304
534
  if (!lessonId || !blockId) return null;
305
535
  return statementFor(
536
+ event,
537
+ (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId }),
538
+ XAPIVerbs.completed,
539
+ ctx.timestamp
540
+ );
541
+ },
542
+ branch_node_viewed: (event, ctx) => {
543
+ if (event.name !== "branch_node_viewed") return null;
544
+ const lessonId = event.lessonId;
545
+ const blockId = event.data.blockId;
546
+ const nodeId = event.data.nodeId;
547
+ if (!lessonId || !blockId || !nodeId) return null;
548
+ return statementFor(
549
+ event,
550
+ (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId, nodeId }),
551
+ XAPIVerbs.experienced,
552
+ ctx.timestamp
553
+ );
554
+ },
555
+ branch_selected: (event, ctx) => {
556
+ if (event.name !== "branch_selected") return null;
557
+ const lessonId = event.lessonId;
558
+ const blockId = event.data.blockId;
559
+ const toNodeId = event.data.toNodeId;
560
+ if (!lessonId || !blockId || !toNodeId) return null;
561
+ return statementFor(
562
+ event,
563
+ (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId, nodeId: toNodeId }),
564
+ XAPIVerbs.experienced,
565
+ ctx.timestamp
566
+ );
567
+ },
568
+ image_juxtaposition_changed: experiencedBlockMapper,
569
+ timeline_event_viewed: experiencedBlockMapper,
570
+ image_sequence_changed: experiencedBlockMapper,
571
+ audio_recording_started: experiencedBlockMapper,
572
+ audio_recording_completed: (event, ctx) => {
573
+ if (event.name !== "audio_recording_completed") return null;
574
+ const lessonId = event.lessonId;
575
+ const blockId = event.data.blockId;
576
+ if (!blockId) return null;
577
+ return statementFor(
578
+ event,
306
579
  (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId }),
307
580
  XAPIVerbs.completed,
308
581
  ctx.timestamp
309
582
  );
583
+ },
584
+ qr_content_revealed: experiencedBlockMapper,
585
+ advent_door_opened: experiencedBlockMapper,
586
+ map_stage_viewed: (event, ctx) => {
587
+ if (event.name !== "map_stage_viewed") return null;
588
+ const lessonId = event.lessonId;
589
+ const blockId = event.data.blockId;
590
+ const stageId = event.data.stageId;
591
+ if (!lessonId || !blockId || !stageId) return null;
592
+ return statementFor(
593
+ event,
594
+ (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId, nodeId: stageId }),
595
+ XAPIVerbs.experienced,
596
+ ctx.timestamp
597
+ );
598
+ },
599
+ map_exit_selected: (event, ctx) => {
600
+ if (event.name !== "map_exit_selected") return null;
601
+ const lessonId = event.lessonId;
602
+ const blockId = event.data.blockId;
603
+ const toStageId = event.data.toStageId;
604
+ if (!lessonId || !blockId || !toStageId) return null;
605
+ return statementFor(
606
+ event,
607
+ (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId, nodeId: toStageId }),
608
+ XAPIVerbs.experienced,
609
+ ctx.timestamp
610
+ );
310
611
  }
311
612
  };
312
613
  function telemetryEventToXAPIStatement(event) {
313
- const mapper = TELEMETRY_XAPI_MAPPERS[event.name];
614
+ const enriched = enrichTelemetryEventForXapi(event);
615
+ const mapper = TELEMETRY_XAPI_MAPPERS[enriched.name];
314
616
  if (!mapper) {
315
- throw new Error(`Unhandled telemetry event: ${event.name}`);
617
+ throw new Error(`Unhandled telemetry event: ${enriched.name}`);
316
618
  }
317
- return mapper(event, {
318
- courseId: event.courseId,
319
- timestamp: event.timestamp
619
+ return mapper(enriched, {
620
+ courseId: enriched.courseId,
621
+ timestamp: enriched.timestamp
320
622
  });
321
623
  }
322
624
 
@@ -334,26 +636,90 @@ function isDevEnvironment() {
334
636
  const g = globalThis;
335
637
  return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
336
638
  }
639
+ function defaultQueueCapHandler() {
640
+ if (isDevEnvironment()) {
641
+ console.warn("[lessonkit] xAPI queue reached capacity; oldest statement(s) dropped.");
642
+ }
643
+ }
644
+ function defaultHeadSkippedHandler(_statement, err) {
645
+ if (isDevEnvironment()) {
646
+ console.warn(
647
+ "[lessonkit] xAPI queue skipped statement after repeated transport failures:",
648
+ err instanceof Error ? err.message : err
649
+ );
650
+ }
651
+ }
337
652
  function createXAPIClient(opts) {
338
653
  const transport = opts?.transport;
339
654
  const exitTransport = opts?.exitTransport;
340
655
  const courseId = opts?.courseId;
341
656
  const queue = opts?.queue ?? createInMemoryXAPIQueue({
342
657
  maxSize: opts?.maxQueueSize,
658
+ maxHeadFailures: opts?.maxHeadFailures,
343
659
  onDepth: opts?.onQueueDepth,
344
- onCap: opts?.onQueueCap
660
+ onCap: opts?.onQueueCap ?? defaultQueueCapHandler,
661
+ onOverflow: (statement) => {
662
+ persistDeadLetterStatement(statement, {
663
+ onTruncated: opts?.onDeadLetterTruncated
664
+ });
665
+ },
666
+ onHeadSkipped: (statement, err) => {
667
+ persistDeadLetterStatement(statement, {
668
+ onTruncated: opts?.onDeadLetterTruncated
669
+ });
670
+ (opts?.onHeadSkipped ?? defaultHeadSkippedHandler)(statement, err);
671
+ }
345
672
  });
346
673
  let warnedNoTransport = false;
347
674
  let warnedTransportFailure = false;
348
675
  const inflightById = /* @__PURE__ */ new Map();
349
676
  const inflightStatements = /* @__PURE__ */ new Map();
677
+ const pendingReplacement = /* @__PURE__ */ new Map();
678
+ const inflightPayload = /* @__PURE__ */ new Map();
679
+ const replacementWatcher = /* @__PURE__ */ new Set();
350
680
  const exitDeliveredIds = /* @__PURE__ */ new Set();
351
681
  const exitNetworkSentIds = /* @__PURE__ */ new Set();
682
+ const exitHandoffIds = /* @__PURE__ */ new Set();
683
+ let activeFlush = null;
684
+ for (const statement of loadDeadLetterStatements()) {
685
+ queue.enqueue(statement);
686
+ }
687
+ const hadDeadLetters = queue.size() > 0;
352
688
  const deliveryTransport = transport ? async (statement) => {
353
689
  if (exitNetworkSentIds.has(statement.id)) return;
354
690
  await transport(statement);
691
+ removeDeadLetterStatement(statement.id);
355
692
  } : void 0;
356
- const sendOrQueue = (statement) => {
693
+ const markExitDelivered = (statement) => {
694
+ exitHandoffIds.delete(statement.id);
695
+ exitDeliveredIds.add(statement.id);
696
+ exitNetworkSentIds.add(statement.id);
697
+ removeDeadLetterStatement(statement.id);
698
+ };
699
+ const dispatchExitStatement = (statement) => {
700
+ if (exitDeliveredIds.has(statement.id)) return;
701
+ exitHandoffIds.add(statement.id);
702
+ try {
703
+ const result = exitTransport(statement);
704
+ if (result != null && typeof result.then === "function") {
705
+ void result.then(
706
+ () => markExitDelivered(statement),
707
+ () => {
708
+ exitHandoffIds.delete(statement.id);
709
+ persistDeadLetterStatement(statement);
710
+ }
711
+ );
712
+ } else {
713
+ markExitDelivered(statement);
714
+ }
715
+ } catch {
716
+ exitHandoffIds.delete(statement.id);
717
+ persistDeadLetterStatement(statement);
718
+ }
719
+ };
720
+ const pendingDuringFlush = [];
721
+ let flushInProgress = false;
722
+ const sendOrQueueInternal = (statement) => {
357
723
  const normalized = withStatementId2(statement);
358
724
  if (exitDeliveredIds.has(normalized.id)) return;
359
725
  if (!deliveryTransport) {
@@ -368,20 +734,39 @@ function createXAPIClient(opts) {
368
734
  }
369
735
  const existing = inflightById.get(normalized.id);
370
736
  if (existing) {
371
- void existing.then(
372
- () => void 0,
373
- () => {
374
- sendOrQueue(normalized);
375
- }
376
- );
737
+ pendingReplacement.set(normalized.id, normalized);
738
+ inflightStatements.set(normalized.id, normalized);
739
+ if (!replacementWatcher.has(normalized.id)) {
740
+ replacementWatcher.add(normalized.id);
741
+ void existing.then(
742
+ () => {
743
+ replacementWatcher.delete(normalized.id);
744
+ const replacement = pendingReplacement.get(normalized.id);
745
+ const transported = inflightPayload.get(normalized.id);
746
+ pendingReplacement.delete(normalized.id);
747
+ inflightPayload.delete(normalized.id);
748
+ if (replacement && replacement !== transported) {
749
+ sendOrQueueInternal(replacement);
750
+ }
751
+ },
752
+ () => {
753
+ replacementWatcher.delete(normalized.id);
754
+ const replacement = pendingReplacement.get(normalized.id) ?? normalized;
755
+ pendingReplacement.delete(normalized.id);
756
+ sendOrQueueInternal(replacement);
757
+ }
758
+ );
759
+ }
377
760
  return;
378
761
  }
762
+ queue.removeById(normalized.id);
379
763
  inflightStatements.set(normalized.id, normalized);
764
+ inflightPayload.set(normalized.id, normalized);
380
765
  const flight = Promise.resolve().then(async () => {
381
766
  await deliveryTransport(normalized);
382
767
  queue.removeById(normalized.id);
383
768
  }).catch((err) => {
384
- if (exitDeliveredIds.has(normalized.id)) return;
769
+ if (exitDeliveredIds.has(normalized.id) || exitHandoffIds.has(normalized.id)) return;
385
770
  queue.enqueue(normalized);
386
771
  opts?.onTransportError?.(err);
387
772
  if (isDevEnvironment() && !warnedTransportFailure) {
@@ -394,17 +779,28 @@ function createXAPIClient(opts) {
394
779
  }).finally(() => {
395
780
  inflightById.delete(normalized.id);
396
781
  inflightStatements.delete(normalized.id);
782
+ if (!replacementWatcher.has(normalized.id)) {
783
+ inflightPayload.delete(normalized.id);
784
+ }
397
785
  });
398
786
  inflightById.set(normalized.id, flight);
399
787
  void flight.catch(() => {
400
788
  });
401
789
  };
790
+ const sendOrQueue = (statement) => {
791
+ if (flushInProgress) {
792
+ pendingDuringFlush.push(statement);
793
+ return;
794
+ }
795
+ sendOrQueueInternal(statement);
796
+ };
402
797
  const emit = (event) => {
403
798
  try {
404
799
  const statement = telemetryEventToXAPIStatement(event);
405
800
  if (!statement) return;
406
801
  sendOrQueue(statement);
407
802
  } catch (err) {
803
+ opts?.onMappingError?.(err);
408
804
  if (isDevEnvironment()) {
409
805
  console.warn(
410
806
  "[lessonkit] xAPI mapping skipped:",
@@ -413,42 +809,66 @@ function createXAPIClient(opts) {
413
809
  }
414
810
  }
415
811
  };
416
- return {
812
+ const runFlushLoop = async () => {
813
+ if (!deliveryTransport) return;
814
+ for (; ; ) {
815
+ await queue.flush(deliveryTransport);
816
+ const flights = [...inflightById.values()];
817
+ if (flights.length > 0) {
818
+ await Promise.all(flights);
819
+ }
820
+ if (queue.size() === 0 && inflightById.size === 0) break;
821
+ }
822
+ };
823
+ const client = {
417
824
  send: (statement) => {
418
825
  sendOrQueue(statement);
419
826
  },
420
827
  queueSize: () => queue.size(),
421
828
  flush: async () => {
422
829
  if (!deliveryTransport) return;
423
- await queue.flush(deliveryTransport);
424
- const flights = [...inflightById.values()];
425
- if (flights.length > 0) {
426
- await Promise.all(flights);
427
- }
428
- if (queue.size() > 0) {
429
- throw new Error("xAPI flush incomplete: statements remain queued after flush");
830
+ for (; ; ) {
831
+ if (activeFlush) {
832
+ await activeFlush;
833
+ } else {
834
+ flushInProgress = true;
835
+ activeFlush = (async () => {
836
+ try {
837
+ await runFlushLoop();
838
+ while (pendingDuringFlush.length > 0) {
839
+ const batch = pendingDuringFlush.splice(0, pendingDuringFlush.length);
840
+ for (const pending of batch) {
841
+ sendOrQueueInternal(pending);
842
+ }
843
+ await runFlushLoop();
844
+ }
845
+ } finally {
846
+ flushInProgress = false;
847
+ }
848
+ })().finally(() => {
849
+ activeFlush = null;
850
+ });
851
+ await activeFlush;
852
+ }
853
+ if (queue.size() === 0 && inflightById.size === 0) break;
430
854
  }
431
855
  },
432
856
  flushOnExit: exitTransport ? () => {
433
857
  const headId = queue.getHeadInFlightId?.();
434
858
  if (headId) {
435
- exitNetworkSentIds.add(headId);
436
- exitDeliveredIds.add(headId);
437
859
  opts.abortInFlight?.(headId);
860
+ const headStatement = inflightStatements.get(headId);
861
+ if (headStatement) {
862
+ dispatchExitStatement(headStatement);
863
+ }
438
864
  }
439
865
  for (const statement of inflightStatements.values()) {
440
- exitNetworkSentIds.add(statement.id);
441
- exitDeliveredIds.add(statement.id);
866
+ if (statement.id === headId) continue;
442
867
  opts.abortInFlight?.(statement.id);
868
+ dispatchExitStatement(statement);
443
869
  }
444
870
  queue.flushOnExit((statement) => {
445
- if (exitDeliveredIds.has(statement.id)) return;
446
- exitNetworkSentIds.add(statement.id);
447
- exitDeliveredIds.add(statement.id);
448
- try {
449
- exitTransport(statement);
450
- } catch {
451
- }
871
+ dispatchExitStatement(statement);
452
872
  });
453
873
  } : void 0,
454
874
  startedLesson: ({ lessonId }) => {
@@ -486,6 +906,98 @@ function createXAPIClient(opts) {
486
906
  });
487
907
  }
488
908
  };
909
+ if (hadDeadLetters && deliveryTransport) {
910
+ queueMicrotask(() => {
911
+ void client.flush().catch(() => void 0);
912
+ });
913
+ }
914
+ return client;
915
+ }
916
+ function resetXAPIDeadLetterForTests() {
917
+ clearDeadLetterStorage();
918
+ }
919
+
920
+ // src/safeLrsUrl.ts
921
+ var import_meta = {};
922
+ function isProductionRuntime() {
923
+ try {
924
+ if (import_meta.env?.PROD === true) return true;
925
+ } catch {
926
+ }
927
+ const g = globalThis;
928
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production";
929
+ }
930
+ function parseHostname(url) {
931
+ return url.hostname.replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
932
+ }
933
+ function isIpv4MappedAddress(hostname) {
934
+ const match = hostname.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
935
+ return match?.[1] ?? null;
936
+ }
937
+ function isLoopbackHost(hostname) {
938
+ const ipv4Mapped = isIpv4MappedAddress(hostname);
939
+ if (ipv4Mapped) return isLoopbackHost(ipv4Mapped);
940
+ return hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "127.0.0.1" || hostname === "::1" || hostname === "0.0.0.0";
941
+ }
942
+ function isLinkLocalOrMetadataHost(hostname) {
943
+ if (hostname === "169.254.169.254") return true;
944
+ if (/^169\.254\./.test(hostname)) return true;
945
+ if (/^fe80:/i.test(hostname)) return true;
946
+ return false;
947
+ }
948
+ function isRfc1918Host(hostname) {
949
+ const ipv4Mapped = isIpv4MappedAddress(hostname);
950
+ if (ipv4Mapped) return isRfc1918Host(ipv4Mapped);
951
+ if (/^10\./.test(hostname)) return true;
952
+ if (/^192\.168\./.test(hostname)) return true;
953
+ const parts = hostname.split(".").map(Number);
954
+ if (parts.length === 4 && parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
955
+ return false;
956
+ }
957
+ function isPrivateOrMetadataHost(hostname) {
958
+ return isLoopbackHost(hostname) || isLinkLocalOrMetadataHost(hostname) || isRfc1918Host(hostname);
959
+ }
960
+ function containsPathTraversal(path) {
961
+ if (path.includes("..")) return true;
962
+ let decoded = path;
963
+ for (let i = 0; i < 2; i++) {
964
+ try {
965
+ const next = decodeURIComponent(decoded.replace(/\+/g, " "));
966
+ if (next.includes("..")) return true;
967
+ if (next === decoded) break;
968
+ decoded = next;
969
+ } catch {
970
+ break;
971
+ }
972
+ }
973
+ return false;
974
+ }
975
+ function assertSafeLrsUrl(url, opts) {
976
+ if (url.startsWith("/")) {
977
+ if (containsPathTraversal(url)) {
978
+ throw new Error(`Unsafe LRS URL: path traversal in "${url}"`);
979
+ }
980
+ return;
981
+ }
982
+ let parsed;
983
+ try {
984
+ parsed = new URL(url);
985
+ } catch {
986
+ throw new Error(`Unsafe LRS URL: invalid URL "${url}"`);
987
+ }
988
+ if (containsPathTraversal(parsed.pathname)) {
989
+ throw new Error(`Unsafe LRS URL: path traversal in "${url}"`);
990
+ }
991
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
992
+ throw new Error(`Unsafe LRS URL: unsupported scheme "${parsed.protocol}"`);
993
+ }
994
+ if (isProductionRuntime() && parsed.protocol !== "https:") {
995
+ throw new Error("Unsafe LRS URL: HTTPS is required in production builds");
996
+ }
997
+ const hostname = parseHostname(parsed);
998
+ if (!opts?.allowPrivateHosts && isPrivateOrMetadataHost(hostname)) {
999
+ throw new Error(`Unsafe LRS URL: private or metadata host "${hostname}" is not allowed`);
1000
+ }
489
1001
  }
490
1002
 
491
1003
  // src/fetchTransport.ts
@@ -555,6 +1067,7 @@ async function postWithRetry(post, retries, initialBackoffMs, maxBackoffMs) {
555
1067
  }
556
1068
  }
557
1069
  function createFetchTransport(opts) {
1070
+ assertSafeLrsUrl(opts.url, { allowPrivateHosts: opts.allowPrivateHosts });
558
1071
  const timeoutMs = opts.timeoutMs ?? 3e4;
559
1072
  const rawRetries = opts.retries ?? 2;
560
1073
  const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
@@ -586,14 +1099,13 @@ function createFetchTransport(opts) {
586
1099
  }
587
1100
  };
588
1101
  const exitTransport = (statement) => {
589
- try {
590
- void postStatement(opts.url, statement, {
591
- ...opts.init,
592
- headers: resolveHeaders(opts.headers),
593
- keepalive: true
594
- }).catch(() => void 0);
595
- } catch {
596
- }
1102
+ return postStatement(opts.url, statement, {
1103
+ ...opts.init,
1104
+ headers: resolveHeaders(opts.headers),
1105
+ keepalive: true
1106
+ }).catch(() => {
1107
+ throw new Error("xAPI keepalive delivery failed");
1108
+ });
597
1109
  };
598
1110
  const abortInFlight = (statementId) => {
599
1111
  activeControllers.get(statementId)?.abort();
@@ -602,6 +1114,7 @@ function createFetchTransport(opts) {
602
1114
  return { transport, exitTransport, abortInFlight };
603
1115
  }
604
1116
  function createFetchBatchSink(opts) {
1117
+ assertSafeLrsUrl(opts.url, { allowPrivateHosts: opts.allowPrivateHosts });
605
1118
  const timeoutMs = opts.timeoutMs ?? 3e4;
606
1119
  const rawRetries = opts.retries ?? 2;
607
1120
  const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
@@ -625,27 +1138,32 @@ function createFetchBatchSink(opts) {
625
1138
  return {
626
1139
  batchSink: (events) => postBatch(events, opts.init ?? {}),
627
1140
  exitBatchSink: (events) => {
628
- try {
629
- void fetch(opts.url, {
630
- method: "POST",
631
- body: JSON.stringify(events),
632
- ...opts.init,
633
- headers: resolveHeaders(opts.headers),
634
- keepalive: true
635
- }).catch(() => void 0);
636
- } catch {
637
- }
1141
+ return fetch(opts.url, {
1142
+ method: "POST",
1143
+ body: JSON.stringify(events),
1144
+ ...opts.init,
1145
+ headers: resolveHeaders(opts.headers),
1146
+ keepalive: true
1147
+ }).then((res) => {
1148
+ if (!res.ok) {
1149
+ throw new FetchHttpError(res.status, res.statusText, "batch");
1150
+ }
1151
+ });
638
1152
  }
639
1153
  };
640
1154
  }
641
1155
  // Annotate the CommonJS export names for ESM import in node:
642
1156
  0 && (module.exports = {
643
1157
  FetchHttpError,
1158
+ assertSafeLrsUrl,
644
1159
  createFetchBatchSink,
645
1160
  createFetchTransport,
646
1161
  createInMemoryXAPIQueue,
647
1162
  createXAPIClient,
648
1163
  isRetryableFetchError,
649
1164
  isRetryableFetchHttpStatus,
1165
+ loadDeadLetterStatements,
1166
+ persistDeadLetterStatement,
1167
+ resetXAPIDeadLetterForTests,
650
1168
  telemetryEventToXAPIStatement
651
1169
  });