@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.cjs CHANGED
@@ -20,19 +20,134 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ FetchHttpError: () => FetchHttpError,
24
+ assertSafeLrsUrl: () => assertSafeLrsUrl,
23
25
  createFetchBatchSink: () => createFetchBatchSink,
24
26
  createFetchTransport: () => createFetchTransport,
25
27
  createInMemoryXAPIQueue: () => createInMemoryXAPIQueue,
26
28
  createXAPIClient: () => createXAPIClient,
29
+ isRetryableFetchError: () => isRetryableFetchError,
30
+ isRetryableFetchHttpStatus: () => isRetryableFetchHttpStatus,
31
+ loadDeadLetterStatements: () => loadDeadLetterStatements,
32
+ persistDeadLetterStatement: () => persistDeadLetterStatement,
33
+ resetXAPIDeadLetterForTests: () => resetXAPIDeadLetterForTests,
27
34
  telemetryEventToXAPIStatement: () => telemetryEventToXAPIStatement
28
35
  });
29
36
  module.exports = __toCommonJS(index_exports);
30
37
 
31
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
+ }
32
86
  function cryptoRandomId() {
33
87
  const g = globalThis;
34
88
  if (g.crypto?.randomUUID) return g.crypto.randomUUID();
35
- 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);
36
151
  }
37
152
 
38
153
  // src/queue.ts
@@ -46,11 +161,15 @@ function withStatementId(statement) {
46
161
  return statement;
47
162
  }
48
163
  var DEFAULT_MAX_QUEUE_SIZE = 1e3;
164
+ var DEFAULT_MAX_HEAD_FAILURES = 10;
49
165
  function createInMemoryXAPIQueue(opts) {
50
166
  const maxSize = opts?.maxSize ?? DEFAULT_MAX_QUEUE_SIZE;
167
+ const maxHeadFailures = opts?.maxHeadFailures ?? DEFAULT_MAX_HEAD_FAILURES;
51
168
  const buffer = [];
52
169
  let flushInFlight = null;
53
170
  let headInFlight = false;
171
+ let headInFlightId;
172
+ let headFailureCount = 0;
54
173
  const notifyDepth = () => {
55
174
  opts?.onDepth?.(buffer.length);
56
175
  };
@@ -65,32 +184,51 @@ function createInMemoryXAPIQueue(opts) {
65
184
  while (buffer.length) {
66
185
  const statement = buffer[0];
67
186
  headInFlight = true;
187
+ headInFlightId = statement.id;
68
188
  try {
69
189
  await transport(statement);
70
190
  buffer.shift();
191
+ headFailureCount = 0;
71
192
  notifyDepth();
72
- } catch {
73
- return;
193
+ } catch (err) {
194
+ headFailureCount += 1;
195
+ if (headFailureCount >= maxHeadFailures) {
196
+ buffer.shift();
197
+ headFailureCount = 0;
198
+ notifyDepth();
199
+ opts?.onHeadSkipped?.(statement, err);
200
+ continue;
201
+ }
202
+ throw err;
74
203
  } finally {
75
204
  headInFlight = false;
205
+ headInFlightId = void 0;
76
206
  }
77
207
  }
78
208
  };
79
209
  return {
80
210
  enqueue: (statement) => {
81
211
  const normalized = withStatementId(statement);
82
- 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
+ }
83
218
  if (buffer.length >= maxSize) {
84
- if (headInFlight && buffer.length <= 1) {
85
- opts?.onCap?.();
86
- return;
87
- }
88
219
  if (headInFlight) {
89
- buffer.splice(1, 1);
220
+ if (buffer.length > 1) {
221
+ buffer.splice(1, 1);
222
+ opts?.onCap?.();
223
+ } else {
224
+ opts?.onCap?.();
225
+ opts?.onOverflow?.(normalized);
226
+ return;
227
+ }
90
228
  } else {
91
229
  buffer.shift();
230
+ opts?.onCap?.();
92
231
  }
93
- opts?.onCap?.();
94
232
  }
95
233
  buffer.push(normalized);
96
234
  notifyDepth();
@@ -106,27 +244,76 @@ function createInMemoryXAPIQueue(opts) {
106
244
  return flushInFlight;
107
245
  },
108
246
  flushOnExit: (exitTransport) => {
109
- const startIdx = headInFlight && buffer.length > 0 ? 1 : 0;
110
- for (let i = startIdx; i < buffer.length; i++) {
111
- const statement = buffer[i];
247
+ for (const statement of buffer) {
112
248
  try {
113
249
  exitTransport(statement);
114
250
  } catch {
115
251
  }
116
252
  }
117
- if (startIdx === 0) {
118
- buffer.length = 0;
119
- } else if (buffer.length > 1) {
120
- buffer.splice(1);
121
- }
253
+ buffer.length = 0;
122
254
  notifyDepth();
123
- }
255
+ },
256
+ getHeadInFlightId: () => headInFlightId
124
257
  };
125
258
  }
