@lessonkit/xapi 1.3.1 → 1.4.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,10 +20,13 @@ 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,
23
24
  createFetchBatchSink: () => createFetchBatchSink,
24
25
  createFetchTransport: () => createFetchTransport,
25
26
  createInMemoryXAPIQueue: () => createInMemoryXAPIQueue,
26
27
  createXAPIClient: () => createXAPIClient,
28
+ isRetryableFetchError: () => isRetryableFetchError,
29
+ isRetryableFetchHttpStatus: () => isRetryableFetchHttpStatus,
27
30
  telemetryEventToXAPIStatement: () => telemetryEventToXAPIStatement
28
31
  });
29
32
  module.exports = __toCommonJS(index_exports);
@@ -46,11 +49,15 @@ function withStatementId(statement) {
46
49
  return statement;
47
50
  }
48
51
  var DEFAULT_MAX_QUEUE_SIZE = 1e3;
52
+ var DEFAULT_MAX_HEAD_FAILURES = 10;
49
53
  function createInMemoryXAPIQueue(opts) {
50
54
  const maxSize = opts?.maxSize ?? DEFAULT_MAX_QUEUE_SIZE;
55
+ const maxHeadFailures = opts?.maxHeadFailures ?? DEFAULT_MAX_HEAD_FAILURES;
51
56
  const buffer = [];
52
57
  let flushInFlight = null;
53
58
  let headInFlight = false;
59
+ let headInFlightId;
60
+ let headFailureCount = 0;
54
61
  const notifyDepth = () => {
55
62
  opts?.onDepth?.(buffer.length);
56
63
  };
@@ -65,14 +72,25 @@ function createInMemoryXAPIQueue(opts) {
65
72
  while (buffer.length) {
66
73
  const statement = buffer[0];
67
74
  headInFlight = true;
75
+ headInFlightId = statement.id;
68
76
  try {
69
77
  await transport(statement);
70
78
  buffer.shift();
79
+ headFailureCount = 0;
71
80
  notifyDepth();
72
- } catch {
73
- return;
81
+ } catch (err) {
82
+ headFailureCount += 1;
83
+ if (headFailureCount >= maxHeadFailures) {
84
+ buffer.shift();
85
+ headFailureCount = 0;
86
+ notifyDepth();
87
+ opts?.onHeadSkipped?.(statement, err);
88
+ continue;
89
+ }
90
+ throw err;
74
91
  } finally {
75
92
  headInFlight = false;
93
+ headInFlightId = void 0;
76
94
  }
77
95
  }
78
96
  };
@@ -81,12 +99,10 @@ function createInMemoryXAPIQueue(opts) {
81
99
  const normalized = withStatementId(statement);
82
100
  if (buffer.some((s) => s.id === normalized.id)) return;
83
101
  if (buffer.length >= maxSize) {
84
- if (headInFlight && buffer.length <= 1) {
85
- opts?.onCap?.();
86
- return;
87
- }
88
102
  if (headInFlight) {
89
- buffer.splice(1, 1);
103
+ if (buffer.length > 1) {
104
+ buffer.splice(1, 1);
105
+ }
90
106
  } else {
91
107
  buffer.shift();
92
108
  }
@@ -106,21 +122,16 @@ function createInMemoryXAPIQueue(opts) {
106
122
  return flushInFlight;
107
123
  },
108
124
  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];
125
+ for (const statement of buffer) {
112
126
  try {
113
127
  exitTransport(statement);
114
128
  } catch {
115
129
  }
116
130
  }
117
- if (startIdx === 0) {
118
- buffer.length = 0;
119
- } else if (buffer.length > 1) {
120
- buffer.splice(1);
121
- }
131
+ buffer.length = 0;
122
132
  notifyDepth();
123
- }
133
+ },
134
+ getHeadInFlightId: () => headInFlightId
124
135
  };
125
136
  }
126
137
 
@@ -270,7 +281,33 @@ var TELEMETRY_XAPI_MAPPERS = {
270
281
  hotspot_opened: experiencedBlockMapper,
271
282
  accordion_section_toggled: experiencedBlockMapper,
272
283
  flashcard_flipped: experiencedBlockMapper,
273
- image_slider_changed: experiencedBlockMapper
284
+ image_slider_changed: experiencedBlockMapper,
285
+ video_cue_reached: experiencedBlockMapper,
286
+ video_segment_completed: (event, ctx) => {
287
+ if (event.name !== "video_segment_completed") return null;
288
+ const lessonId = event.lessonId;
289
+ const blockId = event.data.blockId;
290
+ if (!lessonId || !blockId) return null;
291
+ return statementFor(
292
+ (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId }),
293
+ XAPIVerbs.completed,
294
+ ctx.timestamp
295
+ );
296
+ },
297
+ memory_card_flipped: experiencedBlockMapper,
298
+ information_wall_search: experiencedBlockMapper,
299
+ parallax_slide_viewed: experiencedBlockMapper,
300
+ questionnaire_submitted: (event, ctx) => {
301
+ if (event.name !== "questionnaire_submitted") return null;
302
+ const lessonId = event.lessonId;
303
+ const blockId = event.data.blockId;
304
+ if (!lessonId || !blockId) return null;
305
+ return statementFor(
306
+ (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId }),
307
+ XAPIVerbs.completed,
308
+ ctx.timestamp
309
+ );
310
+ }
274
311
  };
