@lessonkit/xapi 1.3.1 → 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
@@ -16,11 +124,15 @@ function withStatementId(statement) {
16
124
  return statement;
17
125
  }
18
126
  var DEFAULT_MAX_QUEUE_SIZE = 1e3;
127
+ var DEFAULT_MAX_HEAD_FAILURES = 10;
19
128
  function createInMemoryXAPIQueue(opts) {
20
129
  const maxSize = opts?.maxSize ?? DEFAULT_MAX_QUEUE_SIZE;
130
+ const maxHeadFailures = opts?.maxHeadFailures ?? DEFAULT_MAX_HEAD_FAILURES;
21
131
  const buffer = [];
22
132
  let flushInFlight = null;
23
133
  let headInFlight = false;
134
+ let headInFlightId;
135
+ let headFailureCount = 0;
24
136
  const notifyDepth = () => {
25
137
  opts?.onDepth?.(buffer.length);
26
138
  };
@@ -35,32 +147,51 @@ function createInMemoryXAPIQueue(opts) {
35
147
  while (buffer.length) {
36
148
  const statement = buffer[0];
37
149
  headInFlight = true;
150
+ headInFlightId = statement.id;
38
151
  try {
39
152
  await transport(statement);
40
153
  buffer.shift();
154
+ headFailureCount = 0;
41
155
  notifyDepth();
42
- } catch {
43
- return;
156
+ } catch (err) {
157
+ headFailureCount += 1;
158
+ if (headFailureCount >= maxHeadFailures) {
159
+ buffer.shift();
160
+ headFailureCount = 0;
161
+ notifyDepth();
162
+ opts?.onHeadSkipped?.(statement, err);
163
+ continue;
164
+ }
165
+ throw err;
44
166
  } finally {
45
167
  headInFlight = false;
168
+ headInFlightId = void 0;
46
169
  }
47
170
  }
48
171
  };
49
172
  return {
50
173
  enqueue: (statement) => {
51
174
  const normalized = withStatementId(statement);
52
- 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
+ }
53
181
  if (buffer.length >= maxSize) {
54
- if (headInFlight && buffer.length <= 1) {
55
- opts?.onCap?.();
56
- return;
57
- }
58
182
  if (headInFlight) {
59
- buffer.splice(1, 1);
183
+ if (buffer.length > 1) {
184
+ buffer.splice(1, 1);
185
+ opts?.onCap?.();
186
+ } else {
187
+ opts?.onCap?.();
188
+ opts?.onOverflow?.(normalized);
189
+ return;
190
+ }
60
191
  } else {
61
192
  buffer.shift();
193
+ opts?.onCap?.();
62
194
  }
63
- opts?.onCap?.();
64
195
  }
65
196
  buffer.push(normalized);
66
197
  notifyDepth();
@@ -76,27 +207,76 @@ function createInMemoryXAPIQueue(opts) {
76
207
  return flushInFlight;
77
208
  },
78
209
  flushOnExit: (exitTransport) => {
79
- const startIdx = headInFlight && buffer.length > 0 ? 1 : 0;
80
- for (let i = startIdx; i < buffer.length; i++) {
81
- const statement = buffer[i];
210
+ for (const statement of buffer) {
82
211
  try {
83
212
  exitTransport(statement);
84
213
  } catch {
85
214
  }
86
215
  }
87
- if (startIdx === 0) {
88
- buffer.length = 0;
89
- } else if (buffer.length > 1) {
90
- buffer.splice(1);
91
- }
216
+ buffer.length = 0;
92
217
  notifyDepth();
93
- }
218
+ },
219
+ getHeadInFlightId: () => headInFlightId
94
220
  };
95
221
  }
96
222
 
97
223
  // src/client.ts
98
224
  import { nowIso } from "@lessonkit/core";
99
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
+
100
280
  // src/telemetryMap.ts
101
281
  import { buildLessonkitUrn } from "@lessonkit/core";
102
282
 
@@ -133,9 +313,9 @@ function buildXapiScoreResult(opts) {
133
313
  }
134
314
  return result;
135
315
  }
