@lessonkit/xapi 1.4.0 → 1.5.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,26 @@ 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) {
71
184
  buffer.splice(1, 1);
185
+ opts?.onCap?.();
186
+ } else {
187
+ opts?.onCap?.();
188
+ opts?.onOverflow?.(normalized);
189
+ return;
72
190
  }
73
191
  } else {
74
192
  buffer.shift();
193
+ opts?.onCap?.();
75
194
  }
76
- opts?.onCap?.();
77
195
  }
78
196
  buffer.push(normalized);
79
197
  notifyDepth();
@@ -105,6 +223,60 @@ function createInMemoryXAPIQueue(opts) {
105
223
  // src/client.ts
106
224
  import { nowIso } from "@lessonkit/core";
107
225
 
226
+ // src/deadLetter.ts
227
+ var STORAGE_KEY = "lk-xapi-dead-letter";
228
+ var MAX_DEAD_LETTER = 200;
229
+ function readStorage() {
230
+ try {
231
+ const storage = globalThis.sessionStorage;
232
+ return storage ?? null;
233
+ } catch {
234
+ return null;
235
+ }
236
+ }
237
+ function loadDeadLetterStatements() {
238
+ const storage = readStorage();
239
+ if (!storage) return [];
240
+ try {
241
+ const raw = storage.getItem(STORAGE_KEY);
242
+ if (!raw) return [];
243
+ const parsed = JSON.parse(raw);
244
+ if (!Array.isArray(parsed)) return [];
245
+ return parsed.filter(
246
+ (item) => typeof item === "object" && item !== null && typeof item.id === "string"
247
+ );
248
+ } catch {
249
+ return [];
250
+ }
251
+ }
252
+ function persistDeadLetterStatement(statement) {
253
+ const storage = readStorage();
254
+ if (!storage) return;
255
+ try {
256
+ const existing = loadDeadLetterStatements();
257
+ if (existing.some((s) => s.id === statement.id)) return;
258
+ const next = [...existing, statement].slice(-MAX_DEAD_LETTER);
259
+ storage.setItem(STORAGE_KEY, JSON.stringify(next));
260
+ } catch {
261
+ }
262
+ }
263
+ function removeDeadLetterStatement(id) {
264
+ const storage = readStorage();
265
+ if (!storage) return;
266
+ try {
267
+ const next = loadDeadLetterStatements().filter((s) => s.id !== id);
268
+ if (next.length === 0) {
269
+ storage.removeItem(STORAGE_KEY);
270
+ } else {
271
+ storage.setItem(STORAGE_KEY, JSON.stringify(next));
272
+ }
273
+ } catch {
274
+ }
275
+ }
276
+ function clearDeadLetterStorage() {
277
+ readStorage()?.removeItem(STORAGE_KEY);
278
+ }
279
+
108
280
  // src/telemetryMap.ts
109
281
  import { buildLessonkitUrn } from "@lessonkit/core";
110
282
 
@@ -141,9 +313,9 @@ function buildXapiScoreResult(opts) {
141
313
  }
142
314
  return result;
143
315
  }