275
312
  function telemetryEventToXAPIStatement(event) {
276
313
  const mapper = TELEMETRY_XAPI_MAPPERS[event.name];
@@ -310,9 +347,16 @@ function createXAPIClient(opts) {
310
347
  let warnedTransportFailure = false;
311
348
  const inflightById = /* @__PURE__ */ new Map();
312
349
  const inflightStatements = /* @__PURE__ */ new Map();
350
+ const exitDeliveredIds = /* @__PURE__ */ new Set();
351
+ const exitNetworkSentIds = /* @__PURE__ */ new Set();
352
+ const deliveryTransport = transport ? async (statement) => {
353
+ if (exitNetworkSentIds.has(statement.id)) return;
354
+ await transport(statement);
355
+ } : void 0;
313
356
  const sendOrQueue = (statement) => {
314
357
  const normalized = withStatementId2(statement);
315
- if (!transport) {
358
+ if (exitDeliveredIds.has(normalized.id)) return;
359
+ if (!deliveryTransport) {
316
360
  queue.enqueue(normalized);
317
361
  if (isDevEnvironment() && !warnedNoTransport) {
318
362
  warnedNoTransport = true;
@@ -334,17 +378,19 @@ function createXAPIClient(opts) {
334
378
  }
335
379
  inflightStatements.set(normalized.id, normalized);
336
380
  const flight = Promise.resolve().then(async () => {
337
- await transport(normalized);
381
+ await deliveryTransport(normalized);
338
382
  queue.removeById(normalized.id);
339
- }).catch(() => {
383
+ }).catch((err) => {
384
+ if (exitDeliveredIds.has(normalized.id)) return;
340
385
  queue.enqueue(normalized);
386
+ opts?.onTransportError?.(err);
341
387
  if (isDevEnvironment() && !warnedTransportFailure) {
342
388
  warnedTransportFailure = true;
343
389
  console.warn(
344
390
  "[lessonkit] xAPI transport failed; statement re-queued. Check your LRS endpoint or transport implementation."
345
391
  );
346
392
  }
347
- throw new Error("xAPI transport failed");
393
+ throw err instanceof Error ? err : new Error("xAPI transport failed", { cause: err });
348
394
  }).finally(() => {
349
395
  inflightById.delete(normalized.id);
350
396
  inflightStatements.delete(normalized.id);
@@ -373,27 +419,36 @@ function createXAPIClient(opts) {
373
419
  },
374
420
  queueSize: () => queue.size(),
375
421
  flush: async () => {
376
- if (!transport) return;
377
- await queue.flush(transport);
422
+ if (!deliveryTransport) return;
423
+ await queue.flush(deliveryTransport);
378
424
  const flights = [...inflightById.values()];
379
425
  if (flights.length > 0) {
380
- await Promise.allSettled(flights);
426
+ await Promise.all(flights);
427
+ }
428
+ if (queue.size() > 0) {
429
+ throw new Error("xAPI flush incomplete: statements remain queued after flush");
381
430
  }
382
431
  },
383
432
  flushOnExit: exitTransport ? () => {
384
- const exitSentIds = /* @__PURE__ */ new Set();
433
+ const headId = queue.getHeadInFlightId?.();
434
+ if (headId) {
435
+ exitNetworkSentIds.add(headId);
436
+ exitDeliveredIds.add(headId);
437
+ opts.abortInFlight?.(headId);
438
+ }
385
439
  for (const statement of inflightStatements.values()) {
386
- if (exitSentIds.has(statement.id)) continue;
440
+ exitNetworkSentIds.add(statement.id);
441
+ exitDeliveredIds.add(statement.id);
442
+ opts.abortInFlight?.(statement.id);
443
+ }
444
+ queue.flushOnExit((statement) => {
445
+ if (exitDeliveredIds.has(statement.id)) return;
446
+ exitNetworkSentIds.add(statement.id);
447
+ exitDeliveredIds.add(statement.id);
387
448
  try {
388
449
  exitTransport(statement);
389
- exitSentIds.add(statement.id);
390
450
  } catch {
391
451
  }
392
- }
393
- queue.flushOnExit((statement) => {
394
- if (exitSentIds.has(statement.id)) return;
395
- exitTransport(statement);
396
- exitSentIds.add(statement.id);
397
452
  });
398
453
  } : void 0,
399
454
  startedLesson: ({ lessonId }) => {
@@ -434,58 +489,100 @@ function createXAPIClient(opts) {
434
489
  }
435
490
 
436
491
  // src/fetchTransport.ts
492
+ var FetchHttpError = class extends Error {
493
+ status;
494
+ constructor(status, statusText, kind = "xapi") {
495
+ super(
496
+ kind === "xapi" ? `xAPI fetch failed: ${status} ${statusText}` : `telemetry batch fetch failed: ${status} ${statusText}`
497
+ );
498
+ this.name = "FetchHttpError";
499
+ this.status = status;
500
+ }
501
+ };
502
+ function isRetryableFetchHttpStatus(status) {
503
+ return status === 429 || status >= 500;
504
+ }
505
+ function isRetryableFetchError(err) {
506
+ if (err instanceof FetchHttpError) return isRetryableFetchHttpStatus(err.status);
507
+ return true;
508
+ }
437
509
  function resolveHeaders(headers) {
438
510
  if (!headers) return { "Content-Type": "application/json" };
439
511
  const resolved = typeof headers === "function" ? headers() : headers;
440
512
  return { "Content-Type": "application/json", ...resolved };
441
513
  }
442
514
  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
- }
515
+ if (timeoutMs <= 0) return { signal: void 0, abort: () => {
516
+ } };
448
517
  const controller = new AbortController();
449
518
  const timer = setTimeout(() => controller.abort(), timeoutMs);
450
519
  timer.unref?.();
451
- return controller.signal;
520
+ return {
521
+ signal: controller.signal,
522
+ abort: () => {
523
+ clearTimeout(timer);
524
+ controller.abort();
525
+ }
526
+ };
452
527
  }
453
528
  function sleep(ms) {
454
529
  return new Promise((resolve) => setTimeout(resolve, ms));
455
530
  }
456
531
  function postStatement(url, statement, init) {
457
532
  return fetch(url, {
533
+ ...init,
458
534
  method: "POST",
459
- body: JSON.stringify(statement),
460
- ...init
535
+ body: JSON.stringify(statement)
461
536
  }).then((res) => {
462
537
  if (!res.ok) {
463
- throw new Error(`xAPI fetch failed: ${res.status} ${res.statusText}`);
538
+ throw new FetchHttpError(res.status, res.statusText, "xapi");
464
539
  }
465
540
  });
466
541
  }
542
+ async function postWithRetry(post, retries, initialBackoffMs, maxBackoffMs) {
543
+ let attempt = 0;
544
+ let backoff = initialBackoffMs;
545
+ for (; ; ) {
546
+ try {
547
+ await post();
548
+ return;
549
+ } catch (err) {
550
+ if (!isRetryableFetchError(err) || attempt >= retries) throw err;
551
+ await sleep(backoff);
552
+ backoff = Math.min(backoff * 2, maxBackoffMs);
553
+ attempt += 1;
554
+ }
555
+ }
556
+ }
467
557
  function createFetchTransport(opts) {
468
558
  const timeoutMs = opts.timeoutMs ?? 3e4;
469
- const retries = opts.retries ?? 2;
559
+ const rawRetries = opts.retries ?? 2;
560
+ const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
470
561
  const initialBackoffMs = opts.backoffMs ?? 250;
471
562
  const maxBackoffMs = opts.maxBackoffMs ?? 5e3;
563
+ const activeControllers = /* @__PURE__ */ new Map();
472
564
  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
- }
565
+ let abortCleanup;
566
+ activeControllers.set(statement.id, {
567
+ abort: () => abortCleanup?.()
568
+ });
569
+ try {
570
+ await postWithRetry(
571
+ () => {
572
+ const { signal, abort } = createAbortSignal(timeoutMs);
573
+ abortCleanup = abort;
574
+ return postStatement(opts.url, statement, {
575
+ ...opts.init,
576
+ headers: resolveHeaders(opts.headers),
577
+ signal
578
+ });
579
+ },
580
+ retries,
581
+ initialBackoffMs,
582
+ maxBackoffMs
583
+ );
584
+ } finally {
585
+ activeControllers.delete(statement.id);
489
586
  }
490
587
  };
491
588
  const exitTransport = (statement) => {
@@ -498,36 +595,32 @@ function createFetchTransport(opts) {
498
595
  } catch {
499
596
  }
500
597
  };
501
- return { transport, exitTransport };
598
+ const abortInFlight = (statementId) => {
599
+ activeControllers.get(statementId)?.abort();
600
+ activeControllers.delete(statementId);
601
+ };
602
+ return { transport, exitTransport, abortInFlight };
502
603
  }
503
604
  function createFetchBatchSink(opts) {
504
605
  const timeoutMs = opts.timeoutMs ?? 3e4;
505
- const retries = opts.retries ?? 2;
606
+ const rawRetries = opts.retries ?? 2;
607
+ const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
506
608
  const initialBackoffMs = opts.backoffMs ?? 250;
507
609
  const maxBackoffMs = opts.maxBackoffMs ?? 5e3;
508
610
  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;
611
+ await postWithRetry(async () => {
612
+ const { signal } = createAbortSignal(timeoutMs);
613
+ const res = await fetch(opts.url, {
614
+ ...init,
615
+ method: "POST",
616
+ body: JSON.stringify(events),
617
+ headers: resolveHeaders(opts.headers),
618
+ signal
619
+ });
620
+ if (!res.ok) {
621
+ throw new FetchHttpError(res.status, res.statusText, "batch");
529
622
  }
530
- }
623
+ }, retries, initialBackoffMs, maxBackoffMs);
531
624
  };
532
625
  return {
533
626
  batchSink: (events) => postBatch(events, opts.init ?? {}),
@@ -547,9 +640,12 @@ function createFetchBatchSink(opts) {
547
640
  }
548
641
  // Annotate the CommonJS export names for ESM import in node:
549
642
  0 && (module.exports = {
643
+ FetchHttpError,
550
644
  createFetchBatchSink,
551
645
  createFetchTransport,
552
646
  createInMemoryXAPIQueue,
553
647
  createXAPIClient,
648
+ isRetryableFetchError,
649
+ isRetryableFetchHttpStatus,
554
650
  telemetryEventToXAPIStatement
555
651
  });
package/dist/index.d.cts CHANGED
@@ -37,6 +37,8 @@ type XAPIQueue = {
37
37
  flush: (transport: XAPITransport) => Promise<void>;
38
38
  flushOnExit: (exitTransport: XAPIExitTransport) => void;
39
39
  size: () => number;
40
+ /** Statement id currently being delivered via flush, if any. */
41
+ getHeadInFlightId?: () => string | undefined;
40
42
  };
41
43
  type XAPIExitTransport = (statement: XAPIStatement) => void;
42
44
  type XAPIClient = {
@@ -65,6 +67,10 @@ type InMemoryXAPIQueueOptions = {
65
67
  onDepth?: (size: number) => void;
66
68
  /** Called when an oldest statement is dropped because the queue is at maxSize. */
67
69
  onCap?: () => void;
70
+ /** Failures at queue head before skipping (default 10). */
71
+ maxHeadFailures?: number;
72
+ /** Called when the queue head is skipped after repeated transport failures. */
73
+ onHeadSkipped?: (statement: XAPIStatement, err: unknown) => void;
68
74
  };
69
75
  declare function createInMemoryXAPIQueue(opts?: InMemoryXAPIQueueOptions): XAPIQueue;
70
76
 
@@ -72,12 +78,16 @@ declare function createXAPIClient(opts?: {
72
78
  transport?: XAPITransport;
73
79
  /** Keepalive transport for pagehide flush (e.g. from createFetchTransport). */
74
80
  exitTransport?: XAPIExitTransport;
81
+ /** Abort in-flight transport by statement id (e.g. from createFetchTransport). */
82
+ abortInFlight?: (statementId: string) => void;
75
83
  courseId?: CourseId;
76
84
  queue?: XAPIQueue;
77
85
  /** When creating the default in-memory queue (max size 1000 unless overridden). */
78
86
  maxQueueSize?: number;
79
87
  onQueueDepth?: (size: number) => void;
80
88
  onQueueCap?: () => void;
89
+ /** Called when transport fails after retries (statement is re-queued). */
90
+ onTransportError?: (err: unknown) => void;
81
91
  }): XAPIClient;
82
92
 
83
93
  type CreateFetchTransportOptions = {
@@ -100,7 +110,17 @@ type FetchTransportBundle = {
100
110
  transport: XAPITransport;
101
111
  /** Best-effort synchronous delivery for pagehide (keepalive fetch). */
102
112
  exitTransport: (statement: XAPIStatement) => void;
113
+ /** Abort an in-flight transport request by statement id (used on pagehide). */
114
+ abortInFlight: (statementId: string) => void;
103
115
  };
116
+ /** HTTP error from fetch transport with status for retry policy. */
117
+ declare class FetchHttpError extends Error {
118
+ readonly status: number;
119
+ constructor(status: number, statusText: string, kind?: "xapi" | "batch");
120
+ }
121
+ /** Retry 429 and 5xx; do not retry other 4xx (auth/config errors). */
122
+ declare function isRetryableFetchHttpStatus(status: number): boolean;
123
+ declare function isRetryableFetchError(err: unknown): boolean;
104
124
  /**
105
125
  * Creates an xAPI transport backed by fetch with timeout, retry backoff, and a
106
126
  * keepalive exit transport for pagehide delivery.
@@ -123,4 +143,4 @@ declare function createFetchBatchSink(opts: CreateFetchBatchSinkOptions): FetchB
123
143
  */
124
144
  declare function telemetryEventToXAPIStatement(event: TelemetryEvent): XAPIStatement | null;
125
145
 
126
- export { type CreateFetchBatchSinkOptions, type CreateFetchTransportOptions, type FetchBatchSinkBundle, type FetchTransportBundle, type InMemoryXAPIQueueOptions, type XAPIClient, type XAPIExitTransport, type XAPIObjectDefinition, type XAPIQueue, type XAPIResult, type XAPIScore, type XAPIStatement, type XAPITransport, type XAPIVerbIri, createFetchBatchSink, createFetchTransport, createInMemoryXAPIQueue, createXAPIClient, telemetryEventToXAPIStatement };
146
+ export { type CreateFetchBatchSinkOptions, type CreateFetchTransportOptions, type FetchBatchSinkBundle, FetchHttpError, type FetchTransportBundle, type InMemoryXAPIQueueOptions, type XAPIClient, type XAPIExitTransport, type XAPIObjectDefinition, type XAPIQueue, type XAPIResult, type XAPIScore, type XAPIStatement, type XAPITransport, type XAPIVerbIri, createFetchBatchSink, createFetchTransport, createInMemoryXAPIQueue, createXAPIClient, isRetryableFetchError, isRetryableFetchHttpStatus, telemetryEventToXAPIStatement };
package/dist/index.d.ts CHANGED
@@ -37,6 +37,8 @@ type XAPIQueue = {
37
37
  flush: (transport: XAPITransport) => Promise<void>;
38
38
  flushOnExit: (exitTransport: XAPIExitTransport) => void;
39
39
  size: () => number;
40
+ /** Statement id currently being delivered via flush, if any. */
41
+ getHeadInFlightId?: () => string | undefined;
40
42
  };
41
43
  type XAPIExitTransport = (statement: XAPIStatement) => void;
42
44
  type XAPIClient = {
@@ -65,6 +67,10 @@ type InMemoryXAPIQueueOptions = {
65
67
  onDepth?: (size: number) => void;
66
68
  /** Called when an oldest statement is dropped because the queue is at maxSize. */
67
69
  onCap?: () => void;
70
+ /** Failures at queue head before skipping (default 10). */
71
+ maxHeadFailures?: number;
72
+ /** Called when the queue head is skipped after repeated transport failures. */
73
+ onHeadSkipped?: (statement: XAPIStatement, err: unknown) => void;
68
74
  };
69
75
  declare function createInMemoryXAPIQueue(opts?: InMemoryXAPIQueueOptions): XAPIQueue;
70
76
 
@@ -72,12 +78,16 @@ declare function createXAPIClient(opts?: {
72
78
  transport?: XAPITransport;
73
79
  /** Keepalive transport for pagehide flush (e.g. from createFetchTransport). */
74
80
  exitTransport?: XAPIExitTransport;
81
+ /** Abort in-flight transport by statement id (e.g. from createFetchTransport). */
82
+ abortInFlight?: (statementId: string) => void;
75
83
  courseId?: CourseId;
76
84
  queue?: XAPIQueue;
77
85
  /** When creating the default in-memory queue (max size 1000 unless overridden). */
78
86
  maxQueueSize?: number;
79
87
  onQueueDepth?: (size: number) => void;
80
88
  onQueueCap?: () => void;
89
+ /** Called when transport fails after retries (statement is re-queued). */
90
+ onTransportError?: (err: unknown) => void;
81
91
  }): XAPIClient;
82
92
 
83
93
  type CreateFetchTransportOptions = {
@@ -100,7 +110,17 @@ type FetchTransportBundle = {
100
110
  transport: XAPITransport;
101
111
  /** Best-effort synchronous delivery for pagehide (keepalive fetch). */
102
112
  exitTransport: (statement: XAPIStatement) => void;
113
+ /** Abort an in-flight transport request by statement id (used on pagehide). */
114
+ abortInFlight: (statementId: string) => void;
103
115
  };
116
+ /** HTTP error from fetch transport with status for retry policy. */
117
+ declare class FetchHttpError extends Error {
118
+ readonly status: number;
119
+ constructor(status: number, statusText: string, kind?: "xapi" | "batch");
120
+ }
121
+ /** Retry 429 and 5xx; do not retry other 4xx (auth/config errors). */
122
+ declare function isRetryableFetchHttpStatus(status: number): boolean;
123
+ declare function isRetryableFetchError(err: unknown): boolean;
104
124
  /**
105
125
  * Creates an xAPI transport backed by fetch with timeout, retry backoff, and a
106
126
  * keepalive exit transport for pagehide delivery.
@@ -123,4 +143,4 @@ declare function createFetchBatchSink(opts: CreateFetchBatchSinkOptions): FetchB
123
143
  */
124
144
  declare function telemetryEventToXAPIStatement(event: TelemetryEvent): XAPIStatement | null;
125
145
 
126
- export { type CreateFetchBatchSinkOptions, type CreateFetchTransportOptions, type FetchBatchSinkBundle, type FetchTransportBundle, type InMemoryXAPIQueueOptions, type XAPIClient, type XAPIExitTransport, type XAPIObjectDefinition, type XAPIQueue, type XAPIResult, type XAPIScore, type XAPIStatement, type XAPITransport, type XAPIVerbIri, createFetchBatchSink, createFetchTransport, createInMemoryXAPIQueue, createXAPIClient, telemetryEventToXAPIStatement };
146
+ export { type CreateFetchBatchSinkOptions, type CreateFetchTransportOptions, type FetchBatchSinkBundle, FetchHttpError, type FetchTransportBundle, type InMemoryXAPIQueueOptions, type XAPIClient, type XAPIExitTransport, type XAPIObjectDefinition, type XAPIQueue, type XAPIResult, type XAPIScore, type XAPIStatement, type XAPITransport, type XAPIVerbIri, createFetchBatchSink, createFetchTransport, createInMemoryXAPIQueue, createXAPIClient, isRetryableFetchError, isRetryableFetchHttpStatus, telemetryEventToXAPIStatement };
package/dist/index.js CHANGED
@@ -16,11 +16,15 @@ function withStatementId(statement) {
16
16
  return statement;
17
17
  }
18
18
  var DEFAULT_MAX_QUEUE_SIZE = 1e3;
19
+ var DEFAULT_MAX_HEAD_FAILURES = 10;
19
20
  function createInMemoryXAPIQueue(opts) {
20
21
  const maxSize = opts?.maxSize ?? DEFAULT_MAX_QUEUE_SIZE;
22
+ const maxHeadFailures = opts?.maxHeadFailures ?? DEFAULT_MAX_HEAD_FAILURES;
21
23
  const buffer = [];
22
24
  let flushInFlight = null;
23
25
  let headInFlight = false;
26
+ let headInFlightId;
27
+ let headFailureCount = 0;
24
28
  const notifyDepth = () => {
25
29
  opts?.onDepth?.(buffer.length);
26
30
  };
@@ -35,14 +39,25 @@ function createInMemoryXAPIQueue(opts) {
35
39
  while (buffer.length) {
36
40
  const statement = buffer[0];
37
41
  headInFlight = true;
42
+ headInFlightId = statement.id;
38
43
  try {
39
44
  await transport(statement);
40
45
  buffer.shift();
46
+ headFailureCount = 0;
41
47
  notifyDepth();
42
- } catch {
43
- return;
48
+ } catch (err) {
49
+ headFailureCount += 1;
50
+ if (headFailureCount >= maxHeadFailures) {
51
+ buffer.shift();
52
+ headFailureCount = 0;
53
+ notifyDepth();
54
+ opts?.onHeadSkipped?.(statement, err);
55
+ continue;
56
+ }
57
+ throw err;
44
58
  } finally {
45
59
  headInFlight = false;
60
+ headInFlightId = void 0;
46
61
  }
47
62
  }
48
63
  };
@@ -51,12 +66,10 @@ function createInMemoryXAPIQueue(opts) {
51
66
  const normalized = withStatementId(statement);
52
67
  if (buffer.some((s) => s.id === normalized.id)) return;
53
68
  if (buffer.length >= maxSize) {
54
- if (headInFlight && buffer.length <= 1) {
55
- opts?.onCap?.();
56
- return;
57
- }
58
69
  if (headInFlight) {
59
- buffer.splice(1, 1);
70
+ if (buffer.length > 1) {
71
+ buffer.splice(1, 1);
72
+ }
60
73
  } else {
61
74
  buffer.shift();
62
75
  }
@@ -76,21 +89,16 @@ function createInMemoryXAPIQueue(opts) {
76
89
  return flushInFlight;
77
90
  },
78
91
  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];
92
+ for (const statement of buffer) {
82
93
  try {
83
94
  exitTransport(statement);
84
95
  } catch {
85
96
  }
86
97
  }
87
- if (startIdx === 0) {
88
- buffer.length = 0;
89
- } else if (buffer.length > 1) {
90
- buffer.splice(1);
91
- }
98
+ buffer.length = 0;
92
99
  notifyDepth();
93
- }
100
+ },
101
+ getHeadInFlightId: () => headInFlightId
94
102
  };
95
103
  }
96
104
 
@@ -240,7 +248,33 @@ var TELEMETRY_XAPI_MAPPERS = {
240
248
  hotspot_opened: experiencedBlockMapper,
241
249
  accordion_section_toggled: experiencedBlockMapper,
242
250
  flashcard_flipped: experiencedBlockMapper,
243
- image_slider_changed: experiencedBlockMapper
251
+ image_slider_changed: experiencedBlockMapper,
252
+ video_cue_reached: experiencedBlockMapper,
253
+ video_segment_completed: (event, ctx) => {
254
+ if (event.name !== "video_segment_completed") return null;
255
+ const lessonId = event.lessonId;
256
+ const blockId = event.data.blockId;
257
+ if (!lessonId || !blockId) return null;
258
+ return statementFor(
259
+ buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId }),
260
+ XAPIVerbs.completed,
261
+ ctx.timestamp
262
+ );
263
+ },
264
+ memory_card_flipped: experiencedBlockMapper,
265
+ information_wall_search: experiencedBlockMapper,
266
+ parallax_slide_viewed: experiencedBlockMapper,
267
+ questionnaire_submitted: (event, ctx) => {
268
+ if (event.name !== "questionnaire_submitted") return null;
269
+ const lessonId = event.lessonId;
270
+ const blockId = event.data.blockId;
271
+ if (!lessonId || !blockId) return null;
272
+ return statementFor(
273
+ buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId }),
274
+ XAPIVerbs.completed,
275
+ ctx.timestamp
276
+ );
277
+ }
244
278
  };
245
279
  function telemetryEventToXAPIStatement(event) {
246
280
  const mapper = TELEMETRY_XAPI_MAPPERS[event.name];
@@ -280,9 +314,16 @@ function createXAPIClient(opts) {
280
314
  let warnedTransportFailure = false;
281
315
  const inflightById = /* @__PURE__ */ new Map();
282
316
  const inflightStatements = /* @__PURE__ */ new Map();
317
+ const exitDeliveredIds = /* @__PURE__ */ new Set();
318
+ const exitNetworkSentIds = /* @__PURE__ */ new Set();
319
+ const deliveryTransport = transport ? async (statement) => {
320
+ if (exitNetworkSentIds.has(statement.id)) return;
321
+ await transport(statement);
322
+ } : void 0;
283
323
  const sendOrQueue = (statement) => {
284
324
  const normalized = withStatementId2(statement);
285
- if (!transport) {
325
+ if (exitDeliveredIds.has(normalized.id)) return;
326
+ if (!deliveryTransport) {
286
327
  queue.enqueue(normalized);
287
328
  if (isDevEnvironment() && !warnedNoTransport) {
288
329
  warnedNoTransport = true;
@@ -304,17 +345,19 @@ function createXAPIClient(opts) {
304
345
  }
305
346
  inflightStatements.set(normalized.id, normalized);
306
347
  const flight = Promise.resolve().then(async () => {
307
- await transport(normalized);
348
+ await deliveryTransport(normalized);
308
349
  queue.removeById(normalized.id);
309
- }).catch(() => {
350
+ }).catch((err) => {
351
+ if (exitDeliveredIds.has(normalized.id)) return;
310
352
  queue.enqueue(normalized);
353
+ opts?.onTransportError?.(err);
311
354
  if (isDevEnvironment() && !warnedTransportFailure) {
312
355
  warnedTransportFailure = true;
313
356
  console.warn(
314
357
  "[lessonkit] xAPI transport failed; statement re-queued. Check your LRS endpoint or transport implementation."
315
358
  );
316
359
  }
317
- throw new Error("xAPI transport failed");
360
+ throw err instanceof Error ? err : new Error("xAPI transport failed", { cause: err });
318
361
  }).finally(() => {
319
362
  inflightById.delete(normalized.id);
320
363
  inflightStatements.delete(normalized.id);
@@ -343,27 +386,36 @@ function createXAPIClient(opts) {
343
386
  },
344
387
  queueSize: () => queue.size(),
345
388
  flush: async () => {
346
- if (!transport) return;
347
- await queue.flush(transport);
389
+ if (!deliveryTransport) return;
390
+ await queue.flush(deliveryTransport);
348
391
  const flights = [...inflightById.values()];
349
392
  if (flights.length > 0) {
350
- await Promise.allSettled(flights);
393
+ await Promise.all(flights);
394
+ }
395
+ if (queue.size() > 0) {
396
+ throw new Error("xAPI flush incomplete: statements remain queued after flush");
351
397
  }
352
398
  },
353
399
  flushOnExit: exitTransport ? () => {
354
- const exitSentIds = /* @__PURE__ */ new Set();
400
+ const headId = queue.getHeadInFlightId?.();
401
+ if (headId) {
402
+ exitNetworkSentIds.add(headId);
403
+ exitDeliveredIds.add(headId);
404
+ opts.abortInFlight?.(headId);
405
+ }
355
406
  for (const statement of inflightStatements.values()) {
356
- if (exitSentIds.has(statement.id)) continue;
407
+ exitNetworkSentIds.add(statement.id);
408
+ exitDeliveredIds.add(statement.id);
409
+ opts.abortInFlight?.(statement.id);
410
+ }
411
+ queue.flushOnExit((statement) => {
412
+ if (exitDeliveredIds.has(statement.id)) return;
413
+ exitNetworkSentIds.add(statement.id);
414
+ exitDeliveredIds.add(statement.id);
357
415
  try {
358
416
  exitTransport(statement);
359
- exitSentIds.add(statement.id);
360
417
  } catch {
361
418
  }
362
- }
363
- queue.flushOnExit((statement) => {
364
- if (exitSentIds.has(statement.id)) return;
365
- exitTransport(statement);
366
- exitSentIds.add(statement.id);
367
419
  });
368
420
  } : void 0,
369
421
  startedLesson: ({ lessonId }) => {
@@ -404,58 +456,100 @@ function createXAPIClient(opts) {
404
456
  }
405
457
 
406
458
  // src/fetchTransport.ts
459
+ var FetchHttpError = class extends Error {
460
+ status;
461
+ constructor(status, statusText, kind = "xapi") {
462
+ super(
463
+ kind === "xapi" ? `xAPI fetch failed: ${status} ${statusText}` : `telemetry batch fetch failed: ${status} ${statusText}`
464
+ );
465
+ this.name = "FetchHttpError";
466
+ this.status = status;
467
+ }
468
+ };
469
+ function isRetryableFetchHttpStatus(status) {
470
+ return status === 429 || status >= 500;
471
+ }
472
+ function isRetryableFetchError(err) {
473
+ if (err instanceof FetchHttpError) return isRetryableFetchHttpStatus(err.status);
474
+ return true;
475
+ }
407
476
  function resolveHeaders(headers) {
408
477
  if (!headers) return { "Content-Type": "application/json" };
409
478
  const resolved = typeof headers === "function" ? headers() : headers;
410
479
  return { "Content-Type": "application/json", ...resolved };
411
480
  }
412
481
  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
- }
482
+ if (timeoutMs <= 0) return { signal: void 0, abort: () => {
483
+ } };
418
484
  const controller = new AbortController();
419
485
  const timer = setTimeout(() => controller.abort(), timeoutMs);
420
486
  timer.unref?.();
421
- return controller.signal;
487
+ return {
488
+ signal: controller.signal,
489
+ abort: () => {
490
+ clearTimeout(timer);
491
+ controller.abort();
492
+ }
493
+ };
422
494
  }
423
495
  function sleep(ms) {
424
496
  return new Promise((resolve) => setTimeout(resolve, ms));
425
497
  }
426
498
  function postStatement(url, statement, init) {
427
499
  return fetch(url, {
500
+ ...init,
428
501
  method: "POST",
429
- body: JSON.stringify(statement),
430
- ...init
502
+ body: JSON.stringify(statement)
431
503
  }).then((res) => {
432
504
  if (!res.ok) {
433
- throw new Error(`xAPI fetch failed: ${res.status} ${res.statusText}`);
505
+ throw new FetchHttpError(res.status, res.statusText, "xapi");
434
506
  }
435
507
  });
436
508
  }
509
+ async function postWithRetry(post, retries, initialBackoffMs, maxBackoffMs) {
510
+ let attempt = 0;
511
+ let backoff = initialBackoffMs;
512
+ for (; ; ) {
513
+ try {
514
+ await post();
515
+ return;
516
+ } catch (err) {
517
+ if (!isRetryableFetchError(err) || attempt >= retries) throw err;
518
+ await sleep(backoff);
519
+ backoff = Math.min(backoff * 2, maxBackoffMs);
520
+ attempt += 1;
521
+ }
522
+ }
523
+ }
437
524
  function createFetchTransport(opts) {
438
525
  const timeoutMs = opts.timeoutMs ?? 3e4;
439
- const retries = opts.retries ?? 2;
526
+ const rawRetries = opts.retries ?? 2;
527
+ const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
440
528
  const initialBackoffMs = opts.backoffMs ?? 250;
441
529
  const maxBackoffMs = opts.maxBackoffMs ?? 5e3;
530
+ const activeControllers = /* @__PURE__ */ new Map();
442
531
  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
- }
532
+ let abortCleanup;
533
+ activeControllers.set(statement.id, {
534
+ abort: () => abortCleanup?.()
535
+ });
536
+ try {
537
+ await postWithRetry(
538
+ () => {
539
+ const { signal, abort } = createAbortSignal(timeoutMs);
540
+ abortCleanup = abort;
541
+ return postStatement(opts.url, statement, {
542
+ ...opts.init,
543
+ headers: resolveHeaders(opts.headers),
544
+ signal
545
+ });
546
+ },
547
+ retries,
548
+ initialBackoffMs,
549
+ maxBackoffMs
550
+ );
551
+ } finally {
552
+ activeControllers.delete(statement.id);
459
553
  }
460
554
  };
461
555
  const exitTransport = (statement) => {
@@ -468,36 +562,32 @@ function createFetchTransport(opts) {
468
562
  } catch {
469
563
  }
470
564
  };
471
- return { transport, exitTransport };
565
+ const abortInFlight = (statementId) => {
566
+ activeControllers.get(statementId)?.abort();
567
+ activeControllers.delete(statementId);
568
+ };
569
+ return { transport, exitTransport, abortInFlight };
472
570
  }
473
571
  function createFetchBatchSink(opts) {
474
572
  const timeoutMs = opts.timeoutMs ?? 3e4;
475
- const retries = opts.retries ?? 2;
573
+ const rawRetries = opts.retries ?? 2;
574
+ const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
476
575
  const initialBackoffMs = opts.backoffMs ?? 250;
477
576
  const maxBackoffMs = opts.maxBackoffMs ?? 5e3;
478
577
  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;
578
+ await postWithRetry(async () => {
579
+ const { signal } = createAbortSignal(timeoutMs);
580
+ const res = await fetch(opts.url, {
581
+ ...init,
582
+ method: "POST",
583
+ body: JSON.stringify(events),
584
+ headers: resolveHeaders(opts.headers),
585
+ signal
586
+ });
587
+ if (!res.ok) {
588
+ throw new FetchHttpError(res.status, res.statusText, "batch");
499
589
  }
500
- }
590
+ }, retries, initialBackoffMs, maxBackoffMs);
501
591
  };
502
592
  return {
503
593
  batchSink: (events) => postBatch(events, opts.init ?? {}),
@@ -516,9 +606,12 @@ function createFetchBatchSink(opts) {
516
606
  };
517
607
  }
518
608
  export {
609
+ FetchHttpError,
519
610
  createFetchBatchSink,
520
611
  createFetchTransport,
521
612
  createInMemoryXAPIQueue,
522
613
  createXAPIClient,
614
+ isRetryableFetchError,
615
+ isRetryableFetchHttpStatus,
523
616
  telemetryEventToXAPIStatement
524
617
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessonkit/xapi",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "private": false,
5
5
  "description": "xAPI statement generation primitives for LessonKit.",
6
6
  "license": "Apache-2.0",
@@ -48,7 +48,7 @@
48
48
  "lint": "echo \"(no lint configured yet)\""
49
49
  },
50
50
  "dependencies": {
51
- "@lessonkit/core": "1.3.1"
51
+ "@lessonkit/core": "1.4.0"
52
52
  },
53
53
  "devDependencies": {
54
54
  "tsup": "^8.5.0",