@lessonkit/xapi 1.3.0 → 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/README.md CHANGED
@@ -17,27 +17,41 @@ npm install @lessonkit/xapi @lessonkit/core
17
17
  ## Usage
18
18
 
19
19
  ```typescript
20
- import { createXAPIClient, telemetryEventToXAPIStatement } from "@lessonkit/xapi";
20
+ import { createFetchTransport, createXAPIClient, telemetryEventToXAPIStatement } from "@lessonkit/xapi";
21
+
22
+ const { transport, exitTransport } = createFetchTransport({
23
+ url: "/api/xapi/statements",
24
+ timeoutMs: 30_000,
25
+ });
21
26
 
22
27
  const xapi = createXAPIClient({
23
28
  courseId: "my-course",
24
- transport: async (statement) => {
25
- await fetch("/xapi/statements", { method: "POST", body: JSON.stringify(statement) });
26
- },
29
+ transport,
30
+ exitTransport,
27
31
  });
28
32
 
29
33
  xapi.completeLesson({ lessonId: "lesson-1", durationMs: 1200, success: true });
30
34
  await xapi.flush();
35
+ xapi.flushOnExit?.(); // pagehide keepalive delivery
31
36
  ```
32
37
 
33
38
  Map from telemetry events: `telemetryEventToXAPIStatement(event)` — uses canonical LessonKit URNs.
34
39
 
40
+ Batch analytics sink:
41
+
42
+ ```typescript
43
+ import { createFetchBatchSink } from "@lessonkit/xapi";
44
+
45
+ const { batchSink, exitBatchSink } = createFetchBatchSink({ url: "/api/telemetry/batch" });
46
+ ```
47
+
35
48
  ## Behavior
36
49
 
37
50
  - No transport → statements queue in memory (dev warns once).
38
51
  - Transport failure → re-queue; call `flush()` to retry.
39
52
  - Queue capped at **1000** statements by default; oldest dropped when full (`onCap` / `createInMemoryXAPIQueue({ onCap })`).
40
53
  - Concurrent `flush()` calls are coalesced.
54
+ - `createFetchTransport` retries with exponential backoff and uses `AbortSignal.timeout` when available.
41
55
 
42
56
  ## Docs
43
57
 
package/dist/index.cjs CHANGED
@@ -20,8 +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,
24
+ createFetchBatchSink: () => createFetchBatchSink,
25
+ createFetchTransport: () => createFetchTransport,
23
26
  createInMemoryXAPIQueue: () => createInMemoryXAPIQueue,
24
27
  createXAPIClient: () => createXAPIClient,
28
+ isRetryableFetchError: () => isRetryableFetchError,
29
+ isRetryableFetchHttpStatus: () => isRetryableFetchHttpStatus,
25
30
  telemetryEventToXAPIStatement: () => telemetryEventToXAPIStatement
26
31
  });
27
32
  module.exports = __toCommonJS(index_exports);
@@ -44,26 +49,48 @@ function withStatementId(statement) {
44
49
  return statement;
45
50
  }
46
51
  var DEFAULT_MAX_QUEUE_SIZE = 1e3;
