@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.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,26 @@ 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) {
104
221
  buffer.splice(1, 1);
222
+ opts?.onCap?.();
223
+ } else {
224
+ opts?.onCap?.();
225
+ opts?.onOverflow?.(normalized);
226
+ return;
105
227
  }
106
228
  } else {
107
229
  buffer.shift();
230
+ opts?.onCap?.();
108
231
  }
109
- opts?.onCap?.();
110
232
  }
111
233
  buffer.push(normalized);
112
234
  notifyDepth();
@@ -138,6 +260,60 @@ function createInMemoryXAPIQueue(opts) {
138
260
  // src/client.ts
139
261
  var import_core2 = require("@lessonkit/core");
140
262
 
263
+ // src/deadLetter.ts
264
+ var STORAGE_KEY = "lk-xapi-dead-letter";
265
+ var MAX_DEAD_LETTER = 200;
266
+ function readStorage() {
267
+ try {
268
+ const storage = globalThis.sessionStorage;
269
+ return storage ?? null;
270
+ } catch {
271
+ return null;
272
+ }
273
+ }
274
+ function loadDeadLetterStatements() {
275
+ const storage = readStorage();
276
+ if (!storage) return [];
277
+ try {
278
+ const raw = storage.getItem(STORAGE_KEY);
279
+ if (!raw) return [];
280
+ const parsed = JSON.parse(raw);
281
+ if (!Array.isArray(parsed)) return [];
282
+ return parsed.filter(
283
+ (item) => typeof item === "object" && item !== null && typeof item.id === "string"
284
+ );
285
+ } catch {
286
+ return [];
287
+ }
288
+ }
289
+ function persistDeadLetterStatement(statement) {
290
+ const storage = readStorage();
291
+ if (!storage) return;
292
+ try {
293
+ const existing = loadDeadLetterStatements();
294
+ if (existing.some((s) => s.id === statement.id)) return;
295
+ const next = [...existing, statement].slice(-MAX_DEAD_LETTER);
296
+ storage.setItem(STORAGE_KEY, JSON.stringify(next));
297
+ } catch {
298
+ }
299
+ }
300
+ function removeDeadLetterStatement(id) {
301
+ const storage = readStorage();
302
+ if (!storage) return;
303
+ try {
304
+ const next = loadDeadLetterStatements().filter((s) => s.id !== id);
305
+ if (next.length === 0) {
306
+ storage.removeItem(STORAGE_KEY);
307
+ } else {
308
+ storage.setItem(STORAGE_KEY, JSON.stringify(next));
309
+ }
310
+ } catch {
311
+ }
312
+ }
313
+ function clearDeadLetterStorage() {
314
+ readStorage()?.removeItem(STORAGE_KEY);
315
+ }
316
+
141
317
  // src/telemetryMap.ts
142
318
  var import_core = require("@lessonkit/core");
143
319
 
@@ -174,9 +350,9 @@ function buildXapiScoreResult(opts) {
174
350
  }
175
351
  return result;
176
352
  }