136
- function statementFor(objectId, verb, timestamp, extra) {
316
+ function statementFor(event, objectId, verb, timestamp, extra) {
137
317
  return {
138
- id: cryptoRandomId(),
318
+ id: deriveStatementId(event, objectId, verb),
139
319
  timestamp,
140
320
  verb,
141
321
  object: { id: objectId },
@@ -143,8 +323,21 @@ function statementFor(objectId, verb, timestamp, extra) {
143
323
  context: extra?.context
144
324
  };
145
325
  }
146
- 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) {
147
339
  return statementFor(
340
+ event,
148
341
  buildLessonkitUrn({ courseId, lessonId, blockId }),
149
342
  XAPIVerbs.experienced,
150
343
  timestamp
@@ -155,20 +348,39 @@ var experiencedBlockMapper = (event, ctx) => {
155
348
  const lessonId2 = event.lessonId;
156
349
  const blockId2 = event.data?.blockId;
157
350
  if (!lessonId2 || !blockId2 || typeof blockId2 !== "string") return null;
158
- 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
+ );
159
370
  }
160
371
  const lessonId = event.lessonId;
161
372
  const blockId = "data" in event && event.data && "blockId" in event.data ? event.data.blockId : void 0;
162
373
  if (!lessonId || !blockId || typeof blockId !== "string") return null;
163
- return experiencedBlockStatement(ctx.courseId, lessonId, blockId, ctx.timestamp);
374
+ return experiencedBlockStatement(event, ctx.courseId, lessonId, blockId, ctx.timestamp);
164
375
  };
165
376
  var TELEMETRY_XAPI_MAPPERS = {
166
- course_started: (_event, ctx) => statementFor(buildLessonkitUrn({ courseId: ctx.courseId }), XAPIVerbs.initialized, ctx.timestamp),
167
- 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),
168
379
  lesson_started: (event, ctx) => {
169
380
  const lessonId = event.name === "lesson_started" ? event.lessonId : void 0;
170
381
  if (!lessonId) return null;
171
382
  return statementFor(
383
+ event,
172
384
  buildLessonkitUrn({ courseId: ctx.courseId, lessonId }),
173
385
  XAPIVerbs.initialized,
174
386
  ctx.timestamp
@@ -186,9 +398,15 @@ var TELEMETRY_XAPI_MAPPERS = {
186
398
  if (typeof data?.success === "boolean") result.success = data.success;
187
399
  const score = buildXapiScoreResult({ score: data?.score, maxScore: data?.maxScore });
188
400
  if (score) result.score = score;
189
- return statementFor(buildLessonkitUrn({ courseId: ctx.courseId, lessonId }), XAPIVerbs.completed, ctx.timestamp, {
190
- result: Object.keys(result).length ? result : void 0
191
- });
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
+ );
192
410
  },
193
411
  lesson_time_on_task: () => null,
194
412
  quiz_answered: (event, ctx) => {
@@ -196,6 +414,7 @@ var TELEMETRY_XAPI_MAPPERS = {
196
414
  const result = {};
197
415
  if (typeof event.data.correct === "boolean") result.success = event.data.correct;
198
416
  return statementFor(
417
+ event,
199
418
  buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
200
419
  XAPIVerbs.answered,
201
420
  ctx.timestamp,
@@ -206,6 +425,7 @@ var TELEMETRY_XAPI_MAPPERS = {
206
425
  if (event.name !== "quiz_completed") return null;
207
426
  const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
208
427
  return statementFor(
428
+ event,
209
429
  buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
210
430
  XAPIVerbs.completed,
211
431
  ctx.timestamp,
@@ -217,6 +437,7 @@ var TELEMETRY_XAPI_MAPPERS = {
217
437
  const result = {};
218
438
  if (typeof event.data.correct === "boolean") result.success = event.data.correct;
219
439
  return statementFor(
440
+ event,
220
441
  buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
221
442
  XAPIVerbs.answered,
222
443
  ctx.timestamp,
@@ -227,6 +448,7 @@ var TELEMETRY_XAPI_MAPPERS = {
227
448
  if (event.name !== "assessment_completed") return null;
228
449
  const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
229
450
  return statementFor(
451
+ event,
230
452
  buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
231
453
  XAPIVerbs.completed,
232
454
  ctx.timestamp,
@@ -240,16 +462,71 @@ var TELEMETRY_XAPI_MAPPERS = {
240
462
  hotspot_opened: experiencedBlockMapper,
241
463
  accordion_section_toggled: experiencedBlockMapper,
242
464
  flashcard_flipped: experiencedBlockMapper,
243
- image_slider_changed: experiencedBlockMapper
465
+ image_slider_changed: experiencedBlockMapper,
466
+ video_cue_reached: experiencedBlockMapper,
467
+ video_segment_completed: (event, ctx) => {
468
+ if (event.name !== "video_segment_completed") return null;
469
+ const lessonId = event.lessonId;
470
+ const blockId = event.data.blockId;
471
+ if (!lessonId || !blockId) return null;
472
+ return statementFor(
473
+ event,
474
+ buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId }),
475
+ XAPIVerbs.completed,
476
+ ctx.timestamp
477
+ );
478
+ },
479
+ memory_card_flipped: experiencedBlockMapper,
480
+ information_wall_search: experiencedBlockMapper,
481
+ parallax_slide_viewed: experiencedBlockMapper,
482
+ questionnaire_submitted: (event, ctx) => {
483
+ if (event.name !== "questionnaire_submitted") return null;
484
+ const lessonId = event.lessonId;
485
+ const blockId = event.data.blockId;
486
+ if (!lessonId || !blockId) return null;
487
+ return statementFor(
488
+ event,
489
+ buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId }),
490
+ XAPIVerbs.completed,
491
+ ctx.timestamp
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
+ );
519
+ }
244
520
  };
245
521
  function telemetryEventToXAPIStatement(event) {
246
- const mapper = TELEMETRY_XAPI_MAPPERS[event.name];
522
+ const enriched = enrichTelemetryEventForXapi(event);
523
+ const mapper = TELEMETRY_XAPI_MAPPERS[enriched.name];
247
524
  if (!mapper) {
248
- throw new Error(`Unhandled telemetry event: ${event.name}`);
525
+ throw new Error(`Unhandled telemetry event: ${enriched.name}`);
249
526
  }
250
- return mapper(event, {
251
- courseId: event.courseId,
252
- timestamp: event.timestamp
527
+ return mapper(enriched, {
528
+ courseId: enriched.courseId,
529
+ timestamp: enriched.timestamp
253
530
  });
254
531
  }
255
532
 
@@ -267,22 +544,89 @@ function isDevEnvironment() {
267
544
  const g = globalThis;
268
545
  return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
269
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
+ }
270
560
  function createXAPIClient(opts) {
271
561
  const transport = opts?.transport;
272
562
  const exitTransport = opts?.exitTransport;
273
563
  const courseId = opts?.courseId;
274
564
  const queue = opts?.queue ?? createInMemoryXAPIQueue({
275
565
  maxSize: opts?.maxQueueSize,
566
+ maxHeadFailures: opts?.maxHeadFailures,
276
567
  onDepth: opts?.onQueueDepth,
277
- 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
+ }
278
576
  });
279
577
  let warnedNoTransport = false;
280
578
  let warnedTransportFailure = false;
281
579
  const inflightById = /* @__PURE__ */ new Map();
282
580
  const inflightStatements = /* @__PURE__ */ new Map();
283
- const sendOrQueue = (statement) => {
581
+ const pendingReplacement = /* @__PURE__ */ new Map();
582
+ const inflightPayload = /* @__PURE__ */ new Map();
583
+ const replacementWatcher = /* @__PURE__ */ new Set();
584
+ const exitDeliveredIds = /* @__PURE__ */ new Set();
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;
592
+ const deliveryTransport = transport ? async (statement) => {
593
+ if (exitNetworkSentIds.has(statement.id)) return;
594
+ await transport(statement);
595
+ removeDeadLetterStatement(statement.id);
596
+ } : void 0;
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) => {
284
627
  const normalized = withStatementId2(statement);
285
- if (!transport) {
628
+ if (exitDeliveredIds.has(normalized.id)) return;
629
+ if (!deliveryTransport) {
286
630
  queue.enqueue(normalized);
287
631
  if (isDevEnvironment() && !warnedNoTransport) {
288
632
  warnedNoTransport = true;
@@ -294,41 +638,72 @@ function createXAPIClient(opts) {
294
638
  }
295
639
  const existing = inflightById.get(normalized.id);
296
640
  if (existing) {
297
- void existing.then(
298
- () => void 0,
299
- () => {
300
- sendOrQueue(normalized);
301
- }
302
- );
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
+ }
303
664
  return;
304
665
  }
305
666
  inflightStatements.set(normalized.id, normalized);
667
+ inflightPayload.set(normalized.id, normalized);
306
668
  const flight = Promise.resolve().then(async () => {
307
- await transport(normalized);
669
+ await deliveryTransport(normalized);
308
670
  queue.removeById(normalized.id);
309
- }).catch(() => {
671
+ }).catch((err) => {
672
+ if (exitDeliveredIds.has(normalized.id) || exitHandoffIds.has(normalized.id)) return;
310
673
  queue.enqueue(normalized);
674
+ opts?.onTransportError?.(err);
311
675
  if (isDevEnvironment() && !warnedTransportFailure) {
312
676
  warnedTransportFailure = true;
313
677
  console.warn(
314
678
  "[lessonkit] xAPI transport failed; statement re-queued. Check your LRS endpoint or transport implementation."
315
679
  );
316
680
  }
317
- throw new Error("xAPI transport failed");
681
+ throw err instanceof Error ? err : new Error("xAPI transport failed", { cause: err });
318
682
  }).finally(() => {
319
683
  inflightById.delete(normalized.id);
320
684
  inflightStatements.delete(normalized.id);
685
+ if (!replacementWatcher.has(normalized.id)) {
686
+ inflightPayload.delete(normalized.id);
687
+ }
321
688
  });
322
689
  inflightById.set(normalized.id, flight);
323
690
  void flight.catch(() => {
324
691
  });
325
692
  };
693
+ const sendOrQueue = (statement) => {
694
+ if (flushInProgress) {
695
+ pendingDuringFlush.push(statement);
696
+ return;
697
+ }
698
+ sendOrQueueInternal(statement);
699
+ };
326
700
  const emit = (event) => {
327
701
  try {
328
702
  const statement = telemetryEventToXAPIStatement(event);
329
703
  if (!statement) return;
330
704
  sendOrQueue(statement);
331
705
  } catch (err) {
706
+ opts?.onMappingError?.(err);
332
707
  if (isDevEnvironment()) {
333
708
  console.warn(
334
709
  "[lessonkit] xAPI mapping skipped:",
@@ -337,33 +712,66 @@ function createXAPIClient(opts) {
337
712
  }
338
713
  }
339
714
  };
340
- 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 = {
341
727
  send: (statement) => {
342
728
  sendOrQueue(statement);
343
729
  },
344
730
  queueSize: () => queue.size(),
345
731
  flush: async () => {
346
- if (!transport) return;
347
- await queue.flush(transport);
348
- const flights = [...inflightById.values()];
349
- if (flights.length > 0) {
350
- await Promise.allSettled(flights);
732
+ if (!deliveryTransport) return;
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;
351
757
  }
352
758
  },
353
759
  flushOnExit: exitTransport ? () => {
354
- const exitSentIds = /* @__PURE__ */ new Set();
355
- for (const statement of inflightStatements.values()) {
356
- if (exitSentIds.has(statement.id)) continue;
357
- try {
358
- exitTransport(statement);
359
- exitSentIds.add(statement.id);
360
- } catch {
760
+ const headId = queue.getHeadInFlightId?.();
761
+ if (headId) {
762
+ opts.abortInFlight?.(headId);
763
+ const headStatement = inflightStatements.get(headId);
764
+ if (headStatement) {
765
+ dispatchExitStatement(headStatement);
361
766
  }
362
767
  }
768
+ for (const statement of inflightStatements.values()) {
769
+ if (statement.id === headId) continue;
770
+ opts.abortInFlight?.(statement.id);
771
+ dispatchExitStatement(statement);
772
+ }
363
773
  queue.flushOnExit((statement) => {
364
- if (exitSentIds.has(statement.id)) return;
365
- exitTransport(statement);
366
- exitSentIds.add(statement.id);
774
+ dispatchExitStatement(statement);
367
775
  });
368
776
  } : void 0,
369
777
  startedLesson: ({ lessonId }) => {
@@ -401,124 +809,262 @@ function createXAPIClient(opts) {
401
809
  });
402
810
  }
403
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
+ }
404
903
  }
405
904
 
406
905
  // src/fetchTransport.ts
906
+ var FetchHttpError = class extends Error {
907
+ status;
908
+ constructor(status, statusText, kind = "xapi") {
909
+ super(
910
+ kind === "xapi" ? `xAPI fetch failed: ${status} ${statusText}` : `telemetry batch fetch failed: ${status} ${statusText}`
911
+ );
912
+ this.name = "FetchHttpError";
913
+ this.status = status;
914
+ }
915
+ };
916
+ function isRetryableFetchHttpStatus(status) {
917
+ return status === 429 || status >= 500;
918
+ }
919
+ function isRetryableFetchError(err) {
920
+ if (err instanceof FetchHttpError) return isRetryableFetchHttpStatus(err.status);
921
+ return true;
922
+ }
407
923
  function resolveHeaders(headers) {
408
924
  if (!headers) return { "Content-Type": "application/json" };
409
925
  const resolved = typeof headers === "function" ? headers() : headers;
410
926
  return { "Content-Type": "application/json", ...resolved };
411
927
  }
412
928
  function createAbortSignal(timeoutMs) {
413
- if (timeoutMs <= 0) return void 0;
414
- const timeout = AbortSignal;
415
- if (typeof timeout.timeout === "function") {
416
- return timeout.timeout(timeoutMs);
417
- }
929
+ if (timeoutMs <= 0) return { signal: void 0, abort: () => {
930
+ } };
418
931
  const controller = new AbortController();
419
932
  const timer = setTimeout(() => controller.abort(), timeoutMs);
420
933
  timer.unref?.();
421
- return controller.signal;
934
+ return {
935
+ signal: controller.signal,
936
+ abort: () => {
937
+ clearTimeout(timer);
938
+ controller.abort();
939
+ }
940
+ };
422
941
  }
423
942
  function sleep(ms) {
424
943
  return new Promise((resolve) => setTimeout(resolve, ms));
425
944
  }
426
945
  function postStatement(url, statement, init) {
427
946
  return fetch(url, {
947
+ ...init,
428
948
  method: "POST",
429
- body: JSON.stringify(statement),
430
- ...init
949
+ body: JSON.stringify(statement)
431
950
  }).then((res) => {
432
951
  if (!res.ok) {
433
- throw new Error(`xAPI fetch failed: ${res.status} ${res.statusText}`);
952
+ throw new FetchHttpError(res.status, res.statusText, "xapi");
434
953
  }
435
954
  });
436
955
  }
956
+ async function postWithRetry(post, retries, initialBackoffMs, maxBackoffMs) {
957
+ let attempt = 0;
958
+ let backoff = initialBackoffMs;
959
+ for (; ; ) {
960
+ try {
961
+ await post();
962
+ return;
963
+ } catch (err) {
964
+ if (!isRetryableFetchError(err) || attempt >= retries) throw err;
965
+ await sleep(backoff);
966
+ backoff = Math.min(backoff * 2, maxBackoffMs);
967
+ attempt += 1;
968
+ }
969
+ }
970
+ }
437
971
  function createFetchTransport(opts) {
972
+ assertSafeLrsUrl(opts.url, { allowPrivateHosts: opts.allowPrivateHosts });
438
973
  const timeoutMs = opts.timeoutMs ?? 3e4;
439
- const retries = opts.retries ?? 2;
974
+ const rawRetries = opts.retries ?? 2;
975
+ const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
440
976
  const initialBackoffMs = opts.backoffMs ?? 250;
441
977
  const maxBackoffMs = opts.maxBackoffMs ?? 5e3;
978
+ const activeControllers = /* @__PURE__ */ new Map();
442
979
  const transport = async (statement) => {
443
- let attempt = 0;
444
- let backoff = initialBackoffMs;
445
- for (; ; ) {
446
- try {
447
- await postStatement(opts.url, statement, {
448
- ...opts.init,
449
- headers: resolveHeaders(opts.headers),
450
- signal: createAbortSignal(timeoutMs)
451
- });
452
- return;
453
- } catch (err) {
454
- if (attempt >= retries) throw err;
455
- await sleep(backoff);
456
- backoff = Math.min(backoff * 2, maxBackoffMs);
457
- attempt += 1;
458
- }
980
+ let abortCleanup;
981
+ activeControllers.set(statement.id, {
982
+ abort: () => abortCleanup?.()
983
+ });
984
+ try {
985
+ await postWithRetry(
986
+ () => {
987
+ const { signal, abort } = createAbortSignal(timeoutMs);
988
+ abortCleanup = abort;
989
+ return postStatement(opts.url, statement, {
990
+ ...opts.init,
991
+ headers: resolveHeaders(opts.headers),
992
+ signal
993
+ });
994
+ },
995
+ retries,
996
+ initialBackoffMs,
997
+ maxBackoffMs
998
+ );
999
+ } finally {
1000
+ activeControllers.delete(statement.id);
459
1001
  }
460
1002
  };
461
1003
  const exitTransport = (statement) => {
462
- try {
463
- void postStatement(opts.url, statement, {
464
- ...opts.init,
465
- headers: resolveHeaders(opts.headers),
466
- keepalive: true
467
- }).catch(() => void 0);
468
- } catch {
469
- }
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
+ });
470
1011
  };
471
- return { transport, exitTransport };
1012
+ const abortInFlight = (statementId) => {
1013
+ activeControllers.get(statementId)?.abort();
1014
+ activeControllers.delete(statementId);
1015
+ };
1016
+ return { transport, exitTransport, abortInFlight };
472
1017
  }
473
1018
  function createFetchBatchSink(opts) {
1019
+ assertSafeLrsUrl(opts.url, { allowPrivateHosts: opts.allowPrivateHosts });
474
1020
  const timeoutMs = opts.timeoutMs ?? 3e4;
475
- const retries = opts.retries ?? 2;
1021
+ const rawRetries = opts.retries ?? 2;
1022
+ const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
476
1023
  const initialBackoffMs = opts.backoffMs ?? 250;
477
1024
  const maxBackoffMs = opts.maxBackoffMs ?? 5e3;
478
1025
  const postBatch = async (events, init) => {
479
- let attempt = 0;
480
- let backoff = initialBackoffMs;
481
- for (; ; ) {
482
- try {
483
- const res = await fetch(opts.url, {
484
- method: "POST",
485
- body: JSON.stringify(events),
486
- ...init,
487
- headers: resolveHeaders(opts.headers),
488
- signal: createAbortSignal(timeoutMs)
489
- });
490
- if (!res.ok) {
491
- throw new Error(`telemetry batch fetch failed: ${res.status} ${res.statusText}`);
492
- }
493
- return;
494
- } catch (err) {
495
- if (attempt >= retries) throw err;
496
- await sleep(backoff);
497
- backoff = Math.min(backoff * 2, maxBackoffMs);
498
- attempt += 1;
1026
+ await postWithRetry(async () => {
1027
+ const { signal } = createAbortSignal(timeoutMs);
1028
+ const res = await fetch(opts.url, {
1029
+ ...init,
1030
+ method: "POST",
1031
+ body: JSON.stringify(events),
1032
+ headers: resolveHeaders(opts.headers),
1033
+ signal
1034
+ });
1035
+ if (!res.ok) {
1036
+ throw new FetchHttpError(res.status, res.statusText, "batch");
499
1037
  }
500
- }
1038
+ }, retries, initialBackoffMs, maxBackoffMs);
501
1039
  };
502
1040
  return {
503
1041
  batchSink: (events) => postBatch(events, opts.init ?? {}),
504
1042
  exitBatchSink: (events) => {
505
- try {
506
- void fetch(opts.url, {
507
- method: "POST",
508
- body: JSON.stringify(events),
509
- ...opts.init,
510
- headers: resolveHeaders(opts.headers),
511
- keepalive: true
512
- }).catch(() => void 0);
513
- } catch {
514
- }
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
+ });
515
1054
  }
516
1055
  };
517
1056
  }
518
1057
  export {
1058
+ FetchHttpError,
1059
+ assertSafeLrsUrl,
519
1060
  createFetchBatchSink,
520
1061
  createFetchTransport,
521
1062
  createInMemoryXAPIQueue,
522
1063
  createXAPIClient,
1064
+ isRetryableFetchError,
1065
+ isRetryableFetchHttpStatus,
1066
+ loadDeadLetterStatements,
1067
+ persistDeadLetterStatement,
1068
+ resetXAPIDeadLetterForTests,
523
1069
  telemetryEventToXAPIStatement
524
1070
  };