@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.js CHANGED
@@ -1,8 +1,116 @@
1
1
  // src/id.ts
2
+ 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;
3
+ function randomIdFallback() {
4
+ const g = globalThis;
5
+ if (g.crypto?.getRandomValues) {
6
+ const bytes = new Uint8Array(16);
7
+ g.crypto.getRandomValues(bytes);
8
+ return formatUuid(bytes);
9
+ }
10
+ throw new Error(
11
+ "[lessonkit] cryptoRandomId requires crypto.randomUUID or crypto.getRandomValues"
12
+ );
13
+ }
14
+ function formatUuid(bytes) {
15
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
16
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
17
+ }
18
+ function hashSeedToUuid(seed) {
19
+ const bytes = new Uint8Array(16);
20
+ let h0 = 2166136261;
21
+ let h1 = 2166136261 ^ 2654435769;
22
+ let h2 = 2166136261 ^ 2246822507;
23
+ let h3 = 2166136261 ^ 3266489909;
24
+ for (let i = 0; i < seed.length; i += 1) {
25
+ const c = seed.charCodeAt(i);
26
+ h0 = Math.imul(h0 ^ c, 16777619);
27
+ h1 = Math.imul(h1 ^ c + 1, 16777619);
28
+ h2 = Math.imul(h2 ^ c + 2, 16777619);
29
+ h3 = Math.imul(h3 ^ c + 3, 16777619);
30
+ }
31
+ bytes[0] = h0 >>> 24 & 255;
32
+ bytes[1] = h0 >>> 16 & 255;
33
+ bytes[2] = h0 >>> 8 & 255;
34
+ bytes[3] = h0 & 255;
35
+ bytes[4] = h1 >>> 24 & 255;
36
+ bytes[5] = h1 >>> 16 & 255;
37
+ bytes[6] = h1 >>> 8 & 15 | 80;
38
+ bytes[7] = h1 & 255;
39
+ bytes[8] = h2 >>> 24 & 63 | 128;
40
+ bytes[9] = h2 >>> 16 & 255;
41
+ bytes[10] = h2 >>> 8 & 255;
42
+ bytes[11] = h2 & 255;
43
+ bytes[12] = h3 >>> 24 & 255;
44
+ bytes[13] = h3 >>> 16 & 255;
45
+ bytes[14] = h3 >>> 8 & 255;
46
+ bytes[15] = h3 & 255;
47
+ return formatUuid(bytes);
48
+ }
2
49
  function cryptoRandomId() {
3
50
  const g = globalThis;
4
51
  if (g.crypto?.randomUUID) return g.crypto.randomUUID();
5
- return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 11)}`;
52
+ return randomIdFallback();
53
+ }
54
+ var LIFECYCLE_EVENTS_FOR_STABLE_ID = /* @__PURE__ */ new Set([
55
+ "course_started",
56
+ "course_completed",
57
+ "lesson_started",
58
+ "lesson_completed"
59
+ ]);
60
+ var ASSESSMENT_COMPLETION_EVENTS_FOR_STABLE_ID = /* @__PURE__ */ new Set([
61
+ "assessment_completed",
62
+ "quiz_completed"
63
+ ]);
64
+ function stableTelemetryEventId(event) {
65
+ const seed = [
66
+ event.name,
67
+ event.courseId,
68
+ event.lessonId ?? "",
69
+ event.sessionId ?? "",
70
+ event.attemptId ?? ""
71
+ ].join("|");
72
+ return hashSeedToUuid(seed);
73
+ }
74
+ function stableAssessmentCompletionEventId(event) {
75
+ const checkId = event.data && typeof event.data === "object" && "checkId" in event.data ? String(event.data.checkId ?? "") : "";
76
+ const seed = [
77
+ event.name,
78
+ event.courseId,
79
+ event.lessonId ?? "",
80
+ event.sessionId ?? "",
81
+ event.attemptId ?? "",
82
+ checkId
83
+ ].join("|");
84
+ return hashSeedToUuid(seed);
85
+ }
86
+ function enrichTelemetryEventForXapi(event) {
87
+ if (event.id?.trim()) return event;
88
+ if (LIFECYCLE_EVENTS_FOR_STABLE_ID.has(event.name)) {
89
+ return { ...event, id: stableTelemetryEventId(event) };
90
+ }
91
+ if (ASSESSMENT_COMPLETION_EVENTS_FOR_STABLE_ID.has(event.name)) {
92
+ return {
93
+ ...event,
94
+ id: stableAssessmentCompletionEventId(
95
+ event
96
+ )
97
+ };
98
+ }
99
+ return event;
100
+ }
101
+ function deriveStatementId(event, objectId, verb) {
102
+ const eventId = event.id?.trim();
103
+ if (eventId && UUID_RE.test(eventId)) return eventId;
104
+ const seed = [
105
+ event.name,
106
+ event.courseId,
107
+ event.lessonId ?? "",
108
+ event.sessionId ?? "",
109
+ objectId,
110
+ verb,
111
+ event.timestamp
112
+ ].join("|");
113
+ return hashSeedToUuid(seed);
6
114
  }
7
115
 
8
116
  // src/queue.ts
@@ -64,16 +172,30 @@ function createInMemoryXAPIQueue(opts) {
64
172
  return {
65
173
  enqueue: (statement) => {
66
174
  const normalized = withStatementId(statement);
67
- if (buffer.some((s) => s.id === normalized.id)) return;
175
+ const existingIdx = buffer.findIndex((s) => s.id === normalized.id);
176
+ if (existingIdx >= 0) {
177
+ buffer[existingIdx] = normalized;
178
+ notifyDepth();
179
+ return;
180
+ }
68
181
  if (buffer.length >= maxSize) {
69
182
  if (headInFlight) {
70
183
  if (buffer.length > 1) {
184
+ const evicted = buffer[1];
71
185
  buffer.splice(1, 1);
186
+ opts?.onCap?.();
187
+ opts?.onOverflow?.(evicted);
188
+ } else {
189
+ opts?.onCap?.();
190
+ opts?.onOverflow?.(normalized);
191
+ return;
72
192
  }
73
193
  } else {
194
+ const evicted = buffer[0];
74
195
  buffer.shift();
196
+ opts?.onCap?.();
197
+ opts?.onOverflow?.(evicted);
75
198
  }
76
- opts?.onCap?.();
77
199
  }
78
200
  buffer.push(normalized);
79
201
  notifyDepth();
@@ -89,7 +211,9 @@ function createInMemoryXAPIQueue(opts) {
89
211
  return flushInFlight;
90
212
  },
91
213
  flushOnExit: (exitTransport) => {
214
+ const skipId = headInFlightId;
92
215
  for (const statement of buffer) {
216
+ if (statement.id === skipId) continue;
93
217
  try {
94
218
  exitTransport(statement);
95
219
  } catch {
@@ -105,6 +229,64 @@ function createInMemoryXAPIQueue(opts) {
105
229
  // src/client.ts
106
230
  import { nowIso } from "@lessonkit/core";
107
231
 
232
+ // src/deadLetter.ts
233
+ var STORAGE_KEY = "lk-xapi-dead-letter";
234
+ var MAX_DEAD_LETTER = 200;
235
+ function readStorage() {
236
+ try {
237
+ const storage = globalThis.sessionStorage;
238
+ return storage ?? null;
239
+ } catch {
240
+ return null;
241
+ }
242
+ }
243
+ function loadDeadLetterStatements() {
244
+ const storage = readStorage();
245
+ if (!storage) return [];
246
+ try {
247
+ const raw = storage.getItem(STORAGE_KEY);
248
+ if (!raw) return [];
249
+ const parsed = JSON.parse(raw);
250
+ if (!Array.isArray(parsed)) return [];
251
+ return parsed.filter(
252
+ (item) => typeof item === "object" && item !== null && typeof item.id === "string"
253
+ );
254
+ } catch {
255
+ return [];
256
+ }
257
+ }
258
+ function persistDeadLetterStatement(statement, opts) {
259
+ const storage = readStorage();
260
+ if (!storage) return;
261
+ try {
262
+ const existing = loadDeadLetterStatements();
263
+ if (existing.some((s) => s.id === statement.id)) return;
264
+ const combined = [...existing, statement];
265
+ if (combined.length > MAX_DEAD_LETTER) {
266
+ opts?.onTruncated?.(combined.length - MAX_DEAD_LETTER);
267
+ }
268
+ const next = combined.slice(-MAX_DEAD_LETTER);
269
+ storage.setItem(STORAGE_KEY, JSON.stringify(next));
270
+ } catch {
271
+ }
272
+ }
273
+ function removeDeadLetterStatement(id) {
274
+ const storage = readStorage();
275
+ if (!storage) return;
276
+ try {
277
+ const next = loadDeadLetterStatements().filter((s) => s.id !== id);
278
+ if (next.length === 0) {
279
+ storage.removeItem(STORAGE_KEY);
280
+ } else {
281
+ storage.setItem(STORAGE_KEY, JSON.stringify(next));
282
+ }
283
+ } catch {
284
+ }
285
+ }
286
+ function clearDeadLetterStorage() {
287
+ readStorage()?.removeItem(STORAGE_KEY);
288
+ }
289
+
108
290
  // src/telemetryMap.ts
109
291
  import { buildLessonkitUrn } from "@lessonkit/core";
110
292
 
@@ -141,9 +323,9 @@ function buildXapiScoreResult(opts) {
141
323
  }
142
324
  return result;
143
325
  }
144
- function statementFor(objectId, verb, timestamp, extra) {
326
+ function statementFor(event, objectId, verb, timestamp, extra) {
145
327
  return {
146
- id: cryptoRandomId(),
328
+ id: deriveStatementId(event, objectId, verb),
147
329
  timestamp,
148
330
  verb,
149
331
  object: { id: objectId },
@@ -151,8 +333,21 @@ function statementFor(objectId, verb, timestamp, extra) {
151
333
  context: extra?.context
152
334
  };
153
335
  }
154
- function experiencedBlockStatement(courseId, lessonId, blockId, timestamp) {
336
+ function sanitizeTelemetryEmbedSrc(src) {
337
+ try {
338
+ const url = new URL(src);
339
+ url.username = "";
340
+ url.password = "";
341
+ url.search = "";
342
+ url.hash = "";
343
+ return `${url.origin}${url.pathname}`;
344
+ } catch {
345
+ return src;
346
+ }
347
+ }
348
+ function experiencedBlockStatement(event, courseId, lessonId, blockId, timestamp) {
155
349
  return statementFor(
350
+ event,
156
351
  buildLessonkitUrn({ courseId, lessonId, blockId }),
157
352
  XAPIVerbs.experienced,
158
353
  timestamp
@@ -163,20 +358,39 @@ var experiencedBlockMapper = (event, ctx) => {
163
358
  const lessonId2 = event.lessonId;
164
359
  const blockId2 = event.data?.blockId;
165
360
  if (!lessonId2 || !blockId2 || typeof blockId2 !== "string") return null;
166
- return experiencedBlockStatement(ctx.courseId, lessonId2, blockId2, ctx.timestamp);
361
+ const kind = event.data?.kind;
362
+ const extensions = {};
363
+ if (kind === "embed_viewed" || kind === "chart_viewed") {
364
+ extensions["https://lessonkit.dev/xapi/interactionKind"] = kind;
365
+ const data = event.data;
366
+ if (kind === "embed_viewed" && data && typeof data.src === "string") {
367
+ extensions["https://lessonkit.dev/xapi/embedSrc"] = sanitizeTelemetryEmbedSrc(data.src);
368
+ }
369
+ if (kind === "chart_viewed" && data && typeof data.chartType === "string") {
370
+ extensions["https://lessonkit.dev/xapi/chartType"] = data.chartType;
371
+ }
372
+ }
373
+ return statementFor(
374
+ event,
375
+ buildLessonkitUrn({ courseId: ctx.courseId, lessonId: lessonId2, blockId: blockId2 }),
376
+ XAPIVerbs.experienced,
377
+ ctx.timestamp,
378
+ Object.keys(extensions).length > 0 ? { context: { extensions } } : void 0
379
+ );
167
380
  }
168
381
  const lessonId = event.lessonId;
169
382
  const blockId = "data" in event && event.data && "blockId" in event.data ? event.data.blockId : void 0;
170
383
  if (!lessonId || !blockId || typeof blockId !== "string") return null;
171
- return experiencedBlockStatement(ctx.courseId, lessonId, blockId, ctx.timestamp);
384
+ return experiencedBlockStatement(event, ctx.courseId, lessonId, blockId, ctx.timestamp);
172
385
  };
173
386
  var TELEMETRY_XAPI_MAPPERS = {
174
- course_started: (_event, ctx) => statementFor(buildLessonkitUrn({ courseId: ctx.courseId }), XAPIVerbs.initialized, ctx.timestamp),
175
- course_completed: (_event, ctx) => statementFor(buildLessonkitUrn({ courseId: ctx.courseId }), XAPIVerbs.completed, ctx.timestamp),
387
+ course_started: (event, ctx) => statementFor(event, buildLessonkitUrn({ courseId: ctx.courseId }), XAPIVerbs.initialized, ctx.timestamp),
388
+ course_completed: (event, ctx) => statementFor(event, buildLessonkitUrn({ courseId: ctx.courseId }), XAPIVerbs.completed, ctx.timestamp),
176
389
  lesson_started: (event, ctx) => {
177
390
  const lessonId = event.name === "lesson_started" ? event.lessonId : void 0;
178
391
  if (!lessonId) return null;
179
392
  return statementFor(
393
+ event,
180
394
  buildLessonkitUrn({ courseId: ctx.courseId, lessonId }),
181
395
  XAPIVerbs.initialized,
182
396
  ctx.timestamp
@@ -185,6 +399,7 @@ var TELEMETRY_XAPI_MAPPERS = {
185
399
  lesson_completed: (event, ctx) => {
186
400
  if (event.name !== "lesson_completed") return null;
187
401
  const lessonId = event.lessonId;
402
+ if (!lessonId) return null;
188
403
  const data = event.data;
189
404
  const result = {};
190
405
  if (typeof data?.durationMs === "number") {
@@ -194,9 +409,15 @@ var TELEMETRY_XAPI_MAPPERS = {
194
409
  if (typeof data?.success === "boolean") result.success = data.success;
195
410
  const score = buildXapiScoreResult({ score: data?.score, maxScore: data?.maxScore });
196
411
  if (score) result.score = score;
197
- return statementFor(buildLessonkitUrn({ courseId: ctx.courseId, lessonId }), XAPIVerbs.completed, ctx.timestamp, {
198
- result: Object.keys(result).length ? result : void 0
199
- });
412
+ return statementFor(
413
+ event,
414
+ buildLessonkitUrn({ courseId: ctx.courseId, lessonId }),
415
+ XAPIVerbs.completed,
416
+ ctx.timestamp,
417
+ {
418
+ result: Object.keys(result).length ? result : void 0
419
+ }
420
+ );
200
421
  },
201
422
  lesson_time_on_task: () => null,
202
423
  quiz_answered: (event, ctx) => {
@@ -204,6 +425,7 @@ var TELEMETRY_XAPI_MAPPERS = {
204
425
  const result = {};
205
426
  if (typeof event.data.correct === "boolean") result.success = event.data.correct;
206
427
  return statementFor(
428
+ event,
207
429
  buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
208
430
  XAPIVerbs.answered,
209
431
  ctx.timestamp,
@@ -214,6 +436,7 @@ var TELEMETRY_XAPI_MAPPERS = {
214
436
  if (event.name !== "quiz_completed") return null;
215
437
  const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
216
438
  return statementFor(
439
+ event,
217
440
  buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
218
441
  XAPIVerbs.completed,
219
442
  ctx.timestamp,
@@ -225,6 +448,7 @@ var TELEMETRY_XAPI_MAPPERS = {
225
448
  const result = {};
226
449
  if (typeof event.data.correct === "boolean") result.success = event.data.correct;
227
450
  return statementFor(
451
+ event,
228
452
  buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
229
453
  XAPIVerbs.answered,
230
454
  ctx.timestamp,
@@ -235,6 +459,7 @@ var TELEMETRY_XAPI_MAPPERS = {
235
459
  if (event.name !== "assessment_completed") return null;
236
460
  const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
237
461
  return statementFor(
462
+ event,
238
463
  buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
239
464
  XAPIVerbs.completed,
240
465
  ctx.timestamp,
@@ -256,6 +481,7 @@ var TELEMETRY_XAPI_MAPPERS = {
256
481
  const blockId = event.data.blockId;
257
482
  if (!lessonId || !blockId) return null;
258
483
  return statementFor(
484
+ event,
259
485
  buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId }),
260
486
  XAPIVerbs.completed,
261
487
  ctx.timestamp
@@ -270,20 +496,92 @@ var TELEMETRY_XAPI_MAPPERS = {
270
496
  const blockId = event.data.blockId;
271
497
  if (!lessonId || !blockId) return null;
272
498
  return statementFor(
499
+ event,
500
+ buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId }),
501
+ XAPIVerbs.completed,
502
+ ctx.timestamp
503
+ );
504
+ },
505
+ branch_node_viewed: (event, ctx) => {
506
+ if (event.name !== "branch_node_viewed") return null;
507
+ const lessonId = event.lessonId;
508
+ const blockId = event.data.blockId;
509
+ const nodeId = event.data.nodeId;
510
+ if (!lessonId || !blockId || !nodeId) return null;
511
+ return statementFor(
512
+ event,
513
+ buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId, nodeId }),
514
+ XAPIVerbs.experienced,
515
+ ctx.timestamp
516
+ );
517
+ },
518
+ branch_selected: (event, ctx) => {
519
+ if (event.name !== "branch_selected") return null;
520
+ const lessonId = event.lessonId;
521
+ const blockId = event.data.blockId;
522
+ const toNodeId = event.data.toNodeId;
523
+ if (!lessonId || !blockId || !toNodeId) return null;
524
+ return statementFor(
525
+ event,
526
+ buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId, nodeId: toNodeId }),
527
+ XAPIVerbs.experienced,
528
+ ctx.timestamp
529
+ );
530
+ },
531
+ image_juxtaposition_changed: experiencedBlockMapper,
532
+ timeline_event_viewed: experiencedBlockMapper,
533
+ image_sequence_changed: experiencedBlockMapper,
534
+ audio_recording_started: experiencedBlockMapper,
535
+ audio_recording_completed: (event, ctx) => {
536
+ if (event.name !== "audio_recording_completed") return null;
537
+ const lessonId = event.lessonId;
538
+ const blockId = event.data.blockId;
539
+ if (!blockId) return null;
540
+ return statementFor(
541
+ event,
273
542
  buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId }),
274
543
  XAPIVerbs.completed,
275
544
  ctx.timestamp
276
545
  );
546
+ },
547
+ qr_content_revealed: experiencedBlockMapper,
548
+ advent_door_opened: experiencedBlockMapper,
549
+ map_stage_viewed: (event, ctx) => {
550
+ if (event.name !== "map_stage_viewed") return null;
551
+ const lessonId = event.lessonId;
552
+ const blockId = event.data.blockId;
553
+ const stageId = event.data.stageId;
554
+ if (!lessonId || !blockId || !stageId) return null;
555
+ return statementFor(
556
+ event,
557
+ buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId, nodeId: stageId }),
558
+ XAPIVerbs.experienced,
559
+ ctx.timestamp
560
+ );
561
+ },
562
+ map_exit_selected: (event, ctx) => {
563
+ if (event.name !== "map_exit_selected") return null;
564
+ const lessonId = event.lessonId;
565
+ const blockId = event.data.blockId;
566
+ const toStageId = event.data.toStageId;
567
+ if (!lessonId || !blockId || !toStageId) return null;
568
+ return statementFor(
569
+ event,
570
+ buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId, nodeId: toStageId }),
571
+ XAPIVerbs.experienced,
572
+ ctx.timestamp
573
+ );
277
574
  }
278
575
  };
279
576
  function telemetryEventToXAPIStatement(event) {
280
- const mapper = TELEMETRY_XAPI_MAPPERS[event.name];
577
+ const enriched = enrichTelemetryEventForXapi(event);
578
+ const mapper = TELEMETRY_XAPI_MAPPERS[enriched.name];
281
579
  if (!mapper) {
282
- throw new Error(`Unhandled telemetry event: ${event.name}`);
580
+ throw new Error(`Unhandled telemetry event: ${enriched.name}`);
283
581
  }
284
- return mapper(event, {
285
- courseId: event.courseId,
286
- timestamp: event.timestamp
582
+ return mapper(enriched, {
583
+ courseId: enriched.courseId,
584
+ timestamp: enriched.timestamp
287
585
  });
288
586
  }
289
587
 
@@ -301,26 +599,90 @@ function isDevEnvironment() {
301
599
  const g = globalThis;
302
600
  return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
303
601
  }
602
+ function defaultQueueCapHandler() {
603
+ if (isDevEnvironment()) {
604
+ console.warn("[lessonkit] xAPI queue reached capacity; oldest statement(s) dropped.");
605
+ }
606
+ }
607
+ function defaultHeadSkippedHandler(_statement, err) {
608
+ if (isDevEnvironment()) {
609
+ console.warn(
610
+ "[lessonkit] xAPI queue skipped statement after repeated transport failures:",
611
+ err instanceof Error ? err.message : err
612
+ );
613
+ }
614
+ }
304
615
  function createXAPIClient(opts) {
305
616
  const transport = opts?.transport;
306
617
  const exitTransport = opts?.exitTransport;
307
618
  const courseId = opts?.courseId;
308
619
  const queue = opts?.queue ?? createInMemoryXAPIQueue({
309
620
  maxSize: opts?.maxQueueSize,
621
+ maxHeadFailures: opts?.maxHeadFailures,
310
622
  onDepth: opts?.onQueueDepth,
311
- onCap: opts?.onQueueCap
623
+ onCap: opts?.onQueueCap ?? defaultQueueCapHandler,
624
+ onOverflow: (statement) => {
625
+ persistDeadLetterStatement(statement, {
626
+ onTruncated: opts?.onDeadLetterTruncated
627
+ });
628
+ },
629
+ onHeadSkipped: (statement, err) => {
630
+ persistDeadLetterStatement(statement, {
631
+ onTruncated: opts?.onDeadLetterTruncated
632
+ });
633
+ (opts?.onHeadSkipped ?? defaultHeadSkippedHandler)(statement, err);
634
+ }
312
635
  });
313
636
  let warnedNoTransport = false;
314
637
  let warnedTransportFailure = false;
315
638
  const inflightById = /* @__PURE__ */ new Map();
316
639
  const inflightStatements = /* @__PURE__ */ new Map();
640
+ const pendingReplacement = /* @__PURE__ */ new Map();
641
+ const inflightPayload = /* @__PURE__ */ new Map();
642
+ const replacementWatcher = /* @__PURE__ */ new Set();
317
643
  const exitDeliveredIds = /* @__PURE__ */ new Set();
318
644
  const exitNetworkSentIds = /* @__PURE__ */ new Set();
645
+ const exitHandoffIds = /* @__PURE__ */ new Set();
646
+ let activeFlush = null;
647
+ for (const statement of loadDeadLetterStatements()) {
648
+ queue.enqueue(statement);
649
+ }
650
+ const hadDeadLetters = queue.size() > 0;
319
651
  const deliveryTransport = transport ? async (statement) => {
320
652
  if (exitNetworkSentIds.has(statement.id)) return;
321
653
  await transport(statement);
654
+ removeDeadLetterStatement(statement.id);
322
655
  } : void 0;
323
- const sendOrQueue = (statement) => {
656
+ const markExitDelivered = (statement) => {
657
+ exitHandoffIds.delete(statement.id);
658
+ exitDeliveredIds.add(statement.id);
659
+ exitNetworkSentIds.add(statement.id);
660
+ removeDeadLetterStatement(statement.id);
661
+ };
662
+ const dispatchExitStatement = (statement) => {
663
+ if (exitDeliveredIds.has(statement.id)) return;
664
+ exitHandoffIds.add(statement.id);
665
+ try {
666
+ const result = exitTransport(statement);
667
+ if (result != null && typeof result.then === "function") {
668
+ void result.then(
669
+ () => markExitDelivered(statement),
670
+ () => {
671
+ exitHandoffIds.delete(statement.id);
672
+ persistDeadLetterStatement(statement);
673
+ }
674
+ );
675
+ } else {
676
+ markExitDelivered(statement);
677
+ }
678
+ } catch {
679
+ exitHandoffIds.delete(statement.id);
680
+ persistDeadLetterStatement(statement);
681
+ }
682
+ };
683
+ const pendingDuringFlush = [];
684
+ let flushInProgress = false;
685
+ const sendOrQueueInternal = (statement) => {
324
686
  const normalized = withStatementId2(statement);
325
687
  if (exitDeliveredIds.has(normalized.id)) return;
326
688
  if (!deliveryTransport) {
@@ -335,20 +697,39 @@ function createXAPIClient(opts) {
335
697
  }
336
698
  const existing = inflightById.get(normalized.id);
337
699
  if (existing) {
338
- void existing.then(
339
- () => void 0,
340
- () => {
341
- sendOrQueue(normalized);
342
- }
343
- );
700
+ pendingReplacement.set(normalized.id, normalized);
701
+ inflightStatements.set(normalized.id, normalized);
702
+ if (!replacementWatcher.has(normalized.id)) {
703
+ replacementWatcher.add(normalized.id);
704
+ void existing.then(
705
+ () => {
706
+ replacementWatcher.delete(normalized.id);
707
+ const replacement = pendingReplacement.get(normalized.id);
708
+ const transported = inflightPayload.get(normalized.id);
709
+ pendingReplacement.delete(normalized.id);
710
+ inflightPayload.delete(normalized.id);
711
+ if (replacement && replacement !== transported) {
712
+ sendOrQueueInternal(replacement);
713
+ }
714
+ },
715
+ () => {
716
+ replacementWatcher.delete(normalized.id);
717
+ const replacement = pendingReplacement.get(normalized.id) ?? normalized;
718
+ pendingReplacement.delete(normalized.id);
719
+ sendOrQueueInternal(replacement);
720
+ }
721
+ );
722
+ }
344
723
  return;
345
724
  }
725
+ queue.removeById(normalized.id);
346
726
  inflightStatements.set(normalized.id, normalized);
727
+ inflightPayload.set(normalized.id, normalized);
347
728
  const flight = Promise.resolve().then(async () => {
348
729
  await deliveryTransport(normalized);
349
730
  queue.removeById(normalized.id);
350
731
  }).catch((err) => {
351
- if (exitDeliveredIds.has(normalized.id)) return;
732
+ if (exitDeliveredIds.has(normalized.id) || exitHandoffIds.has(normalized.id)) return;
352
733
  queue.enqueue(normalized);
353
734
  opts?.onTransportError?.(err);
354
735
  if (isDevEnvironment() && !warnedTransportFailure) {
@@ -361,17 +742,28 @@ function createXAPIClient(opts) {
361
742
  }).finally(() => {
362
743
  inflightById.delete(normalized.id);
363
744
  inflightStatements.delete(normalized.id);
745
+ if (!replacementWatcher.has(normalized.id)) {
746
+ inflightPayload.delete(normalized.id);
747
+ }
364
748
  });
365
749
  inflightById.set(normalized.id, flight);
366
750
  void flight.catch(() => {
367
751
  });
368
752
  };
753
+ const sendOrQueue = (statement) => {
754
+ if (flushInProgress) {
755
+ pendingDuringFlush.push(statement);
756
+ return;
757
+ }
758
+ sendOrQueueInternal(statement);
759
+ };
369
760
  const emit = (event) => {
370
761
  try {
371
762
  const statement = telemetryEventToXAPIStatement(event);
372
763
  if (!statement) return;
373
764
  sendOrQueue(statement);
374
765
  } catch (err) {
766
+ opts?.onMappingError?.(err);
375
767
  if (isDevEnvironment()) {
376
768
  console.warn(
377
769
  "[lessonkit] xAPI mapping skipped:",
@@ -380,42 +772,66 @@ function createXAPIClient(opts) {
380
772
  }
381
773
  }
382
774
  };
383
- return {
775
+ const runFlushLoop = async () => {
776
+ if (!deliveryTransport) return;
777
+ for (; ; ) {
778
+ await queue.flush(deliveryTransport);
779
+ const flights = [...inflightById.values()];
780
+ if (flights.length > 0) {
781
+ await Promise.all(flights);
782
+ }
783
+ if (queue.size() === 0 && inflightById.size === 0) break;
784
+ }
785
+ };
786
+ const client = {
384
787
  send: (statement) => {
385
788
  sendOrQueue(statement);
386
789
  },
387
790
  queueSize: () => queue.size(),
388
791
  flush: async () => {
389
792
  if (!deliveryTransport) return;
390
- await queue.flush(deliveryTransport);
391
- const flights = [...inflightById.values()];
392
- if (flights.length > 0) {
393
- await Promise.all(flights);
394
- }
395
- if (queue.size() > 0) {
396
- throw new Error("xAPI flush incomplete: statements remain queued after flush");
793
+ for (; ; ) {
794
+ if (activeFlush) {
795
+ await activeFlush;
796
+ } else {
797
+ flushInProgress = true;
798
+ activeFlush = (async () => {
799
+ try {
800
+ await runFlushLoop();
801
+ while (pendingDuringFlush.length > 0) {
802
+ const batch = pendingDuringFlush.splice(0, pendingDuringFlush.length);
803
+ for (const pending of batch) {
804
+ sendOrQueueInternal(pending);
805
+ }
806
+ await runFlushLoop();
807
+ }
808
+ } finally {
809
+ flushInProgress = false;
810
+ }
811
+ })().finally(() => {
812
+ activeFlush = null;
813
+ });
814
+ await activeFlush;
815
+ }
816
+ if (queue.size() === 0 && inflightById.size === 0) break;
397
817
  }
398
818
  },
399
819
  flushOnExit: exitTransport ? () => {
400
820
  const headId = queue.getHeadInFlightId?.();
401
821
  if (headId) {
402
- exitNetworkSentIds.add(headId);
403
- exitDeliveredIds.add(headId);
404
822
  opts.abortInFlight?.(headId);
823
+ const headStatement = inflightStatements.get(headId);
824
+ if (headStatement) {
825
+ dispatchExitStatement(headStatement);
826
+ }
405
827
  }
406
828
  for (const statement of inflightStatements.values()) {
407
- exitNetworkSentIds.add(statement.id);
408
- exitDeliveredIds.add(statement.id);
829
+ if (statement.id === headId) continue;
409
830
  opts.abortInFlight?.(statement.id);
831
+ dispatchExitStatement(statement);
410
832
  }
411
833
  queue.flushOnExit((statement) => {
412
- if (exitDeliveredIds.has(statement.id)) return;
413
- exitNetworkSentIds.add(statement.id);
414
- exitDeliveredIds.add(statement.id);
415
- try {
416
- exitTransport(statement);
417
- } catch {
418
- }
834
+ dispatchExitStatement(statement);
419
835
  });
420
836
  } : void 0,
421
837
  startedLesson: ({ lessonId }) => {
@@ -453,6 +869,97 @@ function createXAPIClient(opts) {
453
869
  });
454
870
  }
455
871
  };
872
+ if (hadDeadLetters && deliveryTransport) {
873
+ queueMicrotask(() => {
874
+ void client.flush().catch(() => void 0);
875
+ });
876
+ }
877
+ return client;
878
+ }
879
+ function resetXAPIDeadLetterForTests() {
880
+ clearDeadLetterStorage();
881
+ }
882
+
883
+ // src/safeLrsUrl.ts
884
+ function isProductionRuntime() {
885
+ try {
886
+ if (import.meta.env?.PROD === true) return true;
887
+ } catch {
888
+ }
889
+ const g = globalThis;
890
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production";
891
+ }
892
+ function parseHostname(url) {
893
+ return url.hostname.replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
894
+ }
895
+ function isIpv4MappedAddress(hostname) {
896
+ const match = hostname.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
897
+ return match?.[1] ?? null;
898
+ }
899
+ function isLoopbackHost(hostname) {
900
+ const ipv4Mapped = isIpv4MappedAddress(hostname);
901
+ if (ipv4Mapped) return isLoopbackHost(ipv4Mapped);
902
+ return hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "127.0.0.1" || hostname === "::1" || hostname === "0.0.0.0";
903
+ }
904
+ function isLinkLocalOrMetadataHost(hostname) {
905
+ if (hostname === "169.254.169.254") return true;
906
+ if (/^169\.254\./.test(hostname)) return true;
907
+ if (/^fe80:/i.test(hostname)) return true;
908
+ return false;
909
+ }
910
+ function isRfc1918Host(hostname) {
911
+ const ipv4Mapped = isIpv4MappedAddress(hostname);
912
+ if (ipv4Mapped) return isRfc1918Host(ipv4Mapped);
913
+ if (/^10\./.test(hostname)) return true;
914
+ if (/^192\.168\./.test(hostname)) return true;
915
+ const parts = hostname.split(".").map(Number);
916
+ if (parts.length === 4 && parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
917
+ return false;
918
+ }
919
+ function isPrivateOrMetadataHost(hostname) {
920
+ return isLoopbackHost(hostname) || isLinkLocalOrMetadataHost(hostname) || isRfc1918Host(hostname);
921
+ }
922
+ function containsPathTraversal(path) {
923
+ if (path.includes("..")) return true;
924
+ let decoded = path;
925
+ for (let i = 0; i < 2; i++) {
926
+ try {
927
+ const next = decodeURIComponent(decoded.replace(/\+/g, " "));
928
+ if (next.includes("..")) return true;
929
+ if (next === decoded) break;
930
+ decoded = next;
931
+ } catch {
932
+ break;
933
+ }
934
+ }
935
+ return false;
936
+ }
937
+ function assertSafeLrsUrl(url, opts) {
938
+ if (url.startsWith("/")) {
939
+ if (containsPathTraversal(url)) {
940
+ throw new Error(`Unsafe LRS URL: path traversal in "${url}"`);
941
+ }
942
+ return;
943
+ }
944
+ let parsed;
945
+ try {
946
+ parsed = new URL(url);
947
+ } catch {
948
+ throw new Error(`Unsafe LRS URL: invalid URL "${url}"`);
949
+ }
950
+ if (containsPathTraversal(parsed.pathname)) {
951
+ throw new Error(`Unsafe LRS URL: path traversal in "${url}"`);
952
+ }
953
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
954
+ throw new Error(`Unsafe LRS URL: unsupported scheme "${parsed.protocol}"`);
955
+ }
956
+ if (isProductionRuntime() && parsed.protocol !== "https:") {
957
+ throw new Error("Unsafe LRS URL: HTTPS is required in production builds");
958
+ }
959
+ const hostname = parseHostname(parsed);
960
+ if (!opts?.allowPrivateHosts && isPrivateOrMetadataHost(hostname)) {
961
+ throw new Error(`Unsafe LRS URL: private or metadata host "${hostname}" is not allowed`);
962
+ }
456
963
  }
457
964
 
458
965
  // src/fetchTransport.ts
@@ -522,6 +1029,7 @@ async function postWithRetry(post, retries, initialBackoffMs, maxBackoffMs) {
522
1029
  }
523
1030
  }
524
1031
  function createFetchTransport(opts) {
1032
+ assertSafeLrsUrl(opts.url, { allowPrivateHosts: opts.allowPrivateHosts });
525
1033
  const timeoutMs = opts.timeoutMs ?? 3e4;
526
1034
  const rawRetries = opts.retries ?? 2;
527
1035
  const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
@@ -553,14 +1061,13 @@ function createFetchTransport(opts) {
553
1061
  }
554
1062
  };
555
1063
  const exitTransport = (statement) => {
556
- try {
557
- void postStatement(opts.url, statement, {
558
- ...opts.init,
559
- headers: resolveHeaders(opts.headers),
560
- keepalive: true
561
- }).catch(() => void 0);
562
- } catch {
563
- }
1064
+ return postStatement(opts.url, statement, {
1065
+ ...opts.init,
1066
+ headers: resolveHeaders(opts.headers),
1067
+ keepalive: true
1068
+ }).catch(() => {
1069
+ throw new Error("xAPI keepalive delivery failed");
1070
+ });
564
1071
  };
565
1072
  const abortInFlight = (statementId) => {
566
1073
  activeControllers.get(statementId)?.abort();
@@ -569,6 +1076,7 @@ function createFetchTransport(opts) {
569
1076
  return { transport, exitTransport, abortInFlight };
570
1077
  }
571
1078
  function createFetchBatchSink(opts) {
1079
+ assertSafeLrsUrl(opts.url, { allowPrivateHosts: opts.allowPrivateHosts });
572
1080
  const timeoutMs = opts.timeoutMs ?? 3e4;
573
1081
  const rawRetries = opts.retries ?? 2;
574
1082
  const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
@@ -592,26 +1100,31 @@ function createFetchBatchSink(opts) {
592
1100
  return {
593
1101
  batchSink: (events) => postBatch(events, opts.init ?? {}),
594
1102
  exitBatchSink: (events) => {
595
- try {
596
- void fetch(opts.url, {
597
- method: "POST",
598
- body: JSON.stringify(events),
599
- ...opts.init,
600
- headers: resolveHeaders(opts.headers),
601
- keepalive: true
602
- }).catch(() => void 0);
603
- } catch {
604
- }
1103
+ return fetch(opts.url, {
1104
+ method: "POST",
1105
+ body: JSON.stringify(events),
1106
+ ...opts.init,
1107
+ headers: resolveHeaders(opts.headers),
1108
+ keepalive: true
1109
+ }).then((res) => {
1110
+ if (!res.ok) {
1111
+ throw new FetchHttpError(res.status, res.statusText, "batch");
1112
+ }
1113
+ });
605
1114
  }
606
1115
  };
607
1116
  }
608
1117
  export {
609
1118
  FetchHttpError,
1119
+ assertSafeLrsUrl,
610
1120
  createFetchBatchSink,
611
1121
  createFetchTransport,
612
1122
  createInMemoryXAPIQueue,
613
1123
  createXAPIClient,
614
1124
  isRetryableFetchError,
615
1125
  isRetryableFetchHttpStatus,
1126
+ loadDeadLetterStatements,
1127
+ persistDeadLetterStatement,
1128
+ resetXAPIDeadLetterForTests,
616
1129
  telemetryEventToXAPIStatement
617
1130
  };