52
+ var DEFAULT_MAX_HEAD_FAILURES = 10;
47
53
  function createInMemoryXAPIQueue(opts) {
48
54
  const maxSize = opts?.maxSize ?? DEFAULT_MAX_QUEUE_SIZE;
55
+ const maxHeadFailures = opts?.maxHeadFailures ?? DEFAULT_MAX_HEAD_FAILURES;
49
56
  const buffer = [];
50
57
  let flushInFlight = null;
51
58
  let headInFlight = false;
59
+ let headInFlightId;
60
+ let headFailureCount = 0;
52
61
  const notifyDepth = () => {
53
62
  opts?.onDepth?.(buffer.length);
54
63
  };
64
+ const removeById = (id) => {
65
+ const idx = buffer.findIndex((s) => s.id === id);
66
+ if (idx >= 0) {
67
+ buffer.splice(idx, 1);
68
+ notifyDepth();
69
+ }
70
+ };
55
71
  const runFlush = async (transport) => {
56
72
  while (buffer.length) {
57
73
  const statement = buffer[0];
58
74
  headInFlight = true;
75
+ headInFlightId = statement.id;
59
76
  try {
60
77
  await transport(statement);
61
78
  buffer.shift();
79
+ headFailureCount = 0;
62
80
  notifyDepth();
63
- } catch {
64
- 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;
65
91
  } finally {
66
92
  headInFlight = false;
93
+ headInFlightId = void 0;
67
94
  }
68
95
  }
69
96
  };
@@ -72,12 +99,10 @@ function createInMemoryXAPIQueue(opts) {
72
99
  const normalized = withStatementId(statement);
73
100
  if (buffer.some((s) => s.id === normalized.id)) return;
74
101
  if (buffer.length >= maxSize) {
75
- if (headInFlight && buffer.length <= 1) {
76
- opts?.onCap?.();
77
- return;
78
- }
79
102
  if (headInFlight) {
80
- buffer.splice(1, 1);
103
+ if (buffer.length > 1) {
104
+ buffer.splice(1, 1);
105
+ }
81
106
  } else {
82
107
  buffer.shift();
83
108
  }
@@ -86,6 +111,7 @@ function createInMemoryXAPIQueue(opts) {
86
111
  buffer.push(normalized);
87
112
  notifyDepth();
88
113
  },
114
+ removeById,
89
115
  size: () => buffer.length,
90
116
  flush: async (transport) => {
91
117
  if (flushInFlight) return flushInFlight;
@@ -94,7 +120,18 @@ function createInMemoryXAPIQueue(opts) {
94
120
  flushInFlight = null;
95
121
  });
96
122
  return flushInFlight;
97
- }
123
+ },
124
+ flushOnExit: (exitTransport) => {
125
+ for (const statement of buffer) {
126
+ try {
127
+ exitTransport(statement);
128
+ } catch {
129
+ }
130
+ }
131
+ buffer.length = 0;
132
+ notifyDepth();
133
+ },
134
+ getHeadInFlightId: () => headInFlightId
98
135
  };
99
136
  }
100
137
 
@@ -244,7 +281,33 @@ var TELEMETRY_XAPI_MAPPERS = {
244
281
  hotspot_opened: experiencedBlockMapper,
245
282
  accordion_section_toggled: experiencedBlockMapper,
246
283
  flashcard_flipped: experiencedBlockMapper,
247
- 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
+ }
248
311
  };