177
- function statementFor(objectId, verb, timestamp, extra) {
353
+ function statementFor(event, objectId, verb, timestamp, extra) {
178
354
  return {
179
- id: cryptoRandomId(),
355
+ id: deriveStatementId(event, objectId, verb),
180
356
  timestamp,
181
357
  verb,
182
358
  object: { id: objectId },
@@ -184,8 +360,21 @@ function statementFor(objectId, verb, timestamp, extra) {
184
360
  context: extra?.context
185
361
  };
186
362
  }
187
- function experiencedBlockStatement(courseId, lessonId, blockId, timestamp) {
363
+ function sanitizeTelemetryEmbedSrc(src) {
364
+ try {
365
+ const url = new URL(src);
366
+ url.username = "";
367
+ url.password = "";
368
+ url.search = "";
369
+ url.hash = "";
370
+ return `${url.origin}${url.pathname}`;
371
+ } catch {
372
+ return src;
373
+ }
374
+ }
375
+ function experiencedBlockStatement(event, courseId, lessonId, blockId, timestamp) {
188
376
  return statementFor(
377
+ event,
189
378
  (0, import_core.buildLessonkitUrn)({ courseId, lessonId, blockId }),
190
379
  XAPIVerbs.experienced,
191
380
  timestamp
@@ -196,20 +385,39 @@ var experiencedBlockMapper = (event, ctx) => {
196
385
  const lessonId2 = event.lessonId;
197
386
  const blockId2 = event.data?.blockId;
198
387
  if (!lessonId2 || !blockId2 || typeof blockId2 !== "string") return null;
199
- return experiencedBlockStatement(ctx.courseId, lessonId2, blockId2, ctx.timestamp);
388
+ const kind = event.data?.kind;
389
+ const extensions = {};
390
+ if (kind === "embed_viewed" || kind === "chart_viewed") {
391
+ extensions["https://lessonkit.dev/xapi/interactionKind"] = kind;
392
+ const data = event.data;
393
+ if (kind === "embed_viewed" && data && typeof data.src === "string") {
394
+ extensions["https://lessonkit.dev/xapi/embedSrc"] = sanitizeTelemetryEmbedSrc(data.src);
395
+ }
396
+ if (kind === "chart_viewed" && data && typeof data.chartType === "string") {
397
+ extensions["https://lessonkit.dev/xapi/chartType"] = data.chartType;
398
+ }
399
+ }
400
+ return statementFor(
401
+ event,
402
+ (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: lessonId2, blockId: blockId2 }),
403
+ XAPIVerbs.experienced,
404
+ ctx.timestamp,
405
+ Object.keys(extensions).length > 0 ? { context: { extensions } } : void 0
406
+ );
200
407
  }
201
408
  const lessonId = event.lessonId;
202
409
  const blockId = "data" in event && event.data && "blockId" in event.data ? event.data.blockId : void 0;
203
410
  if (!lessonId || !blockId || typeof blockId !== "string") return null;
204
- return experiencedBlockStatement(ctx.courseId, lessonId, blockId, ctx.timestamp);
411
+ return experiencedBlockStatement(event, ctx.courseId, lessonId, blockId, ctx.timestamp);
205
412
  };
206
413
  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),
414
+ course_started: (event, ctx) => statementFor(event, (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId }), XAPIVerbs.initialized, ctx.timestamp),
415
+ course_completed: (event, ctx) => statementFor(event, (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId }), XAPIVerbs.completed, ctx.timestamp),
209
416
  lesson_started: (event, ctx) => {
210
417
  const lessonId = event.name === "lesson_started" ? event.lessonId : void 0;
211
418
  if (!lessonId) return null;
212
419
  return statementFor(
420
+ event,
213
421
  (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId }),
214
422
  XAPIVerbs.initialized,
215
423
  ctx.timestamp
@@ -227,9 +435,15 @@ var TELEMETRY_XAPI_MAPPERS = {
227
435
  if (typeof data?.success === "boolean") result.success = data.success;
228
436
  const score = buildXapiScoreResult({ score: data?.score, maxScore: data?.maxScore });
229
437
  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
- });
438
+ return statementFor(
439
+ event,
440
+ (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId }),
441
+ XAPIVerbs.completed,
442
+ ctx.timestamp,
443
+ {
444
+ result: Object.keys(result).length ? result : void 0
445
+ }
446
+ );
233
447
  },
234
448
  lesson_time_on_task: () => null,