144
- function statementFor(objectId, verb, timestamp, extra) {
316
+ function statementFor(event, objectId, verb, timestamp, extra) {
145
317
  return {
146
- id: cryptoRandomId(),
318
+ id: deriveStatementId(event, objectId, verb),
147
319
  timestamp,
148
320
  verb,
149
321
  object: { id: objectId },
@@ -151,8 +323,21 @@ function statementFor(objectId, verb, timestamp, extra) {
151
323
  context: extra?.context
152
324
  };
153
325
  }
154
- function experiencedBlockStatement(courseId, lessonId, blockId, timestamp) {
326
+ function sanitizeTelemetryEmbedSrc(src) {
327
+ try {
328
+ const url = new URL(src);
329
+ url.username = "";
330
+ url.password = "";
331
+ url.search = "";
332
+ url.hash = "";
333
+ return `${url.origin}${url.pathname}`;
334
+ } catch {
335
+ return src;
336
+ }
337
+ }
338
+ function experiencedBlockStatement(event, courseId, lessonId, blockId, timestamp) {
155
339
  return statementFor(
340
+ event,
156
341
  buildLessonkitUrn({ courseId, lessonId, blockId }),
157
342
  XAPIVerbs.experienced,
158
343
  timestamp
@@ -163,20 +348,39 @@ var experiencedBlockMapper = (event, ctx) => {
163
348
  const lessonId2 = event.lessonId;
164
349
  const blockId2 = event.data?.blockId;
165
350
  if (!lessonId2 || !blockId2 || typeof blockId2 !== "string") return null;
166
- return experiencedBlockStatement(ctx.courseId, lessonId2, blockId2, ctx.timestamp);
351
+ const kind = event.data?.kind;
352
+ const extensions = {};
353
+ if (kind === "embed_viewed" || kind === "chart_viewed") {
354
+ extensions["https://lessonkit.dev/xapi/interactionKind"] = kind;
355
+ const data = event.data;
356
+ if (kind === "embed_viewed" && data && typeof data.src === "string") {
357
+ extensions["https://lessonkit.dev/xapi/embedSrc"] = sanitizeTelemetryEmbedSrc(data.src);
358
+ }
359
+ if (kind === "chart_viewed" && data && typeof data.chartType === "string") {
360
+ extensions["https://lessonkit.dev/xapi/chartType"] = data.chartType;
361
+ }
362
+ }
363
+ return statementFor(
364
+ event,
365
+ buildLessonkitUrn({ courseId: ctx.courseId, lessonId: lessonId2, blockId: blockId2 }),
366
+ XAPIVerbs.experienced,
367
+ ctx.timestamp,
368
+ Object.keys(extensions).length > 0 ? { context: { extensions } } : void 0
369
+ );
167
370
  }
168
371
  const lessonId = event.lessonId;
169
372
  const blockId = "data" in event && event.data && "blockId" in event.data ? event.data.blockId : void 0;
170
373
  if (!lessonId || !blockId || typeof blockId !== "string") return null;
171
- return experiencedBlockStatement(ctx.courseId, lessonId, blockId, ctx.timestamp);
374
+ return experiencedBlockStatement(event, ctx.courseId, lessonId, blockId, ctx.timestamp);
172
375
  };
173
376
  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),
377
+ course_started: (event, ctx) => statementFor(event, buildLessonkitUrn({ courseId: ctx.courseId }), XAPIVerbs.initialized, ctx.timestamp),
378
+ course_completed: (event, ctx) => statementFor(event, buildLessonkitUrn({ courseId: ctx.courseId }), XAPIVerbs.completed, ctx.timestamp),
176
379
  lesson_started: (event, ctx) => {
177
380
  const lessonId = event.name === "lesson_started" ? event.lessonId : void 0;
178
381
  if (!lessonId) return null;
179
382
  return statementFor(
383
+ event,
180
384
  buildLessonkitUrn({ courseId: ctx.courseId, lessonId }),
181
385
  XAPIVerbs.initialized,
182
386
  ctx.timestamp
@@ -194,9 +398,15 @@ var TELEMETRY_XAPI_MAPPERS = {
194
398
  if (typeof data?.success === "boolean") result.success = data.success;
195
399
  const score = buildXapiScoreResult({ score: data?.score, maxScore: data?.maxScore });
196
400
  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
- });
401
+ return statementFor(
402
+ event,
403
+ buildLessonkitUrn({ courseId: ctx.courseId, lessonId }),
404
+ XAPIVerbs.completed,
405
+ ctx.timestamp,
406
+ {
407
+ result: Object.keys(result).length ? result : void 0
408
+ }
409
+ );
200
410
  },
201
411
  lesson_time_on_task: () => null,
202
412
  quiz_answered: (event, ctx) => {
@@ -204,6 +414,7 @@ var TELEMETRY_XAPI_MAPPERS = {
204
414
  const result = {};
205
415
  if (typeof event.data.correct === "boolean") result.success = event.data.correct;
206
416
  return statementFor(
417
+ event,
207
418
  buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
208
419
  XAPIVerbs.answered,
209
420
  ctx.timestamp,
@@ -214,6 +425,7 @@ var TELEMETRY_XAPI_MAPPERS = {
214
425
  if (event.name !== "quiz_completed") return null;
215
426
  const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
216
427
  return statementFor(
428
+ event,
217
429
  buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
218
430
  XAPIVerbs.completed,
219
431
  ctx.timestamp,
@@ -225,6 +437,7 @@ var TELEMETRY_XAPI_MAPPERS = {
225
437
  const result = {};
226
438
  if (typeof event.data.correct === "boolean") result.success = event.data.correct;
227
439
  return statementFor(
440
+ event,
228
441
  buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
229
442
  XAPIVerbs.answered,
230
443
  ctx.timestamp,
@@ -235,6 +448,7 @@ var TELEMETRY_XAPI_MAPPERS = {
235
448
  if (event.name !== "assessment_completed") return null;
236
449
  const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
237
450
  return statementFor(
451
+ event,
238
452
  buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
239
453
  XAPIVerbs.completed,
240
454
  ctx.timestamp,
@@ -256,6 +470,7 @@ var TELEMETRY_XAPI_MAPPERS = {
256
470
  const blockId = event.data.blockId;
257
471
  if (!lessonId || !blockId) return null;
258
472
  return statementFor(
473
+ event,
259
474
  buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId }),
260
475
  XAPIVerbs.completed,
261
476
  ctx.timestamp
@@ -270,20 +485,48 @@ var TELEMETRY_XAPI_MAPPERS = {
270
485
  const blockId = event.data.blockId;
271
486
  if (!lessonId || !blockId) return null;
272
487
  return statementFor(
488
+ event,
273
489
  buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId }),
274
490
  XAPIVerbs.completed,
275
491
  ctx.timestamp
276
492
  );
493
+ },
494
+ branch_node_viewed: (event, ctx) => {
495
+ if (event.name !== "branch_node_viewed") return null;
496
+ const lessonId = event.lessonId;
497
+ const blockId = event.data.blockId;
498
+ const nodeId = event.data.nodeId;
499
+ if (!lessonId || !blockId || !nodeId) return null;
500
+ return statementFor(
501
+ event,
502
+ buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId, nodeId }),
503
+ XAPIVerbs.experienced,
504
+ ctx.timestamp
505
+ );
506
+ },
507
+ branch_selected: (event, ctx) => {
508
+ if (event.name !== "branch_selected") return null;
509
+ const lessonId = event.lessonId;
510
+ const blockId = event.data.blockId;
511
+ const toNodeId = event.data.toNodeId;
512
+ if (!lessonId || !blockId || !toNodeId) return null;
513
+ return statementFor(
514
+ event,
515
+ buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId, nodeId: toNodeId }),
516
+ XAPIVerbs.experienced,
517
+ ctx.timestamp
518
+ );
277
519
  }
278
520
  };
279
521
  function telemetryEventToXAPIStatement(event) {
280
- const mapper = TELEMETRY_XAPI_MAPPERS[event.name];
522
+ const enriched = enrichTelemetryEventForXapi(event);
523
+ const mapper = TELEMETRY_XAPI_MAPPERS[enriched.name];
281
524
  if (!mapper) {
282
- throw new Error(`Unhandled telemetry event: ${event.name}`);
525
+ throw new Error(`Unhandled telemetry event: ${enriched.name}`);
283
526
  }
284
- return mapper(event, {
285
- courseId: event.courseId,
286
- timestamp: event.timestamp
527
+ return mapper(enriched, {
528
+ courseId: enriched.courseId,
529
+ timestamp: enriched.timestamp
287
530
  });
288
531
  }
289
532
 
@@ -301,26 +544,86 @@ function isDevEnvironment() {
301
544
  const g = globalThis;
302
545
  return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
303
546
  }
547
+ function defaultQueueCapHandler() {
548
+ if (isDevEnvironment()) {
549
+ console.warn("[lessonkit] xAPI queue reached capacity; oldest statement(s) dropped.");
550
+ }
551
+ }
552
+ function defaultHeadSkippedHandler(_statement, err) {
553
+ if (isDevEnvironment()) {
554
+ console.warn(
555
+ "[lessonkit] xAPI queue skipped statement after repeated transport failures:",
556
+ err instanceof Error ? err.message : err
557
+ );
558
+ }
559
+ }
304
560
  function createXAPIClient(opts) {
305
561
  const transport = opts?.transport;
306
562
  const exitTransport = opts?.exitTransport;
307
563
  const courseId = opts?.courseId;
308
564
  const queue = opts?.queue ?? createInMemoryXAPIQueue({
309
565
  maxSize: opts?.maxQueueSize,
566
+ maxHeadFailures: opts?.maxHeadFailures,
310
567
  onDepth: opts?.onQueueDepth,
311
- onCap: opts?.onQueueCap
568
+ onCap: opts?.onQueueCap ?? defaultQueueCapHandler,
569
+ onOverflow: (statement) => {
570
+ persistDeadLetterStatement(statement);
571
+ },
572
+ onHeadSkipped: (statement, err) => {
573
+ persistDeadLetterStatement(statement);
574
+ (opts?.onHeadSkipped ?? defaultHeadSkippedHandler)(statement, err);
575
+ }
312
576
  });
313
577
  let warnedNoTransport = false;
314
578
  let warnedTransportFailure = false;
315
579
  const inflightById = /* @__PURE__ */ new Map();
316
580
  const inflightStatements = /* @__PURE__ */ new Map();
581
+ const pendingReplacement = /* @__PURE__ */ new Map();
582
+ const inflightPayload = /* @__PURE__ */ new Map();
583
+ const replacementWatcher = /* @__PURE__ */ new Set();
317
584
  const exitDeliveredIds = /* @__PURE__ */ new Set();
318
585
  const exitNetworkSentIds = /* @__PURE__ */ new Set();
586
+ const exitHandoffIds = /* @__PURE__ */ new Set();
587
+ let activeFlush = null;
588
+ for (const statement of loadDeadLetterStatements()) {
589
+ queue.enqueue(statement);
590
+ }
591
+ const hadDeadLetters = queue.size() > 0;
319
592
  const deliveryTransport = transport ? async (statement) => {
320
593
  if (exitNetworkSentIds.has(statement.id)) return;
321
594
  await transport(statement);
595
+ removeDeadLetterStatement(statement.id);
322
596
  } : void 0;
323
- const sendOrQueue = (statement) => {
597
+ const markExitDelivered = (statement) => {
598
+ exitHandoffIds.delete(statement.id);
599
+ exitDeliveredIds.add(statement.id);
600
+ exitNetworkSentIds.add(statement.id);
601
+ removeDeadLetterStatement(statement.id);
602
+ };
603
+ const dispatchExitStatement = (statement) => {
604
+ if (exitDeliveredIds.has(statement.id)) return;
605
+ exitHandoffIds.add(statement.id);
606
+ try {
607
+ const result = exitTransport(statement);
608
+ if (result != null && typeof result.then === "function") {
609
+ void result.then(
610
+ () => markExitDelivered(statement),
611
+ () => {
612
+ exitHandoffIds.delete(statement.id);
613
+ persistDeadLetterStatement(statement);
614
+ }
615
+ );
616
+ } else {
617
+ markExitDelivered(statement);
618
+ }
619
+ } catch {
620
+ exitHandoffIds.delete(statement.id);
621
+ persistDeadLetterStatement(statement);
622
+ }
623
+ };
624
+ const pendingDuringFlush = [];
625
+ let flushInProgress = false;
626
+ const sendOrQueueInternal = (statement) => {
324
627
  const normalized = withStatementId2(statement);
325
628
  if (exitDeliveredIds.has(normalized.id)) return;
326
629
  if (!deliveryTransport) {
@@ -335,20 +638,38 @@ function createXAPIClient(opts) {
335
638
  }
336
639
  const existing = inflightById.get(normalized.id);
337
640
  if (existing) {
338
- void existing.then(
339
- () => void 0,
340
- () => {
341
- sendOrQueue(normalized);
342
- }
343
- );
641
+ pendingReplacement.set(normalized.id, normalized);
642
+ inflightStatements.set(normalized.id, normalized);
643
+ if (!replacementWatcher.has(normalized.id)) {
644
+ replacementWatcher.add(normalized.id);
645
+ void existing.then(
646
+ () => {
647
+ replacementWatcher.delete(normalized.id);
648
+ const replacement = pendingReplacement.get(normalized.id);
649
+ const transported = inflightPayload.get(normalized.id);
650
+ pendingReplacement.delete(normalized.id);
651
+ inflightPayload.delete(normalized.id);
652
+ if (replacement && replacement !== transported) {
653
+ sendOrQueueInternal(replacement);
654
+ }
655
+ },
656
+ () => {
657
+ replacementWatcher.delete(normalized.id);
658
+ const replacement = pendingReplacement.get(normalized.id) ?? normalized;
659
+ pendingReplacement.delete(normalized.id);
660
+ sendOrQueueInternal(replacement);
661
+ }
662
+ );
663
+ }
344
664
  return;
345
665
  }
346
666
  inflightStatements.set(normalized.id, normalized);
667
+ inflightPayload.set(normalized.id, normalized);
347
668
  const flight = Promise.resolve().then(async () => {
348
669
  await deliveryTransport(normalized);
349
670
  queue.removeById(normalized.id);
350
671
  }).catch((err) => {
351
- if (exitDeliveredIds.has(normalized.id)) return;
672
+ if (exitDeliveredIds.has(normalized.id) || exitHandoffIds.has(normalized.id)) return;
352
673
  queue.enqueue(normalized);
353
674
  opts?.onTransportError?.(err);
354
675
  if (isDevEnvironment() && !warnedTransportFailure) {
@@ -361,17 +682,28 @@ function createXAPIClient(opts) {
361
682
  }).finally(() => {
362
683
  inflightById.delete(normalized.id);
363
684
  inflightStatements.delete(normalized.id);
685
+ if (!replacementWatcher.has(normalized.id)) {
686
+ inflightPayload.delete(normalized.id);
687
+ }
364
688
  });
365
689
  inflightById.set(normalized.id, flight);
366
690
  void flight.catch(() => {
367
691
  });
368
692
  };
693
+ const sendOrQueue = (statement) => {
694
+ if (flushInProgress) {
695
+ pendingDuringFlush.push(statement);
696
+ return;
697
+ }
698
+ sendOrQueueInternal(statement);
699
+ };
369
700
  const emit = (event) => {
370
701
  try {
371
702
  const statement = telemetryEventToXAPIStatement(event);
372
703
  if (!statement) return;
373
704
  sendOrQueue(statement);
374
705
  } catch (err) {
706
+ opts?.onMappingError?.(err);
375
707
  if (isDevEnvironment()) {
376
708
  console.warn(
377
709
  "[lessonkit] xAPI mapping skipped:",
@@ -380,42 +712,66 @@ function createXAPIClient(opts) {
380
712
  }
381
713
  }
382
714
  };
383
- return {
715
+ const runFlushLoop = async () => {
716
+ if (!deliveryTransport) return;
717
+ for (; ; ) {
718
+ await queue.flush(deliveryTransport);
719
+ const flights = [...inflightById.values()];
720
+ if (flights.length > 0) {
721
+ await Promise.all(flights);
722
+ }
723
+ if (queue.size() === 0 && inflightById.size === 0) break;
724
+ }
725
+ };
726
+ const client = {
384
727
  send: (statement) => {
385
728
  sendOrQueue(statement);
386
729
  },
387
730
  queueSize: () => queue.size(),
388
731
  flush: async () => {
389
732
  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");
733
+ for (; ; ) {
734
+ if (activeFlush) {
735
+ await activeFlush;
736
+ } else {
737
+ flushInProgress = true;
738
+ activeFlush = (async () => {
739
+ try {
740
+ await runFlushLoop();
741
+ while (pendingDuringFlush.length > 0) {
742
+ const batch = pendingDuringFlush.splice(0, pendingDuringFlush.length);
743
+ for (const pending of batch) {
744
+ sendOrQueueInternal(pending);
745
+ }
746
+ await runFlushLoop();
747
+ }
748
+ } finally {
749
+ flushInProgress = false;
750
+ }
751
+ })().finally(() => {
752
+ activeFlush = null;
753
+ });
754
+ await activeFlush;
755
+ }
756
+ if (queue.size() === 0 && inflightById.size === 0) break;
397
757
  }
398
758
  },
399
759
  flushOnExit: exitTransport ? () => {
400
760
  const headId = queue.getHeadInFlightId?.();
401
761
  if (headId) {
402
- exitNetworkSentIds.add(headId);
403
- exitDeliveredIds.add(headId);
404
762
  opts.abortInFlight?.(headId);
763
+ const headStatement = inflightStatements.get(headId);
764
+ if (headStatement) {
765
+ dispatchExitStatement(headStatement);
766
+ }
405
767
  }
406
768
  for (const statement of inflightStatements.values()) {
407
- exitNetworkSentIds.add(statement.id);
408
- exitDeliveredIds.add(statement.id);
769
+ if (statement.id === headId) continue;
409
770
  opts.abortInFlight?.(statement.id);
771
+ dispatchExitStatement(statement);
410
772
  }
411
773
  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
- }
774
+ dispatchExitStatement(statement);
419
775
  });
420
776
  } : void 0,
421
777
  startedLesson: ({ lessonId }) => {
@@ -453,6 +809,97 @@ function createXAPIClient(opts) {
453
809
  });
454
810
  }
455
811
  };
812
+ if (hadDeadLetters && deliveryTransport) {
813
+ queueMicrotask(() => {
814
+ void client.flush().catch(() => void 0);
815
+ });
816
+ }
817
+ return client;
818
+ }
819
+ function resetXAPIDeadLetterForTests() {
820
+ clearDeadLetterStorage();
821
+ }
822
+
823
+ // src/safeLrsUrl.ts
824
+ function isProductionRuntime() {
825
+ try {
826
+ if (import.meta.env?.PROD === true) return true;
827
+ } catch {
828
+ }
829
+ const g = globalThis;
830
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production";
831
+ }
832
+ function parseHostname(url) {
833
+ return url.hostname.replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
834
+ }
835
+ function isIpv4MappedAddress(hostname) {
836
+ const match = hostname.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
837
+ return match?.[1] ?? null;
838
+ }
839
+ function isLoopbackHost(hostname) {
840
+ const ipv4Mapped = isIpv4MappedAddress(hostname);
841
+ if (ipv4Mapped) return isLoopbackHost(ipv4Mapped);
842
+ return hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "127.0.0.1" || hostname === "::1" || hostname === "0.0.0.0";
843
+ }
844
+ function isLinkLocalOrMetadataHost(hostname) {
845
+ if (hostname === "169.254.169.254") return true;
846
+ if (/^169\.254\./.test(hostname)) return true;
847
+ if (/^fe80:/i.test(hostname)) return true;
848
+ return false;
849
+ }
850
+ function isRfc1918Host(hostname) {
851
+ const ipv4Mapped = isIpv4MappedAddress(hostname);
852
+ if (ipv4Mapped) return isRfc1918Host(ipv4Mapped);
853
+ if (/^10\./.test(hostname)) return true;
854
+ if (/^192\.168\./.test(hostname)) return true;
855
+ const parts = hostname.split(".").map(Number);
856
+ if (parts.length === 4 && parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
857
+ return false;
858
+ }
859
+ function isPrivateOrMetadataHost(hostname) {
860
+ return isLoopbackHost(hostname) || isLinkLocalOrMetadataHost(hostname) || isRfc1918Host(hostname);
861
+ }
862
+ function containsPathTraversal(path) {
863
+ if (path.includes("..")) return true;
864
+ let decoded = path;
865
+ for (let i = 0; i < 2; i++) {
866
+ try {
867
+ const next = decodeURIComponent(decoded.replace(/\+/g, " "));
868
+ if (next.includes("..")) return true;
869
+ if (next === decoded) break;
870
+ decoded = next;
871
+ } catch {
872
+ break;
873
+ }
874
+ }
875
+ return false;
876
+ }
877
+ function assertSafeLrsUrl(url, opts) {
878
+ if (url.startsWith("/")) {
879
+ if (containsPathTraversal(url)) {
880
+ throw new Error(`Unsafe LRS URL: path traversal in "${url}"`);
881
+ }
882
+ return;
883
+ }
884
+ let parsed;
885
+ try {
886
+ parsed = new URL(url);
887
+ } catch {
888
+ throw new Error(`Unsafe LRS URL: invalid URL "${url}"`);
889
+ }
890
+ if (containsPathTraversal(parsed.pathname)) {
891
+ throw new Error(`Unsafe LRS URL: path traversal in "${url}"`);
892
+ }
893
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
894
+ throw new Error(`Unsafe LRS URL: unsupported scheme "${parsed.protocol}"`);
895
+ }
896
+ if (isProductionRuntime() && parsed.protocol !== "https:") {
897
+ throw new Error("Unsafe LRS URL: HTTPS is required in production builds");
898
+ }
899
+ const hostname = parseHostname(parsed);
900
+ if (!opts?.allowPrivateHosts && isPrivateOrMetadataHost(hostname)) {
901
+ throw new Error(`Unsafe LRS URL: private or metadata host "${hostname}" is not allowed`);
902
+ }
456
903
  }
457
904
 
458
905
  // src/fetchTransport.ts
@@ -522,6 +969,7 @@ async function postWithRetry(post, retries, initialBackoffMs, maxBackoffMs) {
522
969
  }
523
970
  }
524
971
  function createFetchTransport(opts) {
972
+ assertSafeLrsUrl(opts.url, { allowPrivateHosts: opts.allowPrivateHosts });
525
973
  const timeoutMs = opts.timeoutMs ?? 3e4;
526
974
  const rawRetries = opts.retries ?? 2;
527
975
  const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
@@ -553,14 +1001,13 @@ function createFetchTransport(opts) {
553
1001
  }
554
1002
  };
555
1003
  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
- }
1004
+ return postStatement(opts.url, statement, {
1005
+ ...opts.init,
1006
+ headers: resolveHeaders(opts.headers),
1007
+ keepalive: true
1008
+ }).catch(() => {
1009
+ throw new Error("xAPI keepalive delivery failed");
1010
+ });
564
1011
  };
565
1012
  const abortInFlight = (statementId) => {
566
1013
  activeControllers.get(statementId)?.abort();
@@ -569,6 +1016,7 @@ function createFetchTransport(opts) {
569
1016
  return { transport, exitTransport, abortInFlight };
570
1017
  }
571
1018
  function createFetchBatchSink(opts) {
1019
+ assertSafeLrsUrl(opts.url, { allowPrivateHosts: opts.allowPrivateHosts });
572
1020
  const timeoutMs = opts.timeoutMs ?? 3e4;
573
1021
  const rawRetries = opts.retries ?? 2;
574
1022
  const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
@@ -592,26 +1040,31 @@ function createFetchBatchSink(opts) {
592
1040
  return {
593
1041
  batchSink: (events) => postBatch(events, opts.init ?? {}),
594
1042
  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
- }
1043
+ return fetch(opts.url, {
1044
+ method: "POST",
1045
+ body: JSON.stringify(events),
1046
+ ...opts.init,
1047
+ headers: resolveHeaders(opts.headers),
1048
+ keepalive: true
1049
+ }).then((res) => {
1050
+ if (!res.ok) {
1051
+ throw new FetchHttpError(res.status, res.statusText, "batch");
1052
+ }
1053
+ });
605
1054
  }
606
1055
  };
607
1056
  }
608
1057
  export {
609
1058
  FetchHttpError,
1059
+ assertSafeLrsUrl,
610
1060
  createFetchBatchSink,
611
1061
  createFetchTransport,
612
1062
  createInMemoryXAPIQueue,
613
1063
  createXAPIClient,
614
1064
  isRetryableFetchError,
615
1065
  isRetryableFetchHttpStatus,
1066
+ loadDeadLetterStatements,
1067
+ persistDeadLetterStatement,
1068
+ resetXAPIDeadLetterForTests,
616
1069
  telemetryEventToXAPIStatement
617
1070
  };