126
259
 
127
260
  // src/client.ts
128
261
  var import_core2 = require("@lessonkit/core");
129
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
+
130
317
  // src/telemetryMap.ts
131
318
  var import_core = require("@lessonkit/core");
132
319
 
@@ -163,9 +350,9 @@ function buildXapiScoreResult(opts) {
163
350
  }
164
351
  return result;
165
352
  }
166
- function statementFor(objectId, verb, timestamp, extra) {
353
+ function statementFor(event, objectId, verb, timestamp, extra) {
167
354
  return {
168
- id: cryptoRandomId(),
355
+ id: deriveStatementId(event, objectId, verb),
169
356
  timestamp,
170
357
  verb,
171
358
  object: { id: objectId },
@@ -173,8 +360,21 @@ function statementFor(objectId, verb, timestamp, extra) {
173
360
  context: extra?.context
174
361
  };
175
362
  }
176
- 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) {
177
376
  return statementFor(
377
+ event,
178
378
  (0, import_core.buildLessonkitUrn)({ courseId, lessonId, blockId }),
179
379
  XAPIVerbs.experienced,
180
380
  timestamp
@@ -185,20 +385,39 @@ var experiencedBlockMapper = (event, ctx) => {
185
385
  const lessonId2 = event.lessonId;
186
386
  const blockId2 = event.data?.blockId;
187
387
  if (!lessonId2 || !blockId2 || typeof blockId2 !== "string") return null;
188
- 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
+ );
189
407
  }
190
408
  const lessonId = event.lessonId;
191
409
  const blockId = "data" in event && event.data && "blockId" in event.data ? event.data.blockId : void 0;
192
410
  if (!lessonId || !blockId || typeof blockId !== "string") return null;
193
- return experiencedBlockStatement(ctx.courseId, lessonId, blockId, ctx.timestamp);
411
+ return experiencedBlockStatement(event, ctx.courseId, lessonId, blockId, ctx.timestamp);
194
412
  };
195
413
  var TELEMETRY_XAPI_MAPPERS = {
196
- course_started: (_event, ctx) => statementFor((0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId }), XAPIVerbs.initialized, ctx.timestamp),
197
- 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),
198
416
  lesson_started: (event, ctx) => {
199
417
  const lessonId = event.name === "lesson_started" ? event.lessonId : void 0;
200
418
  if (!lessonId) return null;
201
419
  return statementFor(
420
+ event,
202
421
  (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId }),
203
422
  XAPIVerbs.initialized,
204
423
  ctx.timestamp
@@ -216,9 +435,15 @@ var TELEMETRY_XAPI_MAPPERS = {
216
435
  if (typeof data?.success === "boolean") result.success = data.success;
217
436
  const score = buildXapiScoreResult({ score: data?.score, maxScore: data?.maxScore });
218
437
  if (score) result.score = score;
219
- return statementFor((0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId }), XAPIVerbs.completed, ctx.timestamp, {
220
- result: Object.keys(result).length ? result : void 0
221
- });
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
+ );
222
447
  },
223
448
  lesson_time_on_task: () => null,