249
312
  function telemetryEventToXAPIStatement(event) {
250
313
  const mapper = TELEMETRY_XAPI_MAPPERS[event.name];
@@ -273,6 +336,7 @@ function isDevEnvironment() {
273
336
  }
274
337
  function createXAPIClient(opts) {
275
338
  const transport = opts?.transport;
339
+ const exitTransport = opts?.exitTransport;
276
340
  const courseId = opts?.courseId;
277
341
  const queue = opts?.queue ?? createInMemoryXAPIQueue({
278
342
  maxSize: opts?.maxQueueSize,
@@ -282,9 +346,17 @@ function createXAPIClient(opts) {
282
346
  let warnedNoTransport = false;
283
347
  let warnedTransportFailure = false;
284
348
  const inflightById = /* @__PURE__ */ new Map();
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;
285
356
  const sendOrQueue = (statement) => {
286
357
  const normalized = withStatementId2(statement);
287
- if (!transport) {
358
+ if (exitDeliveredIds.has(normalized.id)) return;
359
+ if (!deliveryTransport) {
288
360
  queue.enqueue(normalized);
289
361
  if (isDevEnvironment() && !warnedNoTransport) {
290
362
  warnedNoTransport = true;
@@ -304,17 +376,24 @@ function createXAPIClient(opts) {
304
376
  );
305
377
  return;
306
378
  }
307
- const flight = Promise.resolve().then(() => transport(normalized)).catch(() => {
379
+ inflightStatements.set(normalized.id, normalized);
380
+ const flight = Promise.resolve().then(async () => {
381
+ await deliveryTransport(normalized);
382
+ queue.removeById(normalized.id);
383
+ }).catch((err) => {
384
+ if (exitDeliveredIds.has(normalized.id)) return;
308
385
  queue.enqueue(normalized);
386
+ opts?.onTransportError?.(err);
309
387
  if (isDevEnvironment() && !warnedTransportFailure) {
310
388
  warnedTransportFailure = true;
311
389
  console.warn(
312
390
  "[lessonkit] xAPI transport failed; statement re-queued. Check your LRS endpoint or transport implementation."
313
391
  );
314
392
  }
315
- throw new Error("xAPI transport failed");
393
+ throw err instanceof Error ? err : new Error("xAPI transport failed", { cause: err });
316
394
  }).finally(() => {
317
395
  inflightById.delete(normalized.id);
396
+ inflightStatements.delete(normalized.id);
318
397
  });
319
398
  inflightById.set(normalized.id, flight);
320
399
  void flight.catch(() => {
@@ -340,13 +419,38 @@ function createXAPIClient(opts) {
340
419
  },
341
420
  queueSize: () => queue.size(),
342
421
  flush: async () => {
343
- if (!transport) return;
344
- await queue.flush(transport);
422
+ if (!deliveryTransport) return;
423
+ await queue.flush(deliveryTransport);
345
424
  const flights = [...inflightById.values()];
346
425
  if (flights.length > 0) {
347
- 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");
348
430
  }
349
431
  },
432
+ flushOnExit: exitTransport ? () => {
433
+ const headId = queue.getHeadInFlightId?.();
434
+ if (headId) {
435
+ exitNetworkSentIds.add(headId);
436
+ exitDeliveredIds.add(headId);
437
+ opts.abortInFlight?.(headId);
438
+ }
439
+ for (const statement of inflightStatements.values()) {
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);
448
+ try {
449
+ exitTransport(statement);
450
+ } catch {
451
+ }
452
+ });
453
+ } : void 0,
350
454
  startedLesson: ({ lessonId }) => {
351
455
  if (!courseId) return;
352
456
  emit({
@@ -383,9 +487,165 @@ function createXAPIClient(opts) {
383
487
  }
384
488
  };
385
489
  }
490
+
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
+ }
509
+ function resolveHeaders(headers) {
510
+ if (!headers) return { "Content-Type": "application/json" };
511
+ const resolved = typeof headers === "function" ? headers() : headers;
512
+ return { "Content-Type": "application/json", ...resolved };
513
+ }
514
+ function createAbortSignal(timeoutMs) {
515
+ if (timeoutMs <= 0) return { signal: void 0, abort: () => {
516
+ } };
517
+ const controller = new AbortController();
518
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
519
+ timer.unref?.();
520
+ return {
521
+ signal: controller.signal,
522
+ abort: () => {
523
+ clearTimeout(timer);
524
+ controller.abort();
525
+ }
526
+ };
527
+ }
528
+ function sleep(ms) {
529
+ return new Promise((resolve) => setTimeout(resolve, ms));
530
+ }
531
+ function postStatement(url, statement, init) {
532
+ return fetch(url, {
533
+ ...init,
534
+ method: "POST",
535
+ body: JSON.stringify(statement)
536
+ }).then((res) => {
537
+ if (!res.ok) {
538
+ throw new FetchHttpError(res.status, res.statusText, "xapi");
539
+ }
540
+ });
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
+ }
557
+ function createFetchTransport(opts) {
558
+ const timeoutMs = opts.timeoutMs ?? 3e4;
559
+ const rawRetries = opts.retries ?? 2;
560
+ const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
561
+ const initialBackoffMs = opts.backoffMs ?? 250;
562
+ const maxBackoffMs = opts.maxBackoffMs ?? 5e3;
563
+ const activeControllers = /* @__PURE__ */ new Map();
564
+ const transport = async (statement) => {
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);
586
+ }
587
+ };
588
+ const exitTransport = (statement) => {
589
+ try {
590
+ void postStatement(opts.url, statement, {
591
+ ...opts.init,
592
+ headers: resolveHeaders(opts.headers),
593
+ keepalive: true
594
+ }).catch(() => void 0);
595
+ } catch {
596
+ }
597
+ };
598
+ const abortInFlight = (statementId) => {
599
+ activeControllers.get(statementId)?.abort();
600
+ activeControllers.delete(statementId);
601
+ };
602
+ return { transport, exitTransport, abortInFlight };
603
+ }
604
+ function createFetchBatchSink(opts) {
605
+ const timeoutMs = opts.timeoutMs ?? 3e4;
606
+ const rawRetries = opts.retries ?? 2;
607
+ const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
608
+ const initialBackoffMs = opts.backoffMs ?? 250;
609
+ const maxBackoffMs = opts.maxBackoffMs ?? 5e3;
610
+ const postBatch = async (events, init) => {
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");
622
+ }
623
+ }, retries, initialBackoffMs, maxBackoffMs);
624
+ };
625
+ return {
626
+ batchSink: (events) => postBatch(events, opts.init ?? {}),
627
+ exitBatchSink: (events) => {
628
+ try {
629
+ void fetch(opts.url, {
630
+ method: "POST",
631
+ body: JSON.stringify(events),
632
+ ...opts.init,
633
+ headers: resolveHeaders(opts.headers),
634
+ keepalive: true
635
+ }).catch(() => void 0);
636
+ } catch {
637
+ }
638
+ }
639
+ };
640
+ }
386
641
  // Annotate the CommonJS export names for ESM import in node:
387
642
  0 && (module.exports = {
643
+ FetchHttpError,
644
+ createFetchBatchSink,
645
+ createFetchTransport,
388
646
  createInMemoryXAPIQueue,
389
647
  createXAPIClient,
648
+ isRetryableFetchError,
649
+ isRetryableFetchHttpStatus,
390
650
  telemetryEventToXAPIStatement
391
651
  });
package/dist/index.d.cts CHANGED
@@ -32,12 +32,20 @@ type XAPIStatement = {
32
32
  type XAPITransport = (statement: XAPIStatement) => void | Promise<void>;
33
33
  type XAPIQueue = {
34
34
  enqueue: (statement: XAPIStatement) => void;
35
+ /** Remove a queued statement by id (e.g. after successful direct transport). */
36
+ removeById: (id: string) => void;
35
37
  flush: (transport: XAPITransport) => Promise<void>;
38
+ flushOnExit: (exitTransport: XAPIExitTransport) => void;
36
39
  size: () => number;
40
+ /** Statement id currently being delivered via flush, if any. */
41
+ getHeadInFlightId?: () => string | undefined;
37
42
  };
43
+ type XAPIExitTransport = (statement: XAPIStatement) => void;
38
44
  type XAPIClient = {
39
45
  send: (statement: XAPIStatement) => void;
40
46
  flush: () => Promise<void>;
47
+ /** Best-effort synchronous flush for pagehide using keepalive transport when configured. */
48
+ flushOnExit?: () => void;
41
49
  queueSize: () => number;
42
50
  startedLesson: (opts: {
43
51
  lessonId: LessonId;
@@ -59,23 +67,80 @@ type InMemoryXAPIQueueOptions = {
59
67
  onDepth?: (size: number) => void;
60
68
  /** Called when an oldest statement is dropped because the queue is at maxSize. */
61
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;
62
74
  };
63
75
  declare function createInMemoryXAPIQueue(opts?: InMemoryXAPIQueueOptions): XAPIQueue;
64
76
 
65
77
  declare function createXAPIClient(opts?: {
66
78
  transport?: XAPITransport;
79
+ /** Keepalive transport for pagehide flush (e.g. from createFetchTransport). */
80
+ exitTransport?: XAPIExitTransport;
81
+ /** Abort in-flight transport by statement id (e.g. from createFetchTransport). */
82
+ abortInFlight?: (statementId: string) => void;
67
83
  courseId?: CourseId;
68
84
  queue?: XAPIQueue;
69
85
  /** When creating the default in-memory queue (max size 1000 unless overridden). */
70
86
  maxQueueSize?: number;
71
87
  onQueueDepth?: (size: number) => void;
72
88
  onQueueCap?: () => void;
89
+ /** Called when transport fails after retries (statement is re-queued). */
90
+ onTransportError?: (err: unknown) => void;
73
91
  }): XAPIClient;
74
92
 
93
+ type CreateFetchTransportOptions = {
94
+ /** LRS or proxy endpoint (POST). */
95
+ url: string;
96
+ /** Per-request timeout (default 30_000 ms). Uses AbortSignal.timeout when available. */
97
+ timeoutMs?: number;
98
+ /** Static headers merged into each request (e.g. Authorization from a short-lived token). */
99
+ headers?: Record<string, string> | (() => Record<string, string>);
100
+ /** Retries after transport failure (default 2). */
101
+ retries?: number;
102
+ /** Initial backoff in ms (default 250). Doubles each retry up to maxBackoffMs. */
103
+ backoffMs?: number;
104
+ /** Maximum backoff in ms (default 5_000). */
105
+ maxBackoffMs?: number;
106
+ /** Extra fetch init merged into each request. */
107
+ init?: Omit<RequestInit, "method" | "body" | "signal" | "keepalive">;
108
+ };
109
+ type FetchTransportBundle = {
110
+ transport: XAPITransport;
111
+ /** Best-effort synchronous delivery for pagehide (keepalive fetch). */
112
+ exitTransport: (statement: XAPIStatement) => void;
113
+ /** Abort an in-flight transport request by statement id (used on pagehide). */
114
+ abortInFlight: (statementId: string) => void;
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;
124
+ /**
125
+ * Creates an xAPI transport backed by fetch with timeout, retry backoff, and a
126
+ * keepalive exit transport for pagehide delivery.
127
+ */
128
+ declare function createFetchTransport(opts: CreateFetchTransportOptions): FetchTransportBundle;
129
+ type CreateFetchBatchSinkOptions = CreateFetchTransportOptions;
130
+ type FetchBatchSinkBundle = {
131
+ batchSink: (events: unknown[]) => Promise<void>;
132
+ /** Best-effort keepalive POST for pagehide (JSON array body). */
133
+ exitBatchSink: (events: unknown[]) => void;
134
+ };
135
+ /**
136
+ * Batch analytics sink with timeout, retry backoff, and keepalive exit delivery.
137
+ */
138
+ declare function createFetchBatchSink(opts: CreateFetchBatchSinkOptions): FetchBatchSinkBundle;
139
+
75
140
  /**
76
141
  * Map a LessonKit telemetry event to an xAPI statement, or null if the event should not emit xAPI.
77
142
  * `lesson_time_on_task` returns null (companion metric; lesson_completed carries duration).
78
143
  */
79
144
  declare function telemetryEventToXAPIStatement(event: TelemetryEvent): XAPIStatement | null;
80
145
 
81
- export { type InMemoryXAPIQueueOptions, type XAPIClient, type XAPIObjectDefinition, type XAPIQueue, type XAPIResult, type XAPIScore, type XAPIStatement, type XAPITransport, type XAPIVerbIri, 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
@@ -32,12 +32,20 @@ type XAPIStatement = {
32
32
  type XAPITransport = (statement: XAPIStatement) => void | Promise<void>;
33
33
  type XAPIQueue = {
34
34
  enqueue: (statement: XAPIStatement) => void;
35
+ /** Remove a queued statement by id (e.g. after successful direct transport). */
36
+ removeById: (id: string) => void;
35
37
  flush: (transport: XAPITransport) => Promise<void>;
38
+ flushOnExit: (exitTransport: XAPIExitTransport) => void;
36
39
  size: () => number;
40
+ /** Statement id currently being delivered via flush, if any. */
41
+ getHeadInFlightId?: () => string | undefined;
37
42
  };
43
+ type XAPIExitTransport = (statement: XAPIStatement) => void;
38
44
  type XAPIClient = {
39
45
  send: (statement: XAPIStatement) => void;
40
46
  flush: () => Promise<void>;
47
+ /** Best-effort synchronous flush for pagehide using keepalive transport when configured. */
48
+ flushOnExit?: () => void;
41
49
  queueSize: () => number;
42
50
  startedLesson: (opts: {
43
51
  lessonId: LessonId;
@@ -59,23 +67,80 @@ type InMemoryXAPIQueueOptions = {
59
67
  onDepth?: (size: number) => void;
60
68
  /** Called when an oldest statement is dropped because the queue is at maxSize. */
61
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;
62
74
  };
63
75
  declare function createInMemoryXAPIQueue(opts?: InMemoryXAPIQueueOptions): XAPIQueue;
64
76
 
65
77
  declare function createXAPIClient(opts?: {
66
78
  transport?: XAPITransport;
79
+ /** Keepalive transport for pagehide flush (e.g. from createFetchTransport). */
80
+ exitTransport?: XAPIExitTransport;
81
+ /** Abort in-flight transport by statement id (e.g. from createFetchTransport). */
82
+ abortInFlight?: (statementId: string) => void;
67
83
  courseId?: CourseId;
68
84
  queue?: XAPIQueue;
69
85
  /** When creating the default in-memory queue (max size 1000 unless overridden). */
70
86
  maxQueueSize?: number;
71
87
  onQueueDepth?: (size: number) => void;
72
88
  onQueueCap?: () => void;
89
+ /** Called when transport fails after retries (statement is re-queued). */
90
+ onTransportError?: (err: unknown) => void;
73
91
  }): XAPIClient;
74
92
 
93
+ type CreateFetchTransportOptions = {
94
+ /** LRS or proxy endpoint (POST). */
95
+ url: string;
96
+ /** Per-request timeout (default 30_000 ms). Uses AbortSignal.timeout when available. */
97
+ timeoutMs?: number;
98
+ /** Static headers merged into each request (e.g. Authorization from a short-lived token). */
99
+ headers?: Record<string, string> | (() => Record<string, string>);
100
+ /** Retries after transport failure (default 2). */
101
+ retries?: number;
102
+ /** Initial backoff in ms (default 250). Doubles each retry up to maxBackoffMs. */
103
+ backoffMs?: number;
104
+ /** Maximum backoff in ms (default 5_000). */
105
+ maxBackoffMs?: number;
106
+ /** Extra fetch init merged into each request. */
107
+ init?: Omit<RequestInit, "method" | "body" | "signal" | "keepalive">;
108
+ };
109
+ type FetchTransportBundle = {
110
+ transport: XAPITransport;
111
+ /** Best-effort synchronous delivery for pagehide (keepalive fetch). */
112
+ exitTransport: (statement: XAPIStatement) => void;
113
+ /** Abort an in-flight transport request by statement id (used on pagehide). */
114
+ abortInFlight: (statementId: string) => void;
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;
124
+ /**
125
+ * Creates an xAPI transport backed by fetch with timeout, retry backoff, and a
126
+ * keepalive exit transport for pagehide delivery.
127
+ */
128
+ declare function createFetchTransport(opts: CreateFetchTransportOptions): FetchTransportBundle;
129
+ type CreateFetchBatchSinkOptions = CreateFetchTransportOptions;
130
+ type FetchBatchSinkBundle = {
131
+ batchSink: (events: unknown[]) => Promise<void>;
132
+ /** Best-effort keepalive POST for pagehide (JSON array body). */
133
+ exitBatchSink: (events: unknown[]) => void;
134
+ };
135
+ /**
136
+ * Batch analytics sink with timeout, retry backoff, and keepalive exit delivery.
137
+ */
138
+ declare function createFetchBatchSink(opts: CreateFetchBatchSinkOptions): FetchBatchSinkBundle;
139
+
75
140
  /**
76
141
  * Map a LessonKit telemetry event to an xAPI statement, or null if the event should not emit xAPI.
77
142
  * `lesson_time_on_task` returns null (companion metric; lesson_completed carries duration).
78
143
  */
79
144
  declare function telemetryEventToXAPIStatement(event: TelemetryEvent): XAPIStatement | null;
80
145
 
81
- export { type InMemoryXAPIQueueOptions, type XAPIClient, type XAPIObjectDefinition, type XAPIQueue, type XAPIResult, type XAPIScore, type XAPIStatement, type XAPITransport, type XAPIVerbIri, 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,26 +16,48 @@ 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
  };
31
+ const removeById = (id) => {
32
+ const idx = buffer.findIndex((s) => s.id === id);
33
+ if (idx >= 0) {
34
+ buffer.splice(idx, 1);
35
+ notifyDepth();
36
+ }
37
+ };
27
38
  const runFlush = async (transport) => {
28
39
  while (buffer.length) {
29
40
  const statement = buffer[0];
30
41
  headInFlight = true;
42
+ headInFlightId = statement.id;
31
43
  try {
32
44
  await transport(statement);
33
45
  buffer.shift();
46
+ headFailureCount = 0;
34
47
  notifyDepth();
35
- } catch {
36
- 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;
37
58
  } finally {
38
59
  headInFlight = false;
60
+ headInFlightId = void 0;
39
61
  }
40
62
  }
41
63
  };
@@ -44,12 +66,10 @@ function createInMemoryXAPIQueue(opts) {
44
66
  const normalized = withStatementId(statement);
45
67
  if (buffer.some((s) => s.id === normalized.id)) return;
46
68
  if (buffer.length >= maxSize) {
47
- if (headInFlight && buffer.length <= 1) {
48
- opts?.onCap?.();
49
- return;
50
- }
51
69
  if (headInFlight) {
52
- buffer.splice(1, 1);
70
+ if (buffer.length > 1) {
71
+ buffer.splice(1, 1);
72
+ }
53
73
  } else {
54
74
  buffer.shift();
55
75
  }
@@ -58,6 +78,7 @@ function createInMemoryXAPIQueue(opts) {
58
78
  buffer.push(normalized);
59
79
  notifyDepth();
60
80
  },
81
+ removeById,
61
82
  size: () => buffer.length,
62
83
  flush: async (transport) => {
63
84
  if (flushInFlight) return flushInFlight;
@@ -66,7 +87,18 @@ function createInMemoryXAPIQueue(opts) {
66
87
  flushInFlight = null;
67
88
  });
68
89
  return flushInFlight;
69
- }
90
+ },
91
+ flushOnExit: (exitTransport) => {
92
+ for (const statement of buffer) {
93
+ try {
94
+ exitTransport(statement);
95
+ } catch {
96
+ }
97
+ }
98
+ buffer.length = 0;
99
+ notifyDepth();
100
+ },
101
+ getHeadInFlightId: () => headInFlightId
70
102
  };
71
103
  }
72
104
 
@@ -216,7 +248,33 @@ var TELEMETRY_XAPI_MAPPERS = {
216
248
  hotspot_opened: experiencedBlockMapper,
217
249
  accordion_section_toggled: experiencedBlockMapper,
218
250
  flashcard_flipped: experiencedBlockMapper,
219
- 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
+ }
220
278
  };
221
279
  function telemetryEventToXAPIStatement(event) {
222
280
  const mapper = TELEMETRY_XAPI_MAPPERS[event.name];
@@ -245,6 +303,7 @@ function isDevEnvironment() {
245
303
  }
246
304
  function createXAPIClient(opts) {
247
305
  const transport = opts?.transport;
306
+ const exitTransport = opts?.exitTransport;
248
307
  const courseId = opts?.courseId;
249
308
  const queue = opts?.queue ?? createInMemoryXAPIQueue({
250
309
  maxSize: opts?.maxQueueSize,
@@ -254,9 +313,17 @@ function createXAPIClient(opts) {
254
313
  let warnedNoTransport = false;
255
314
  let warnedTransportFailure = false;
256
315
  const inflightById = /* @__PURE__ */ new Map();
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;
257
323
  const sendOrQueue = (statement) => {
258
324
  const normalized = withStatementId2(statement);
259
- if (!transport) {
325
+ if (exitDeliveredIds.has(normalized.id)) return;
326
+ if (!deliveryTransport) {
260
327
  queue.enqueue(normalized);
261
328
  if (isDevEnvironment() && !warnedNoTransport) {
262
329
  warnedNoTransport = true;
@@ -276,17 +343,24 @@ function createXAPIClient(opts) {
276
343
  );
277
344
  return;
278
345
  }
279
- const flight = Promise.resolve().then(() => transport(normalized)).catch(() => {
346
+ inflightStatements.set(normalized.id, normalized);
347
+ const flight = Promise.resolve().then(async () => {
348
+ await deliveryTransport(normalized);
349
+ queue.removeById(normalized.id);
350
+ }).catch((err) => {
351
+ if (exitDeliveredIds.has(normalized.id)) return;
280
352
  queue.enqueue(normalized);
353
+ opts?.onTransportError?.(err);
281
354
  if (isDevEnvironment() && !warnedTransportFailure) {
282
355
  warnedTransportFailure = true;
283
356
  console.warn(
284
357
  "[lessonkit] xAPI transport failed; statement re-queued. Check your LRS endpoint or transport implementation."
285
358
  );
286
359
  }
287
- throw new Error("xAPI transport failed");
360
+ throw err instanceof Error ? err : new Error("xAPI transport failed", { cause: err });
288
361
  }).finally(() => {
289
362
  inflightById.delete(normalized.id);
363
+ inflightStatements.delete(normalized.id);
290
364
  });
291
365
  inflightById.set(normalized.id, flight);
292
366
  void flight.catch(() => {
@@ -312,13 +386,38 @@ function createXAPIClient(opts) {
312
386
  },
313
387
  queueSize: () => queue.size(),
314
388
  flush: async () => {
315
- if (!transport) return;
316
- await queue.flush(transport);
389
+ if (!deliveryTransport) return;
390
+ await queue.flush(deliveryTransport);
317
391
  const flights = [...inflightById.values()];
318
392
  if (flights.length > 0) {
319
- 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");
320
397
  }
321
398
  },
399
+ flushOnExit: exitTransport ? () => {
400
+ const headId = queue.getHeadInFlightId?.();
401
+ if (headId) {
402
+ exitNetworkSentIds.add(headId);
403
+ exitDeliveredIds.add(headId);
404
+ opts.abortInFlight?.(headId);
405
+ }
406
+ for (const statement of inflightStatements.values()) {
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);
415
+ try {
416
+ exitTransport(statement);
417
+ } catch {
418
+ }
419
+ });
420
+ } : void 0,
322
421
  startedLesson: ({ lessonId }) => {
323
422
  if (!courseId) return;
324
423
  emit({
@@ -355,8 +454,164 @@ function createXAPIClient(opts) {
355
454
  }
356
455
  };
357
456
  }
457
+
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
+ }
476
+ function resolveHeaders(headers) {
477
+ if (!headers) return { "Content-Type": "application/json" };
478
+ const resolved = typeof headers === "function" ? headers() : headers;
479
+ return { "Content-Type": "application/json", ...resolved };
480
+ }
481
+ function createAbortSignal(timeoutMs) {
482
+ if (timeoutMs <= 0) return { signal: void 0, abort: () => {
483
+ } };
484
+ const controller = new AbortController();
485
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
486
+ timer.unref?.();
487
+ return {
488
+ signal: controller.signal,
489
+ abort: () => {
490
+ clearTimeout(timer);
491
+ controller.abort();
492
+ }
493
+ };
494
+ }
495
+ function sleep(ms) {
496
+ return new Promise((resolve) => setTimeout(resolve, ms));
497
+ }
498
+ function postStatement(url, statement, init) {
499
+ return fetch(url, {
500
+ ...init,
501
+ method: "POST",
502
+ body: JSON.stringify(statement)
503
+ }).then((res) => {
504
+ if (!res.ok) {
505
+ throw new FetchHttpError(res.status, res.statusText, "xapi");
506
+ }
507
+ });
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
+ }
524
+ function createFetchTransport(opts) {
525
+ const timeoutMs = opts.timeoutMs ?? 3e4;
526
+ const rawRetries = opts.retries ?? 2;
527
+ const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
528
+ const initialBackoffMs = opts.backoffMs ?? 250;
529
+ const maxBackoffMs = opts.maxBackoffMs ?? 5e3;
530
+ const activeControllers = /* @__PURE__ */ new Map();
531
+ const transport = async (statement) => {
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);
553
+ }
554
+ };
555
+ const exitTransport = (statement) => {
556
+ try {
557
+ void postStatement(opts.url, statement, {
558
+ ...opts.init,
559
+ headers: resolveHeaders(opts.headers),
560
+ keepalive: true
561
+ }).catch(() => void 0);
562
+ } catch {
563
+ }
564
+ };
565
+ const abortInFlight = (statementId) => {
566
+ activeControllers.get(statementId)?.abort();
567
+ activeControllers.delete(statementId);
568
+ };
569
+ return { transport, exitTransport, abortInFlight };
570
+ }
571
+ function createFetchBatchSink(opts) {
572
+ const timeoutMs = opts.timeoutMs ?? 3e4;
573
+ const rawRetries = opts.retries ?? 2;
574
+ const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
575
+ const initialBackoffMs = opts.backoffMs ?? 250;
576
+ const maxBackoffMs = opts.maxBackoffMs ?? 5e3;
577
+ const postBatch = async (events, init) => {
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");
589
+ }
590
+ }, retries, initialBackoffMs, maxBackoffMs);
591
+ };
592
+ return {
593
+ batchSink: (events) => postBatch(events, opts.init ?? {}),
594
+ exitBatchSink: (events) => {
595
+ try {
596
+ void fetch(opts.url, {
597
+ method: "POST",
598
+ body: JSON.stringify(events),
599
+ ...opts.init,
600
+ headers: resolveHeaders(opts.headers),
601
+ keepalive: true
602
+ }).catch(() => void 0);
603
+ } catch {
604
+ }
605
+ }
606
+ };
607
+ }
358
608
  export {
609
+ FetchHttpError,
610
+ createFetchBatchSink,
611
+ createFetchTransport,
359
612
  createInMemoryXAPIQueue,
360
613
  createXAPIClient,
614
+ isRetryableFetchError,
615
+ isRetryableFetchHttpStatus,
361
616
  telemetryEventToXAPIStatement
362
617
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessonkit/xapi",
3
- "version": "1.3.0",
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.0"
51
+ "@lessonkit/core": "1.4.0"
52
52
  },
53
53
  "devDependencies": {
54
54
  "tsup": "^8.5.0",