235
449
  quiz_answered: (event, ctx) => {
@@ -237,6 +451,7 @@ var TELEMETRY_XAPI_MAPPERS = {
237
451
  const result = {};
238
452
  if (typeof event.data.correct === "boolean") result.success = event.data.correct;
239
453
  return statementFor(
454
+ event,
240
455
  (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
241
456
  XAPIVerbs.answered,
242
457
  ctx.timestamp,
@@ -247,6 +462,7 @@ var TELEMETRY_XAPI_MAPPERS = {
247
462
  if (event.name !== "quiz_completed") return null;
248
463
  const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
249
464
  return statementFor(
465
+ event,
250
466
  (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
251
467
  XAPIVerbs.completed,
252
468
  ctx.timestamp,
@@ -258,6 +474,7 @@ var TELEMETRY_XAPI_MAPPERS = {
258
474
  const result = {};
259
475
  if (typeof event.data.correct === "boolean") result.success = event.data.correct;
260
476
  return statementFor(
477
+ event,
261
478
  (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
262
479
  XAPIVerbs.answered,
263
480
  ctx.timestamp,
@@ -268,6 +485,7 @@ var TELEMETRY_XAPI_MAPPERS = {
268
485
  if (event.name !== "assessment_completed") return null;
269
486
  const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
270
487
  return statementFor(
488
+ event,
271
489
  (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
272
490
  XAPIVerbs.completed,
273
491
  ctx.timestamp,
@@ -289,6 +507,7 @@ var TELEMETRY_XAPI_MAPPERS = {
289
507
  const blockId = event.data.blockId;
290
508
  if (!lessonId || !blockId) return null;
291
509
  return statementFor(
510
+ event,
292
511
  (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId }),
293
512
  XAPIVerbs.completed,
294
513
  ctx.timestamp
@@ -303,20 +522,48 @@ var TELEMETRY_XAPI_MAPPERS = {
303
522
  const blockId = event.data.blockId;
304
523
  if (!lessonId || !blockId) return null;
305
524
  return statementFor(
525
+ event,
306
526
  (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId }),
307
527
  XAPIVerbs.completed,
308
528
  ctx.timestamp
309
529
  );
530
+ },
531
+ branch_node_viewed: (event, ctx) => {
532
+ if (event.name !== "branch_node_viewed") return null;
533
+ const lessonId = event.lessonId;
534
+ const blockId = event.data.blockId;
535
+ const nodeId = event.data.nodeId;
536
+ if (!lessonId || !blockId || !nodeId) return null;
537
+ return statementFor(
538
+ event,
539
+ (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId, nodeId }),
540
+ XAPIVerbs.experienced,
541
+ ctx.timestamp
542
+ );
543
+ },
544
+ branch_selected: (event, ctx) => {
545
+ if (event.name !== "branch_selected") return null;
546
+ const lessonId = event.lessonId;
547
+ const blockId = event.data.blockId;
548
+ const toNodeId = event.data.toNodeId;
549
+ if (!lessonId || !blockId || !toNodeId) return null;
550
+ return statementFor(
551
+ event,
552
+ (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId, nodeId: toNodeId }),
553
+ XAPIVerbs.experienced,
554
+ ctx.timestamp
555
+ );
310
556
  }
311
557
  };
312
558
  function telemetryEventToXAPIStatement(event) {
313
- const mapper = TELEMETRY_XAPI_MAPPERS[event.name];
559
+ const enriched = enrichTelemetryEventForXapi(event);
560
+ const mapper = TELEMETRY_XAPI_MAPPERS[enriched.name];
314
561
  if (!mapper) {
315
- throw new Error(`Unhandled telemetry event: ${event.name}`);
562
+ throw new Error(`Unhandled telemetry event: ${enriched.name}`);
316
563
  }
317
- return mapper(event, {
318
- courseId: event.courseId,
319
- timestamp: event.timestamp
564
+ return mapper(enriched, {
565
+ courseId: enriched.courseId,
566
+ timestamp: enriched.timestamp
320
567
  });
321
568
  }
322
569
 
@@ -334,26 +581,86 @@ function isDevEnvironment() {
334
581
  const g = globalThis;
335
582
  return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
336
583
  }
584
+ function defaultQueueCapHandler() {
585
+ if (isDevEnvironment()) {
586
+ console.warn("[lessonkit] xAPI queue reached capacity; oldest statement(s) dropped.");
587
+ }
588
+ }
589
+ function defaultHeadSkippedHandler(_statement, err) {
590
+ if (isDevEnvironment()) {
591
+ console.warn(
592
+ "[lessonkit] xAPI queue skipped statement after repeated transport failures:",
593
+ err instanceof Error ? err.message : err
594
+ );
595
+ }
596
+ }
337
597
  function createXAPIClient(opts) {
338
598
  const transport = opts?.transport;
339
599
  const exitTransport = opts?.exitTransport;
340
600
  const courseId = opts?.courseId;
341
601
  const queue = opts?.queue ?? createInMemoryXAPIQueue({
342
602
  maxSize: opts?.maxQueueSize,
603
+ maxHeadFailures: opts?.maxHeadFailures,
343
604
  onDepth: opts?.onQueueDepth,
344
- onCap: opts?.onQueueCap
605
+ onCap: opts?.onQueueCap ?? defaultQueueCapHandler,
606
+ onOverflow: (statement) => {
607
+ persistDeadLetterStatement(statement);
608
+ },
609
+ onHeadSkipped: (statement, err) => {
610
+ persistDeadLetterStatement(statement);
611
+ (opts?.onHeadSkipped ?? defaultHeadSkippedHandler)(statement, err);
612
+ }
345
613
  });
346
614
  let warnedNoTransport = false;
347
615
  let warnedTransportFailure = false;
348
616
  const inflightById = /* @__PURE__ */ new Map();
349
617
  const inflightStatements = /* @__PURE__ */ new Map();
618
+ const pendingReplacement = /* @__PURE__ */ new Map();
619
+ const inflightPayload = /* @__PURE__ */ new Map();
620
+ const replacementWatcher = /* @__PURE__ */ new Set();
350
621
  const exitDeliveredIds = /* @__PURE__ */ new Set();
351
622
  const exitNetworkSentIds = /* @__PURE__ */ new Set();
623
+ const exitHandoffIds = /* @__PURE__ */ new Set();
624
+ let activeFlush = null;
625
+ for (const statement of loadDeadLetterStatements()) {
626
+ queue.enqueue(statement);
627
+ }
628
+ const hadDeadLetters = queue.size() > 0;
352
629
  const deliveryTransport = transport ? async (statement) => {
353
630
  if (exitNetworkSentIds.has(statement.id)) return;
354
631
  await transport(statement);
632
+ removeDeadLetterStatement(statement.id);
355
633
  } : void 0;
356
- const sendOrQueue = (statement) => {
634
+ const markExitDelivered = (statement) => {
635
+ exitHandoffIds.delete(statement.id);
636
+ exitDeliveredIds.add(statement.id);
637
+ exitNetworkSentIds.add(statement.id);
638
+ removeDeadLetterStatement(statement.id);
639
+ };
640
+ const dispatchExitStatement = (statement) => {
641
+ if (exitDeliveredIds.has(statement.id)) return;
642
+ exitHandoffIds.add(statement.id);
643
+ try {
644
+ const result = exitTransport(statement);
645
+ if (result != null && typeof result.then === "function") {
646
+ void result.then(
647
+ () => markExitDelivered(statement),
648
+ () => {
649
+ exitHandoffIds.delete(statement.id);
650
+ persistDeadLetterStatement(statement);
651
+ }
652
+ );
653
+ } else {
654
+ markExitDelivered(statement);
655
+ }
656
+ } catch {
657
+ exitHandoffIds.delete(statement.id);
658
+ persistDeadLetterStatement(statement);
659
+ }
660
+ };
661
+ const pendingDuringFlush = [];
662
+ let flushInProgress = false;
663
+ const sendOrQueueInternal = (statement) => {
357
664
  const normalized = withStatementId2(statement);
358
665
  if (exitDeliveredIds.has(normalized.id)) return;
359
666
  if (!deliveryTransport) {
@@ -368,20 +675,38 @@ function createXAPIClient(opts) {
368
675
  }
369
676
  const existing = inflightById.get(normalized.id);
370
677
  if (existing) {
371
- void existing.then(
372
- () => void 0,
373
- () => {
374
- sendOrQueue(normalized);
375
- }
376
- );
678
+ pendingReplacement.set(normalized.id, normalized);
679
+ inflightStatements.set(normalized.id, normalized);
680
+ if (!replacementWatcher.has(normalized.id)) {
681
+ replacementWatcher.add(normalized.id);
682
+ void existing.then(
683
+ () => {
684
+ replacementWatcher.delete(normalized.id);
685
+ const replacement = pendingReplacement.get(normalized.id);
686
+ const transported = inflightPayload.get(normalized.id);
687
+ pendingReplacement.delete(normalized.id);
688
+ inflightPayload.delete(normalized.id);
689
+ if (replacement && replacement !== transported) {
690
+ sendOrQueueInternal(replacement);
691
+ }
692
+ },
693
+ () => {
694
+ replacementWatcher.delete(normalized.id);
695
+ const replacement = pendingReplacement.get(normalized.id) ?? normalized;
696
+ pendingReplacement.delete(normalized.id);
697
+ sendOrQueueInternal(replacement);
698
+ }
699
+ );
700
+ }
377
701
  return;
378
702
  }
379
703
  inflightStatements.set(normalized.id, normalized);
704
+ inflightPayload.set(normalized.id, normalized);
380
705
  const flight = Promise.resolve().then(async () => {
381
706
  await deliveryTransport(normalized);
382
707
  queue.removeById(normalized.id);
383
708
  }).catch((err) => {
384
- if (exitDeliveredIds.has(normalized.id)) return;
709
+ if (exitDeliveredIds.has(normalized.id) || exitHandoffIds.has(normalized.id)) return;
385
710
  queue.enqueue(normalized);
386
711
  opts?.onTransportError?.(err);
387
712
  if (isDevEnvironment() && !warnedTransportFailure) {
@@ -394,17 +719,28 @@ function createXAPIClient(opts) {
394
719
  }).finally(() => {
395
720
  inflightById.delete(normalized.id);
396
721
  inflightStatements.delete(normalized.id);
722
+ if (!replacementWatcher.has(normalized.id)) {
723
+ inflightPayload.delete(normalized.id);
724
+ }
397
725
  });
398
726
  inflightById.set(normalized.id, flight);
399
727
  void flight.catch(() => {
400
728
  });
401
729
  };
730
+ const sendOrQueue = (statement) => {
731
+ if (flushInProgress) {
732
+ pendingDuringFlush.push(statement);
733
+ return;
734
+ }
735
+ sendOrQueueInternal(statement);
736
+ };
402
737
  const emit = (event) => {
403
738
  try {
404
739
  const statement = telemetryEventToXAPIStatement(event);
405
740
  if (!statement) return;
406
741
  sendOrQueue(statement);
407
742
  } catch (err) {
743
+ opts?.onMappingError?.(err);
408
744
  if (isDevEnvironment()) {
409
745
  console.warn(
410
746
  "[lessonkit] xAPI mapping skipped:",
@@ -413,42 +749,66 @@ function createXAPIClient(opts) {
413
749
  }
414
750
  }
415
751
  };
416
- return {
752
+ const runFlushLoop = async () => {
753
+ if (!deliveryTransport) return;
754
+ for (; ; ) {
755
+ await queue.flush(deliveryTransport);
756
+ const flights = [...inflightById.values()];
757
+ if (flights.length > 0) {
758
+ await Promise.all(flights);
759
+ }
760
+ if (queue.size() === 0 && inflightById.size === 0) break;
761
+ }
762
+ };
763
+ const client = {
417
764
  send: (statement) => {
418
765
  sendOrQueue(statement);
419
766
  },
420
767
  queueSize: () => queue.size(),
421
768
  flush: async () => {
422
769
  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");
770
+ for (; ; ) {
771
+ if (activeFlush) {
772
+ await activeFlush;
773
+ } else {
774
+ flushInProgress = true;
775
+ activeFlush = (async () => {
776
+ try {
777
+ await runFlushLoop();
778
+ while (pendingDuringFlush.length > 0) {
779
+ const batch = pendingDuringFlush.splice(0, pendingDuringFlush.length);
780
+ for (const pending of batch) {
781
+ sendOrQueueInternal(pending);
782
+ }
783
+ await runFlushLoop();
784
+ }
785
+ } finally {
786
+ flushInProgress = false;
787
+ }
788
+ })().finally(() => {
789
+ activeFlush = null;
790
+ });
791
+ await activeFlush;
792
+ }
793
+ if (queue.size() === 0 && inflightById.size === 0) break;
430
794
  }
431
795
  },
432
796
  flushOnExit: exitTransport ? () => {
433
797
  const headId = queue.getHeadInFlightId?.();
434
798
  if (headId) {
435
- exitNetworkSentIds.add(headId);
436
- exitDeliveredIds.add(headId);
437
799
  opts.abortInFlight?.(headId);
800
+ const headStatement = inflightStatements.get(headId);
801
+ if (headStatement) {
802
+ dispatchExitStatement(headStatement);
803
+ }
438
804
  }
439
805
  for (const statement of inflightStatements.values()) {
440
- exitNetworkSentIds.add(statement.id);
441
- exitDeliveredIds.add(statement.id);
806
+ if (statement.id === headId) continue;
442
807
  opts.abortInFlight?.(statement.id);
808
+ dispatchExitStatement(statement);
443
809
  }
444
810
  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
- }
811
+ dispatchExitStatement(statement);
452
812
  });
453
813
  } : void 0,
454
814
  startedLesson: ({ lessonId }) => {
@@ -486,6 +846,98 @@ function createXAPIClient(opts) {
486
846
  });
487
847
  }
488
848
  };
849
+ if (hadDeadLetters && deliveryTransport) {
850
+ queueMicrotask(() => {
851
+ void client.flush().catch(() => void 0);
852
+ });
853
+ }
854
+ return client;
855
+ }
856
+ function resetXAPIDeadLetterForTests() {
857
+ clearDeadLetterStorage();
858
+ }
859
+
860
+ // src/safeLrsUrl.ts
861
+ var import_meta = {};
862
+ function isProductionRuntime() {
863
+ try {
864
+ if (import_meta.env?.PROD === true) return true;
865
+ } catch {
866
+ }
867
+ const g = globalThis;
868
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production";
869
+ }
870
+ function parseHostname(url) {
871
+ return url.hostname.replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
872
+ }
873
+ function isIpv4MappedAddress(hostname) {
874
+ const match = hostname.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
875
+ return match?.[1] ?? null;
876
+ }
877
+ function isLoopbackHost(hostname) {
878
+ const ipv4Mapped = isIpv4MappedAddress(hostname);
879
+ if (ipv4Mapped) return isLoopbackHost(ipv4Mapped);
880
+ return hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "127.0.0.1" || hostname === "::1" || hostname === "0.0.0.0";
881
+ }
882
+ function isLinkLocalOrMetadataHost(hostname) {
883
+ if (hostname === "169.254.169.254") return true;
884
+ if (/^169\.254\./.test(hostname)) return true;
885
+ if (/^fe80:/i.test(hostname)) return true;
886
+ return false;
887
+ }
888
+ function isRfc1918Host(hostname) {
889
+ const ipv4Mapped = isIpv4MappedAddress(hostname);
890
+ if (ipv4Mapped) return isRfc1918Host(ipv4Mapped);
891
+ if (/^10\./.test(hostname)) return true;
892
+ if (/^192\.168\./.test(hostname)) return true;
893
+ const parts = hostname.split(".").map(Number);
894
+ if (parts.length === 4 && parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
895
+ return false;
896
+ }
897
+ function isPrivateOrMetadataHost(hostname) {
898
+ return isLoopbackHost(hostname) || isLinkLocalOrMetadataHost(hostname) || isRfc1918Host(hostname);
899
+ }
900
+ function containsPathTraversal(path) {
901
+ if (path.includes("..")) return true;
902
+ let decoded = path;
903
+ for (let i = 0; i < 2; i++) {
904
+ try {
905
+ const next = decodeURIComponent(decoded.replace(/\+/g, " "));
906
+ if (next.includes("..")) return true;
907
+ if (next === decoded) break;
908
+ decoded = next;
909
+ } catch {
910
+ break;
911
+ }
912
+ }
913
+ return false;
914
+ }
915
+ function assertSafeLrsUrl(url, opts) {
916
+ if (url.startsWith("/")) {
917
+ if (containsPathTraversal(url)) {
918
+ throw new Error(`Unsafe LRS URL: path traversal in "${url}"`);
919
+ }
920
+ return;
921
+ }
922
+ let parsed;
923
+ try {
924
+ parsed = new URL(url);
925
+ } catch {
926
+ throw new Error(`Unsafe LRS URL: invalid URL "${url}"`);
927
+ }
928
+ if (containsPathTraversal(parsed.pathname)) {
929
+ throw new Error(`Unsafe LRS URL: path traversal in "${url}"`);
930
+ }
931
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
932
+ throw new Error(`Unsafe LRS URL: unsupported scheme "${parsed.protocol}"`);
933
+ }
934
+ if (isProductionRuntime() && parsed.protocol !== "https:") {
935
+ throw new Error("Unsafe LRS URL: HTTPS is required in production builds");
936
+ }
937
+ const hostname = parseHostname(parsed);
938
+ if (!opts?.allowPrivateHosts && isPrivateOrMetadataHost(hostname)) {
939
+ throw new Error(`Unsafe LRS URL: private or metadata host "${hostname}" is not allowed`);
940
+ }
489
941
  }
490
942
 
491
943
  // src/fetchTransport.ts
@@ -555,6 +1007,7 @@ async function postWithRetry(post, retries, initialBackoffMs, maxBackoffMs) {
555
1007
  }
556
1008
  }
557
1009
  function createFetchTransport(opts) {
1010
+ assertSafeLrsUrl(opts.url, { allowPrivateHosts: opts.allowPrivateHosts });
558
1011
  const timeoutMs = opts.timeoutMs ?? 3e4;
559
1012
  const rawRetries = opts.retries ?? 2;
560
1013
  const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
@@ -586,14 +1039,13 @@ function createFetchTransport(opts) {
586
1039
  }
587
1040
  };
588
1041
  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
- }
1042
+ return postStatement(opts.url, statement, {
1043
+ ...opts.init,
1044
+ headers: resolveHeaders(opts.headers),
1045
+ keepalive: true
1046
+ }).catch(() => {
1047
+ throw new Error("xAPI keepalive delivery failed");
1048
+ });
597
1049
  };
598
1050
  const abortInFlight = (statementId) => {
599
1051
  activeControllers.get(statementId)?.abort();
@@ -602,6 +1054,7 @@ function createFetchTransport(opts) {
602
1054
  return { transport, exitTransport, abortInFlight };
603
1055
  }
604
1056
  function createFetchBatchSink(opts) {
1057
+ assertSafeLrsUrl(opts.url, { allowPrivateHosts: opts.allowPrivateHosts });
605
1058
  const timeoutMs = opts.timeoutMs ?? 3e4;
606
1059
  const rawRetries = opts.retries ?? 2;
607
1060
  const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
@@ -625,27 +1078,32 @@ function createFetchBatchSink(opts) {
625
1078
  return {
626
1079
  batchSink: (events) => postBatch(events, opts.init ?? {}),
627
1080
  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
- }
1081
+ return fetch(opts.url, {
1082
+ method: "POST",
1083
+ body: JSON.stringify(events),
1084
+ ...opts.init,
1085
+ headers: resolveHeaders(opts.headers),
1086
+ keepalive: true
1087
+ }).then((res) => {
1088
+ if (!res.ok) {
1089
+ throw new FetchHttpError(res.status, res.statusText, "batch");
1090
+ }
1091
+ });
638
1092
  }
639
1093
  };
640
1094
  }
641
1095
  // Annotate the CommonJS export names for ESM import in node:
642
1096
  0 && (module.exports = {
643
1097
  FetchHttpError,
1098
+ assertSafeLrsUrl,
644
1099
  createFetchBatchSink,
645
1100
  createFetchTransport,
646
1101
  createInMemoryXAPIQueue,
647
1102
  createXAPIClient,
648
1103
  isRetryableFetchError,
649
1104
  isRetryableFetchHttpStatus,
1105
+ loadDeadLetterStatements,
1106
+ persistDeadLetterStatement,
1107
+ resetXAPIDeadLetterForTests,
650
1108
  telemetryEventToXAPIStatement
651
1109
  });