224
449
  quiz_answered: (event, ctx) => {
@@ -226,6 +451,7 @@ var TELEMETRY_XAPI_MAPPERS = {
226
451
  const result = {};
227
452
  if (typeof event.data.correct === "boolean") result.success = event.data.correct;
228
453
  return statementFor(
454
+ event,
229
455
  (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
230
456
  XAPIVerbs.answered,
231
457
  ctx.timestamp,
@@ -236,6 +462,7 @@ var TELEMETRY_XAPI_MAPPERS = {
236
462
  if (event.name !== "quiz_completed") return null;
237
463
  const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
238
464
  return statementFor(
465
+ event,
239
466
  (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
240
467
  XAPIVerbs.completed,
241
468
  ctx.timestamp,
@@ -247,6 +474,7 @@ var TELEMETRY_XAPI_MAPPERS = {
247
474
  const result = {};
248
475
  if (typeof event.data.correct === "boolean") result.success = event.data.correct;
249
476
  return statementFor(
477
+ event,
250
478
  (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
251
479
  XAPIVerbs.answered,
252
480
  ctx.timestamp,
@@ -257,6 +485,7 @@ var TELEMETRY_XAPI_MAPPERS = {
257
485
  if (event.name !== "assessment_completed") return null;
258
486
  const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
259
487
  return statementFor(
488
+ event,
260
489
  (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
261
490
  XAPIVerbs.completed,
262
491
  ctx.timestamp,
@@ -270,16 +499,71 @@ var TELEMETRY_XAPI_MAPPERS = {
270
499
  hotspot_opened: experiencedBlockMapper,
271
500
  accordion_section_toggled: experiencedBlockMapper,
272
501
  flashcard_flipped: experiencedBlockMapper,
273
- image_slider_changed: experiencedBlockMapper
502
+ image_slider_changed: experiencedBlockMapper,
503
+ video_cue_reached: experiencedBlockMapper,
504
+ video_segment_completed: (event, ctx) => {
505
+ if (event.name !== "video_segment_completed") return null;
506
+ const lessonId = event.lessonId;
507
+ const blockId = event.data.blockId;
508
+ if (!lessonId || !blockId) return null;
509
+ return statementFor(
510
+ event,
511
+ (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId }),
512
+ XAPIVerbs.completed,
513
+ ctx.timestamp
514
+ );
515
+ },
516
+ memory_card_flipped: experiencedBlockMapper,
517
+ information_wall_search: experiencedBlockMapper,
518
+ parallax_slide_viewed: experiencedBlockMapper,
519
+ questionnaire_submitted: (event, ctx) => {
520
+ if (event.name !== "questionnaire_submitted") return null;
521
+ const lessonId = event.lessonId;
522
+ const blockId = event.data.blockId;
523
+ if (!lessonId || !blockId) return null;
524
+ return statementFor(
525
+ event,
526
+ (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId }),
527
+ XAPIVerbs.completed,
528
+ ctx.timestamp
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
+ );
556
+ }
274
557
  };
275
558
  function telemetryEventToXAPIStatement(event) {
276
- const mapper = TELEMETRY_XAPI_MAPPERS[event.name];
559
+ const enriched = enrichTelemetryEventForXapi(event);
560
+ const mapper = TELEMETRY_XAPI_MAPPERS[enriched.name];
277
561
  if (!mapper) {
278
- throw new Error(`Unhandled telemetry event: ${event.name}`);
562
+ throw new Error(`Unhandled telemetry event: ${enriched.name}`);
279
563
  }
280
- return mapper(event, {
281
- courseId: event.courseId,
282
- timestamp: event.timestamp
564
+ return mapper(enriched, {
565
+ courseId: enriched.courseId,
566
+ timestamp: enriched.timestamp
283
567
  });
284
568
  }
285
569
 
@@ -297,22 +581,89 @@ function isDevEnvironment() {
297
581
  const g = globalThis;
298
582
  return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
299
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
+ }
300
597
  function createXAPIClient(opts) {
301
598
  const transport = opts?.transport;
302
599
  const exitTransport = opts?.exitTransport;
303
600
  const courseId = opts?.courseId;
304
601
  const queue = opts?.queue ?? createInMemoryXAPIQueue({
305
602
  maxSize: opts?.maxQueueSize,
603
+ maxHeadFailures: opts?.maxHeadFailures,
306
604
  onDepth: opts?.onQueueDepth,
307
- 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
+ }
308
613
  });
309
614
  let warnedNoTransport = false;
310
615
  let warnedTransportFailure = false;
311
616
  const inflightById = /* @__PURE__ */ new Map();
312
617
  const inflightStatements = /* @__PURE__ */ new Map();
313
- const sendOrQueue = (statement) => {
618
+ const pendingReplacement = /* @__PURE__ */ new Map();
619
+ const inflightPayload = /* @__PURE__ */ new Map();
620
+ const replacementWatcher = /* @__PURE__ */ new Set();
621
+ const exitDeliveredIds = /* @__PURE__ */ new Set();
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;
629
+ const deliveryTransport = transport ? async (statement) => {
630
+ if (exitNetworkSentIds.has(statement.id)) return;
631
+ await transport(statement);
632
+ removeDeadLetterStatement(statement.id);
633
+ } : void 0;
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) => {
314
664
  const normalized = withStatementId2(statement);
315
- if (!transport) {
665
+ if (exitDeliveredIds.has(normalized.id)) return;
666
+ if (!deliveryTransport) {
316
667
  queue.enqueue(normalized);
317
668
  if (isDevEnvironment() && !warnedNoTransport) {
318
669
  warnedNoTransport = true;
@@ -324,41 +675,72 @@ function createXAPIClient(opts) {
324
675
  }
325
676
  const existing = inflightById.get(normalized.id);
326
677
  if (existing) {
327
- void existing.then(
328
- () => void 0,
329
- () => {
330
- sendOrQueue(normalized);
331
- }
332
- );
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
+ }
333
701
  return;
334
702
  }
335
703
  inflightStatements.set(normalized.id, normalized);
704
+ inflightPayload.set(normalized.id, normalized);
336
705
  const flight = Promise.resolve().then(async () => {
337
- await transport(normalized);
706
+ await deliveryTransport(normalized);
338
707
  queue.removeById(normalized.id);
339
- }).catch(() => {
708
+ }).catch((err) => {
709
+ if (exitDeliveredIds.has(normalized.id) || exitHandoffIds.has(normalized.id)) return;
340
710
  queue.enqueue(normalized);
711
+ opts?.onTransportError?.(err);
341
712
  if (isDevEnvironment() && !warnedTransportFailure) {
342
713
  warnedTransportFailure = true;
343
714
  console.warn(
344
715
  "[lessonkit] xAPI transport failed; statement re-queued. Check your LRS endpoint or transport implementation."
345
716
  );
346
717
  }
347
- throw new Error("xAPI transport failed");
718
+ throw err instanceof Error ? err : new Error("xAPI transport failed", { cause: err });
348
719
  }).finally(() => {
349
720
  inflightById.delete(normalized.id);
350
721
  inflightStatements.delete(normalized.id);
722
+ if (!replacementWatcher.has(normalized.id)) {
723
+ inflightPayload.delete(normalized.id);
724
+ }
351
725
  });
352
726
  inflightById.set(normalized.id, flight);
353
727
  void flight.catch(() => {
354
728
  });
355
729
  };
730
+ const sendOrQueue = (statement) => {
731
+ if (flushInProgress) {
732
+ pendingDuringFlush.push(statement);
733
+ return;
734
+ }
735
+ sendOrQueueInternal(statement);
736
+ };
356
737
  const emit = (event) => {
357
738
  try {
358
739
  const statement = telemetryEventToXAPIStatement(event);
359
740
  if (!statement) return;
360
741
  sendOrQueue(statement);
361
742
  } catch (err) {
743
+ opts?.onMappingError?.(err);
362
744
  if (isDevEnvironment()) {
363
745
  console.warn(
364
746
  "[lessonkit] xAPI mapping skipped:",
@@ -367,33 +749,66 @@ function createXAPIClient(opts) {
367
749
  }
368
750
  }
369
751
  };
370
- 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 = {
371
764
  send: (statement) => {
372
765
  sendOrQueue(statement);
373
766
  },
374
767
  queueSize: () => queue.size(),
375
768
  flush: async () => {
376
- if (!transport) return;
377
- await queue.flush(transport);
378
- const flights = [...inflightById.values()];
379
- if (flights.length > 0) {
380
- await Promise.allSettled(flights);
769
+ if (!deliveryTransport) return;
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;
381
794
  }
382
795
  },
383
796
  flushOnExit: exitTransport ? () => {
384
- const exitSentIds = /* @__PURE__ */ new Set();
385
- for (const statement of inflightStatements.values()) {
386
- if (exitSentIds.has(statement.id)) continue;
387
- try {
388
- exitTransport(statement);
389
- exitSentIds.add(statement.id);
390
- } catch {
797
+ const headId = queue.getHeadInFlightId?.();
798
+ if (headId) {
799
+ opts.abortInFlight?.(headId);
800
+ const headStatement = inflightStatements.get(headId);
801
+ if (headStatement) {
802
+ dispatchExitStatement(headStatement);
391
803
  }
392
804
  }
805
+ for (const statement of inflightStatements.values()) {
806
+ if (statement.id === headId) continue;
807
+ opts.abortInFlight?.(statement.id);
808
+ dispatchExitStatement(statement);
809
+ }
393
810
  queue.flushOnExit((statement) => {
394
- if (exitSentIds.has(statement.id)) return;
395
- exitTransport(statement);
396
- exitSentIds.add(statement.id);
811
+ dispatchExitStatement(statement);
397
812
  });
398
813
  } : void 0,
399
814
  startedLesson: ({ lessonId }) => {
@@ -431,125 +846,264 @@ function createXAPIClient(opts) {
431
846
  });
432
847
  }
433
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
+ }
434
941
  }
435
942
 
436
943
  // src/fetchTransport.ts
944
+ var FetchHttpError = class extends Error {
945
+ status;
946
+ constructor(status, statusText, kind = "xapi") {
947
+ super(
948
+ kind === "xapi" ? `xAPI fetch failed: ${status} ${statusText}` : `telemetry batch fetch failed: ${status} ${statusText}`
949
+ );
950
+ this.name = "FetchHttpError";
951
+ this.status = status;
952
+ }
953
+ };
954
+ function isRetryableFetchHttpStatus(status) {
955
+ return status === 429 || status >= 500;
956
+ }
957
+ function isRetryableFetchError(err) {
958
+ if (err instanceof FetchHttpError) return isRetryableFetchHttpStatus(err.status);
959
+ return true;
960
+ }
437
961
  function resolveHeaders(headers) {
438
962
  if (!headers) return { "Content-Type": "application/json" };
439
963
  const resolved = typeof headers === "function" ? headers() : headers;
440
964
  return { "Content-Type": "application/json", ...resolved };
441
965
  }
442
966
  function createAbortSignal(timeoutMs) {
443
- if (timeoutMs <= 0) return void 0;
444
- const timeout = AbortSignal;
445
- if (typeof timeout.timeout === "function") {
446
- return timeout.timeout(timeoutMs);
447
- }
967
+ if (timeoutMs <= 0) return { signal: void 0, abort: () => {
968
+ } };
448
969
  const controller = new AbortController();
449
970
  const timer = setTimeout(() => controller.abort(), timeoutMs);
450
971
  timer.unref?.();
451
- return controller.signal;
972
+ return {
973
+ signal: controller.signal,
974
+ abort: () => {
975
+ clearTimeout(timer);
976
+ controller.abort();
977
+ }
978
+ };
452
979
  }
453
980
  function sleep(ms) {
454
981
  return new Promise((resolve) => setTimeout(resolve, ms));
455
982
  }
456
983
  function postStatement(url, statement, init) {
457
984
  return fetch(url, {
985
+ ...init,
458
986
  method: "POST",
459
- body: JSON.stringify(statement),
460
- ...init
987
+ body: JSON.stringify(statement)
461
988
  }).then((res) => {
462
989
  if (!res.ok) {
463
- throw new Error(`xAPI fetch failed: ${res.status} ${res.statusText}`);
990
+ throw new FetchHttpError(res.status, res.statusText, "xapi");
464
991
  }
465
992
  });
466
993
  }
994
+ async function postWithRetry(post, retries, initialBackoffMs, maxBackoffMs) {
995
+ let attempt = 0;
996
+ let backoff = initialBackoffMs;
997
+ for (; ; ) {
998
+ try {
999
+ await post();
1000
+ return;
1001
+ } catch (err) {
1002
+ if (!isRetryableFetchError(err) || attempt >= retries) throw err;
1003
+ await sleep(backoff);
1004
+ backoff = Math.min(backoff * 2, maxBackoffMs);
1005
+ attempt += 1;
1006
+ }
1007
+ }
1008
+ }
467
1009
  function createFetchTransport(opts) {
1010
+ assertSafeLrsUrl(opts.url, { allowPrivateHosts: opts.allowPrivateHosts });
468
1011
  const timeoutMs = opts.timeoutMs ?? 3e4;
469
- const retries = opts.retries ?? 2;
1012
+ const rawRetries = opts.retries ?? 2;
1013
+ const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
470
1014
  const initialBackoffMs = opts.backoffMs ?? 250;
471
1015
  const maxBackoffMs = opts.maxBackoffMs ?? 5e3;
1016
+ const activeControllers = /* @__PURE__ */ new Map();
472
1017
  const transport = async (statement) => {
473
- let attempt = 0;
474
- let backoff = initialBackoffMs;
475
- for (; ; ) {
476
- try {
477
- await postStatement(opts.url, statement, {
478
- ...opts.init,
479
- headers: resolveHeaders(opts.headers),
480
- signal: createAbortSignal(timeoutMs)
481
- });
482
- return;
483
- } catch (err) {
484
- if (attempt >= retries) throw err;
485
- await sleep(backoff);
486
- backoff = Math.min(backoff * 2, maxBackoffMs);
487
- attempt += 1;
488
- }
1018
+ let abortCleanup;
1019
+ activeControllers.set(statement.id, {
1020
+ abort: () => abortCleanup?.()
1021
+ });
1022
+ try {
1023
+ await postWithRetry(
1024
+ () => {
1025
+ const { signal, abort } = createAbortSignal(timeoutMs);
1026
+ abortCleanup = abort;
1027
+ return postStatement(opts.url, statement, {
1028
+ ...opts.init,
1029
+ headers: resolveHeaders(opts.headers),
1030
+ signal
1031
+ });
1032
+ },
1033
+ retries,
1034
+ initialBackoffMs,
1035
+ maxBackoffMs
1036
+ );
1037
+ } finally {
1038
+ activeControllers.delete(statement.id);
489
1039
  }
490
1040
  };
491
1041
  const exitTransport = (statement) => {
492
- try {
493
- void postStatement(opts.url, statement, {
494
- ...opts.init,
495
- headers: resolveHeaders(opts.headers),
496
- keepalive: true
497
- }).catch(() => void 0);
498
- } catch {
499
- }
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
+ });
500
1049
  };
501
- return { transport, exitTransport };
1050
+ const abortInFlight = (statementId) => {
1051
+ activeControllers.get(statementId)?.abort();
1052
+ activeControllers.delete(statementId);
1053
+ };
1054
+ return { transport, exitTransport, abortInFlight };
502
1055
  }
503
1056
  function createFetchBatchSink(opts) {
1057
+ assertSafeLrsUrl(opts.url, { allowPrivateHosts: opts.allowPrivateHosts });
504
1058
  const timeoutMs = opts.timeoutMs ?? 3e4;
505
- const retries = opts.retries ?? 2;
1059
+ const rawRetries = opts.retries ?? 2;
1060
+ const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
506
1061
  const initialBackoffMs = opts.backoffMs ?? 250;
507
1062
  const maxBackoffMs = opts.maxBackoffMs ?? 5e3;
508
1063
  const postBatch = async (events, init) => {
509
- let attempt = 0;
510
- let backoff = initialBackoffMs;
511
- for (; ; ) {
512
- try {
513
- const res = await fetch(opts.url, {
514
- method: "POST",
515
- body: JSON.stringify(events),
516
- ...init,
517
- headers: resolveHeaders(opts.headers),
518
- signal: createAbortSignal(timeoutMs)
519
- });
520
- if (!res.ok) {
521
- throw new Error(`telemetry batch fetch failed: ${res.status} ${res.statusText}`);
522
- }
523
- return;
524
- } catch (err) {
525
- if (attempt >= retries) throw err;
526
- await sleep(backoff);
527
- backoff = Math.min(backoff * 2, maxBackoffMs);
528
- attempt += 1;
1064
+ await postWithRetry(async () => {
1065
+ const { signal } = createAbortSignal(timeoutMs);
1066
+ const res = await fetch(opts.url, {
1067
+ ...init,
1068
+ method: "POST",
1069
+ body: JSON.stringify(events),
1070
+ headers: resolveHeaders(opts.headers),
1071
+ signal
1072
+ });
1073
+ if (!res.ok) {
1074
+ throw new FetchHttpError(res.status, res.statusText, "batch");
529
1075
  }
530
- }
1076
+ }, retries, initialBackoffMs, maxBackoffMs);
531
1077
  };
532
1078
  return {
533
1079
  batchSink: (events) => postBatch(events, opts.init ?? {}),
534
1080
  exitBatchSink: (events) => {
535
- try {
536
- void fetch(opts.url, {
537
- method: "POST",
538
- body: JSON.stringify(events),
539
- ...opts.init,
540
- headers: resolveHeaders(opts.headers),
541
- keepalive: true
542
- }).catch(() => void 0);
543
- } catch {
544
- }
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
+ });
545
1092
  }
546
1093
  };
547
1094
  }
548
1095
  // Annotate the CommonJS export names for ESM import in node:
549
1096
  0 && (module.exports = {
1097
+ FetchHttpError,
1098
+ assertSafeLrsUrl,
550
1099
  createFetchBatchSink,
551
1100
  createFetchTransport,
552
1101
  createInMemoryXAPIQueue,
553
1102
  createXAPIClient,
1103
+ isRetryableFetchError,
1104
+ isRetryableFetchHttpStatus,
1105
+ loadDeadLetterStatements,
1106
+ persistDeadLetterStatement,
1107
+ resetXAPIDeadLetterForTests,
554
1108
  telemetryEventToXAPIStatement
555
1109
  });