@lessonkit/xapi 1.2.0 → 1.3.1

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,43 +20,82 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ createFetchBatchSink: () => createFetchBatchSink,
24
+ createFetchTransport: () => createFetchTransport,
23
25
  createInMemoryXAPIQueue: () => createInMemoryXAPIQueue,
24
26
  createXAPIClient: () => createXAPIClient,
25
27
  telemetryEventToXAPIStatement: () => telemetryEventToXAPIStatement
26
28
  });
27
29
  module.exports = __toCommonJS(index_exports);
28
30
 
31
+ // src/id.ts
32
+ function cryptoRandomId() {
33
+ const g = globalThis;
34
+ if (g.crypto?.randomUUID) return g.crypto.randomUUID();
35
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 11)}`;
36
+ }
37
+
29
38
  // src/queue.ts
39
+ function withStatementId(statement) {
40
+ const trimmed = statement.id?.trim();
41
+ if (trimmed) {
42
+ if (trimmed !== statement.id) statement.id = trimmed;
43
+ return statement;
44
+ }
45
+ statement.id = cryptoRandomId();
46
+ return statement;
47
+ }
30
48
  var DEFAULT_MAX_QUEUE_SIZE = 1e3;
31
49
  function createInMemoryXAPIQueue(opts) {
32
50
  const maxSize = opts?.maxSize ?? DEFAULT_MAX_QUEUE_SIZE;
33
51
  const buffer = [];
34
52
  let flushInFlight = null;
53
+ let headInFlight = false;
35
54
  const notifyDepth = () => {
36
55
  opts?.onDepth?.(buffer.length);
37
56
  };
57
+ const removeById = (id) => {
58
+ const idx = buffer.findIndex((s) => s.id === id);
59
+ if (idx >= 0) {
60
+ buffer.splice(idx, 1);
61
+ notifyDepth();
62
+ }
63
+ };
38
64
  const runFlush = async (transport) => {
39
65
  while (buffer.length) {
40
66
  const statement = buffer[0];
67
+ headInFlight = true;
41
68
  try {
42
69
  await transport(statement);
43
70
  buffer.shift();
44
71
  notifyDepth();
45
72
  } catch {
46
73
  return;
74
+ } finally {
75
+ headInFlight = false;
47
76
  }
48
77
  }
49
78
  };
50
79
  return {
51
80
  enqueue: (statement) => {
52
- if (statement.id && buffer.some((s) => s.id === statement.id)) return;
81
+ const normalized = withStatementId(statement);
82
+ if (buffer.some((s) => s.id === normalized.id)) return;
53
83
  if (buffer.length >= maxSize) {
54
- buffer.shift();
84
+ if (headInFlight && buffer.length <= 1) {
85
+ opts?.onCap?.();
86
+ return;
87
+ }
88
+ if (headInFlight) {
89
+ buffer.splice(1, 1);
90
+ } else {
91
+ buffer.shift();
92
+ }
55
93
  opts?.onCap?.();
56
94
  }
57
- buffer.push(statement);
95
+ buffer.push(normalized);
58
96
  notifyDepth();
59
97
  },
98
+ removeById,
60
99
  size: () => buffer.length,
61
100
  flush: async (transport) => {
62
101
  if (flushInFlight) return flushInFlight;
@@ -65,6 +104,22 @@ function createInMemoryXAPIQueue(opts) {
65
104
  flushInFlight = null;
66
105
  });
67
106
  return flushInFlight;
107
+ },
108
+ 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];
112
+ try {
113
+ exitTransport(statement);
114
+ } catch {
115
+ }
116
+ }
117
+ if (startIdx === 0) {
118
+ buffer.length = 0;
119
+ } else if (buffer.length > 1) {
120
+ buffer.splice(1);
121
+ }
122
+ notifyDepth();
68
123
  }
69
124
  };
70
125
  }
@@ -75,16 +130,10 @@ var import_core2 = require("@lessonkit/core");
75
130
  // src/telemetryMap.ts
76
131
  var import_core = require("@lessonkit/core");
77
132
 
78
- // src/id.ts
79
- function cryptoRandomId() {
80
- const g = globalThis;
81
- if (g.crypto?.randomUUID) return g.crypto.randomUUID();
82
- return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 11)}`;
83
- }
84
-
85
133
  // src/duration.ts
86
134
  function formatDurationMs(ms) {
87
- const safe = Math.max(0, ms);
135
+ if (!Number.isFinite(ms) || ms < 0) return void 0;
136
+ const safe = ms;
88
137
  const seconds = safe / 1e3;
89
138
  const fixed = Number.isInteger(seconds) ? String(seconds) : seconds.toFixed(3).replace(/0+$/, "").replace(/\.$/, "");
90
139
  return `PT${fixed}S`;
@@ -101,12 +150,18 @@ function buildXapiScoreResult(opts) {
101
150
  const max = typeof opts.maxScore === "number" ? opts.maxScore : void 0;
102
151
  const raw = typeof opts.score === "number" ? opts.score : void 0;
103
152
  if (typeof raw !== "number" && typeof max !== "number") return void 0;
104
- return {
105
- raw,
106
- max,
107
- min: 0,
108
- scaled: typeof raw === "number" && typeof max === "number" && max > 0 ? raw / max : void 0
109
- };
153
+ if (typeof raw === "number" && !Number.isFinite(raw) || typeof max === "number" && !Number.isFinite(max)) {
154
+ return void 0;
155
+ }
156
+ if (typeof max === "number" && max <= 0) return void 0;
157
+ if (typeof raw === "number" && raw < 0) return void 0;
158
+ const result = { min: 0 };
159
+ if (typeof raw === "number") result.raw = raw;
160
+ if (typeof max === "number") result.max = max;
161
+ if (typeof raw === "number" && typeof max === "number" && max > 0 && raw <= max) {
162
+ result.scaled = raw / max;
163
+ }
164
+ return result;
110
165
  }
111
166
  function statementFor(objectId, verb, timestamp, extra) {
112
167
  return {
@@ -129,7 +184,7 @@ var experiencedBlockMapper = (event, ctx) => {
129
184
  if (event.name === "interaction") {
130
185
  const lessonId2 = event.lessonId;
131
186
  const blockId2 = event.data?.blockId;
132
- if (!lessonId2 || !blockId2) return null;
187
+ if (!lessonId2 || !blockId2 || typeof blockId2 !== "string") return null;
133
188
  return experiencedBlockStatement(ctx.courseId, lessonId2, blockId2, ctx.timestamp);
134
189
  }
135
190
  const lessonId = event.lessonId;
@@ -155,7 +210,8 @@ var TELEMETRY_XAPI_MAPPERS = {
155
210
  const data = event.data;
156
211
  const result = {};
157
212
  if (typeof data?.durationMs === "number") {
158
- result.duration = formatDurationMs(data.durationMs);
213
+ const duration = formatDurationMs(data.durationMs);
214
+ if (duration !== void 0) result.duration = duration;
159
215
  }
160
216
  if (typeof data?.success === "boolean") result.success = data.success;
161
217
  const score = buildXapiScoreResult({ score: data?.score, maxScore: data?.maxScore });
@@ -209,6 +265,7 @@ var TELEMETRY_XAPI_MAPPERS = {
209
265
  },
210
266
  interaction: experiencedBlockMapper,
211
267
  book_page_viewed: experiencedBlockMapper,
268
+ slide_viewed: experiencedBlockMapper,
212
269
  compound_page_viewed: experiencedBlockMapper,
213
270
  hotspot_opened: experiencedBlockMapper,
214
271
  accordion_section_toggled: experiencedBlockMapper,
@@ -227,12 +284,22 @@ function telemetryEventToXAPIStatement(event) {
227
284
  }
228
285
 
229
286
  // src/client.ts
287
+ function withStatementId2(statement) {
288
+ const trimmed = statement.id?.trim();
289
+ if (trimmed) {
290
+ if (trimmed !== statement.id) statement.id = trimmed;
291
+ return statement;
292
+ }
293
+ statement.id = cryptoRandomId();
294
+ return statement;
295
+ }
230
296
  function isDevEnvironment() {
231
297
  const g = globalThis;
232
298
  return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
233
299
  }
234
300
  function createXAPIClient(opts) {
235
301
  const transport = opts?.transport;
302
+ const exitTransport = opts?.exitTransport;
236
303
  const courseId = opts?.courseId;
237
304
  const queue = opts?.queue ?? createInMemoryXAPIQueue({
238
305
  maxSize: opts?.maxQueueSize,
@@ -242,9 +309,11 @@ function createXAPIClient(opts) {
242
309
  let warnedNoTransport = false;
243
310
  let warnedTransportFailure = false;
244
311
  const inflightById = /* @__PURE__ */ new Map();
312
+ const inflightStatements = /* @__PURE__ */ new Map();
245
313
  const sendOrQueue = (statement) => {
314
+ const normalized = withStatementId2(statement);
246
315
  if (!transport) {
247
- queue.enqueue(statement);
316
+ queue.enqueue(normalized);
248
317
  if (isDevEnvironment() && !warnedNoTransport) {
249
318
  warnedNoTransport = true;
250
319
  console.warn(
@@ -253,35 +322,50 @@ function createXAPIClient(opts) {
253
322
  }
254
323
  return;
255
324
  }
256
- const existing = inflightById.get(statement.id);
325
+ const existing = inflightById.get(normalized.id);
257
326
  if (existing) {
258
327
  void existing.then(
259
328
  () => void 0,
260
329
  () => {
261
- sendOrQueue(statement);
330
+ sendOrQueue(normalized);
262
331
  }
263
332
  );
264
333
  return;
265
334
  }
266
- const transportFlight = Promise.resolve().then(() => transport(statement));
267
- const flight = transportFlight.catch(() => {
268
- queue.enqueue(statement);
335
+ inflightStatements.set(normalized.id, normalized);
336
+ const flight = Promise.resolve().then(async () => {
337
+ await transport(normalized);
338
+ queue.removeById(normalized.id);
339
+ }).catch(() => {
340
+ queue.enqueue(normalized);
269
341
  if (isDevEnvironment() && !warnedTransportFailure) {
270
342
  warnedTransportFailure = true;
271
343
  console.warn(
272
344
  "[lessonkit] xAPI transport failed; statement re-queued. Check your LRS endpoint or transport implementation."
273
345
  );
274
346
  }
347
+ throw new Error("xAPI transport failed");
275
348
  }).finally(() => {
276
- inflightById.delete(statement.id);
349
+ inflightById.delete(normalized.id);
350
+ inflightStatements.delete(normalized.id);
351
+ });
352
+ inflightById.set(normalized.id, flight);
353
+ void flight.catch(() => {
277
354
  });
278
- inflightById.set(statement.id, transportFlight);
279
- void flight;
280
355
  };
281
356
  const emit = (event) => {
282
- const statement = telemetryEventToXAPIStatement(event);
283
- if (!statement) return;
284
- sendOrQueue(statement);
357
+ try {
358
+ const statement = telemetryEventToXAPIStatement(event);
359
+ if (!statement) return;
360
+ sendOrQueue(statement);
361
+ } catch (err) {
362
+ if (isDevEnvironment()) {
363
+ console.warn(
364
+ "[lessonkit] xAPI mapping skipped:",
365
+ err instanceof Error ? err.message : err
366
+ );
367
+ }
368
+ }
285
369
  };
286
370
  return {
287
371
  send: (statement) => {
@@ -296,6 +380,22 @@ function createXAPIClient(opts) {
296
380
  await Promise.allSettled(flights);
297
381
  }
298
382
  },
383
+ 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 {
391
+ }
392
+ }
393
+ queue.flushOnExit((statement) => {
394
+ if (exitSentIds.has(statement.id)) return;
395
+ exitTransport(statement);
396
+ exitSentIds.add(statement.id);
397
+ });
398
+ } : void 0,
299
399
  startedLesson: ({ lessonId }) => {
300
400
  if (!courseId) return;
301
401
  emit({
@@ -332,8 +432,123 @@ function createXAPIClient(opts) {
332
432
  }
333
433
  };
334
434
  }
435
+
436
+ // src/fetchTransport.ts
437
+ function resolveHeaders(headers) {
438
+ if (!headers) return { "Content-Type": "application/json" };
439
+ const resolved = typeof headers === "function" ? headers() : headers;
440
+ return { "Content-Type": "application/json", ...resolved };
441
+ }
442
+ 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
+ }
448
+ const controller = new AbortController();
449
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
450
+ timer.unref?.();
451
+ return controller.signal;
452
+ }
453
+ function sleep(ms) {
454
+ return new Promise((resolve) => setTimeout(resolve, ms));
455
+ }
456
+ function postStatement(url, statement, init) {
457
+ return fetch(url, {
458
+ method: "POST",
459
+ body: JSON.stringify(statement),
460
+ ...init
461
+ }).then((res) => {
462
+ if (!res.ok) {
463
+ throw new Error(`xAPI fetch failed: ${res.status} ${res.statusText}`);
464
+ }
465
+ });
466
+ }
467
+ function createFetchTransport(opts) {
468
+ const timeoutMs = opts.timeoutMs ?? 3e4;
469
+ const retries = opts.retries ?? 2;
470
+ const initialBackoffMs = opts.backoffMs ?? 250;
471
+ const maxBackoffMs = opts.maxBackoffMs ?? 5e3;
472
+ 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
+ }
489
+ }
490
+ };
491
+ 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
+ }
500
+ };
501
+ return { transport, exitTransport };
502
+ }
503
+ function createFetchBatchSink(opts) {
504
+ const timeoutMs = opts.timeoutMs ?? 3e4;
505
+ const retries = opts.retries ?? 2;
506
+ const initialBackoffMs = opts.backoffMs ?? 250;
507
+ const maxBackoffMs = opts.maxBackoffMs ?? 5e3;
508
+ 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;
529
+ }
530
+ }
531
+ };
532
+ return {
533
+ batchSink: (events) => postBatch(events, opts.init ?? {}),
534
+ 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
+ }
545
+ }
546
+ };
547
+ }
335
548
  // Annotate the CommonJS export names for ESM import in node:
336
549
  0 && (module.exports = {
550
+ createFetchBatchSink,
551
+ createFetchTransport,
337
552
  createInMemoryXAPIQueue,
338
553
  createXAPIClient,
339
554
  telemetryEventToXAPIStatement
package/dist/index.d.cts CHANGED
@@ -32,12 +32,18 @@ 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;
37
40
  };
41
+ type XAPIExitTransport = (statement: XAPIStatement) => void;
38
42
  type XAPIClient = {
39
43
  send: (statement: XAPIStatement) => void;
40
44
  flush: () => Promise<void>;
45
+ /** Best-effort synchronous flush for pagehide using keepalive transport when configured. */
46
+ flushOnExit?: () => void;
41
47
  queueSize: () => number;
42
48
  startedLesson: (opts: {
43
49
  lessonId: LessonId;
@@ -64,6 +70,8 @@ declare function createInMemoryXAPIQueue(opts?: InMemoryXAPIQueueOptions): XAPIQ
64
70
 
65
71
  declare function createXAPIClient(opts?: {
66
72
  transport?: XAPITransport;
73
+ /** Keepalive transport for pagehide flush (e.g. from createFetchTransport). */
74
+ exitTransport?: XAPIExitTransport;
67
75
  courseId?: CourseId;
68
76
  queue?: XAPIQueue;
69
77
  /** When creating the default in-memory queue (max size 1000 unless overridden). */
@@ -72,10 +80,47 @@ declare function createXAPIClient(opts?: {
72
80
  onQueueCap?: () => void;
73
81
  }): XAPIClient;
74
82
 
83
+ type CreateFetchTransportOptions = {
84
+ /** LRS or proxy endpoint (POST). */
85
+ url: string;
86
+ /** Per-request timeout (default 30_000 ms). Uses AbortSignal.timeout when available. */
87
+ timeoutMs?: number;
88
+ /** Static headers merged into each request (e.g. Authorization from a short-lived token). */
89
+ headers?: Record<string, string> | (() => Record<string, string>);
90
+ /** Retries after transport failure (default 2). */
91
+ retries?: number;
92
+ /** Initial backoff in ms (default 250). Doubles each retry up to maxBackoffMs. */
93
+ backoffMs?: number;
94
+ /** Maximum backoff in ms (default 5_000). */
95
+ maxBackoffMs?: number;
96
+ /** Extra fetch init merged into each request. */
97
+ init?: Omit<RequestInit, "method" | "body" | "signal" | "keepalive">;
98
+ };
99
+ type FetchTransportBundle = {
100
+ transport: XAPITransport;
101
+ /** Best-effort synchronous delivery for pagehide (keepalive fetch). */
102
+ exitTransport: (statement: XAPIStatement) => void;
103
+ };
104
+ /**
105
+ * Creates an xAPI transport backed by fetch with timeout, retry backoff, and a
106
+ * keepalive exit transport for pagehide delivery.
107
+ */
108
+ declare function createFetchTransport(opts: CreateFetchTransportOptions): FetchTransportBundle;
109
+ type CreateFetchBatchSinkOptions = CreateFetchTransportOptions;
110
+ type FetchBatchSinkBundle = {
111
+ batchSink: (events: unknown[]) => Promise<void>;
112
+ /** Best-effort keepalive POST for pagehide (JSON array body). */
113
+ exitBatchSink: (events: unknown[]) => void;
114
+ };
115
+ /**
116
+ * Batch analytics sink with timeout, retry backoff, and keepalive exit delivery.
117
+ */
118
+ declare function createFetchBatchSink(opts: CreateFetchBatchSinkOptions): FetchBatchSinkBundle;
119
+
75
120
  /**
76
121
  * Map a LessonKit telemetry event to an xAPI statement, or null if the event should not emit xAPI.
77
122
  * `lesson_time_on_task` returns null (companion metric; lesson_completed carries duration).
78
123
  */
79
124
  declare function telemetryEventToXAPIStatement(event: TelemetryEvent): XAPIStatement | null;
80
125
 
81
- export { type InMemoryXAPIQueueOptions, type XAPIClient, type XAPIObjectDefinition, type XAPIQueue, type XAPIResult, type XAPIScore, type XAPIStatement, type XAPITransport, type XAPIVerbIri, createInMemoryXAPIQueue, createXAPIClient, telemetryEventToXAPIStatement };
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 };
package/dist/index.d.ts CHANGED
@@ -32,12 +32,18 @@ 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;
37
40
  };
41
+ type XAPIExitTransport = (statement: XAPIStatement) => void;
38
42
  type XAPIClient = {
39
43
  send: (statement: XAPIStatement) => void;
40
44
  flush: () => Promise<void>;
45
+ /** Best-effort synchronous flush for pagehide using keepalive transport when configured. */
46
+ flushOnExit?: () => void;
41
47
  queueSize: () => number;
42
48
  startedLesson: (opts: {
43
49
  lessonId: LessonId;
@@ -64,6 +70,8 @@ declare function createInMemoryXAPIQueue(opts?: InMemoryXAPIQueueOptions): XAPIQ
64
70
 
65
71
  declare function createXAPIClient(opts?: {
66
72
  transport?: XAPITransport;
73
+ /** Keepalive transport for pagehide flush (e.g. from createFetchTransport). */
74
+ exitTransport?: XAPIExitTransport;
67
75
  courseId?: CourseId;
68
76
  queue?: XAPIQueue;
69
77
  /** When creating the default in-memory queue (max size 1000 unless overridden). */
@@ -72,10 +80,47 @@ declare function createXAPIClient(opts?: {
72
80
  onQueueCap?: () => void;
73
81
  }): XAPIClient;
74
82
 
83
+ type CreateFetchTransportOptions = {
84
+ /** LRS or proxy endpoint (POST). */
85
+ url: string;
86
+ /** Per-request timeout (default 30_000 ms). Uses AbortSignal.timeout when available. */
87
+ timeoutMs?: number;
88
+ /** Static headers merged into each request (e.g. Authorization from a short-lived token). */
89
+ headers?: Record<string, string> | (() => Record<string, string>);
90
+ /** Retries after transport failure (default 2). */
91
+ retries?: number;
92
+ /** Initial backoff in ms (default 250). Doubles each retry up to maxBackoffMs. */
93
+ backoffMs?: number;
94
+ /** Maximum backoff in ms (default 5_000). */
95
+ maxBackoffMs?: number;
96
+ /** Extra fetch init merged into each request. */
97
+ init?: Omit<RequestInit, "method" | "body" | "signal" | "keepalive">;
98
+ };
99
+ type FetchTransportBundle = {
100
+ transport: XAPITransport;
101
+ /** Best-effort synchronous delivery for pagehide (keepalive fetch). */
102
+ exitTransport: (statement: XAPIStatement) => void;
103
+ };
104
+ /**
105
+ * Creates an xAPI transport backed by fetch with timeout, retry backoff, and a
106
+ * keepalive exit transport for pagehide delivery.
107
+ */
108
+ declare function createFetchTransport(opts: CreateFetchTransportOptions): FetchTransportBundle;
109
+ type CreateFetchBatchSinkOptions = CreateFetchTransportOptions;
110
+ type FetchBatchSinkBundle = {
111
+ batchSink: (events: unknown[]) => Promise<void>;
112
+ /** Best-effort keepalive POST for pagehide (JSON array body). */
113
+ exitBatchSink: (events: unknown[]) => void;
114
+ };
115
+ /**
116
+ * Batch analytics sink with timeout, retry backoff, and keepalive exit delivery.
117
+ */
118
+ declare function createFetchBatchSink(opts: CreateFetchBatchSinkOptions): FetchBatchSinkBundle;
119
+
75
120
  /**
76
121
  * Map a LessonKit telemetry event to an xAPI statement, or null if the event should not emit xAPI.
77
122
  * `lesson_time_on_task` returns null (companion metric; lesson_completed carries duration).
78
123
  */
79
124
  declare function telemetryEventToXAPIStatement(event: TelemetryEvent): XAPIStatement | null;
80
125
 
81
- export { type InMemoryXAPIQueueOptions, type XAPIClient, type XAPIObjectDefinition, type XAPIQueue, type XAPIResult, type XAPIScore, type XAPIStatement, type XAPITransport, type XAPIVerbIri, createInMemoryXAPIQueue, createXAPIClient, telemetryEventToXAPIStatement };
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 };
package/dist/index.js CHANGED
@@ -1,34 +1,71 @@
1
+ // src/id.ts
2
+ function cryptoRandomId() {
3
+ const g = globalThis;
4
+ if (g.crypto?.randomUUID) return g.crypto.randomUUID();
5
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 11)}`;
6
+ }
7
+
1
8
  // src/queue.ts
9
+ function withStatementId(statement) {
10
+ const trimmed = statement.id?.trim();
11
+ if (trimmed) {
12
+ if (trimmed !== statement.id) statement.id = trimmed;
13
+ return statement;
14
+ }
15
+ statement.id = cryptoRandomId();
16
+ return statement;
17
+ }
2
18
  var DEFAULT_MAX_QUEUE_SIZE = 1e3;
3
19
  function createInMemoryXAPIQueue(opts) {
4
20
  const maxSize = opts?.maxSize ?? DEFAULT_MAX_QUEUE_SIZE;
5
21
  const buffer = [];
6
22
  let flushInFlight = null;
23
+ let headInFlight = false;
7
24
  const notifyDepth = () => {
8
25
  opts?.onDepth?.(buffer.length);
9
26
  };
27
+ const removeById = (id) => {
28
+ const idx = buffer.findIndex((s) => s.id === id);
29
+ if (idx >= 0) {
30
+ buffer.splice(idx, 1);
31
+ notifyDepth();
32
+ }
33
+ };
10
34
  const runFlush = async (transport) => {
11
35
  while (buffer.length) {
12
36
  const statement = buffer[0];
37
+ headInFlight = true;
13
38
  try {
14
39
  await transport(statement);
15
40
  buffer.shift();
16
41
  notifyDepth();
17
42
  } catch {
18
43
  return;
44
+ } finally {
45
+ headInFlight = false;
19
46
  }
20
47
  }
21
48
  };
22
49
  return {
23
50
  enqueue: (statement) => {
24
- if (statement.id && buffer.some((s) => s.id === statement.id)) return;
51
+ const normalized = withStatementId(statement);
52
+ if (buffer.some((s) => s.id === normalized.id)) return;
25
53
  if (buffer.length >= maxSize) {
26
- buffer.shift();
54
+ if (headInFlight && buffer.length <= 1) {
55
+ opts?.onCap?.();
56
+ return;
57
+ }
58
+ if (headInFlight) {
59
+ buffer.splice(1, 1);
60
+ } else {
61
+ buffer.shift();
62
+ }
27
63
  opts?.onCap?.();
28
64
  }
29
- buffer.push(statement);
65
+ buffer.push(normalized);
30
66
  notifyDepth();
31
67
  },
68
+ removeById,
32
69
  size: () => buffer.length,
33
70
  flush: async (transport) => {
34
71
  if (flushInFlight) return flushInFlight;
@@ -37,6 +74,22 @@ function createInMemoryXAPIQueue(opts) {
37
74
  flushInFlight = null;
38
75
  });
39
76
  return flushInFlight;
77
+ },
78
+ 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];
82
+ try {
83
+ exitTransport(statement);
84
+ } catch {
85
+ }
86
+ }
87
+ if (startIdx === 0) {
88
+ buffer.length = 0;
89
+ } else if (buffer.length > 1) {
90
+ buffer.splice(1);
91
+ }
92
+ notifyDepth();
40
93
  }
41
94
  };
42
95
  }
@@ -47,16 +100,10 @@ import { nowIso } from "@lessonkit/core";
47
100
  // src/telemetryMap.ts
48
101
  import { buildLessonkitUrn } from "@lessonkit/core";
49
102
 
50
- // src/id.ts
51
- function cryptoRandomId() {
52
- const g = globalThis;
53
- if (g.crypto?.randomUUID) return g.crypto.randomUUID();
54
- return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 11)}`;
55
- }
56
-
57
103
  // src/duration.ts
58
104
  function formatDurationMs(ms) {
59
- const safe = Math.max(0, ms);
105
+ if (!Number.isFinite(ms) || ms < 0) return void 0;
106
+ const safe = ms;
60
107
  const seconds = safe / 1e3;
61
108
  const fixed = Number.isInteger(seconds) ? String(seconds) : seconds.toFixed(3).replace(/0+$/, "").replace(/\.$/, "");
62
109
  return `PT${fixed}S`;
@@ -73,12 +120,18 @@ function buildXapiScoreResult(opts) {
73
120
  const max = typeof opts.maxScore === "number" ? opts.maxScore : void 0;
74
121
  const raw = typeof opts.score === "number" ? opts.score : void 0;
75
122
  if (typeof raw !== "number" && typeof max !== "number") return void 0;
76
- return {
77
- raw,
78
- max,
79
- min: 0,
80
- scaled: typeof raw === "number" && typeof max === "number" && max > 0 ? raw / max : void 0
81
- };
123
+ if (typeof raw === "number" && !Number.isFinite(raw) || typeof max === "number" && !Number.isFinite(max)) {
124
+ return void 0;
125
+ }
126
+ if (typeof max === "number" && max <= 0) return void 0;
127
+ if (typeof raw === "number" && raw < 0) return void 0;
128
+ const result = { min: 0 };
129
+ if (typeof raw === "number") result.raw = raw;
130
+ if (typeof max === "number") result.max = max;
131
+ if (typeof raw === "number" && typeof max === "number" && max > 0 && raw <= max) {
132
+ result.scaled = raw / max;
133
+ }
134
+ return result;
82
135
  }
83
136
  function statementFor(objectId, verb, timestamp, extra) {
84
137
  return {
@@ -101,7 +154,7 @@ var experiencedBlockMapper = (event, ctx) => {
101
154
  if (event.name === "interaction") {
102
155
  const lessonId2 = event.lessonId;
103
156
  const blockId2 = event.data?.blockId;
104
- if (!lessonId2 || !blockId2) return null;
157
+ if (!lessonId2 || !blockId2 || typeof blockId2 !== "string") return null;
105
158
  return experiencedBlockStatement(ctx.courseId, lessonId2, blockId2, ctx.timestamp);
106
159
  }
107
160
  const lessonId = event.lessonId;
@@ -127,7 +180,8 @@ var TELEMETRY_XAPI_MAPPERS = {
127
180
  const data = event.data;
128
181
  const result = {};
129
182
  if (typeof data?.durationMs === "number") {
130
- result.duration = formatDurationMs(data.durationMs);
183
+ const duration = formatDurationMs(data.durationMs);
184
+ if (duration !== void 0) result.duration = duration;
131
185
  }
132
186
  if (typeof data?.success === "boolean") result.success = data.success;
133
187
  const score = buildXapiScoreResult({ score: data?.score, maxScore: data?.maxScore });
@@ -181,6 +235,7 @@ var TELEMETRY_XAPI_MAPPERS = {
181
235
  },
182
236
  interaction: experiencedBlockMapper,
183
237
  book_page_viewed: experiencedBlockMapper,
238
+ slide_viewed: experiencedBlockMapper,
184
239
  compound_page_viewed: experiencedBlockMapper,
185
240
  hotspot_opened: experiencedBlockMapper,
186
241
  accordion_section_toggled: experiencedBlockMapper,
@@ -199,12 +254,22 @@ function telemetryEventToXAPIStatement(event) {
199
254
  }
200
255
 
201
256
  // src/client.ts
257
+ function withStatementId2(statement) {
258
+ const trimmed = statement.id?.trim();
259
+ if (trimmed) {
260
+ if (trimmed !== statement.id) statement.id = trimmed;
261
+ return statement;
262
+ }
263
+ statement.id = cryptoRandomId();
264
+ return statement;
265
+ }
202
266
  function isDevEnvironment() {
203
267
  const g = globalThis;
204
268
  return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
205
269
  }
206
270
  function createXAPIClient(opts) {
207
271
  const transport = opts?.transport;
272
+ const exitTransport = opts?.exitTransport;
208
273
  const courseId = opts?.courseId;
209
274
  const queue = opts?.queue ?? createInMemoryXAPIQueue({
210
275
  maxSize: opts?.maxQueueSize,
@@ -214,9 +279,11 @@ function createXAPIClient(opts) {
214
279
  let warnedNoTransport = false;
215
280
  let warnedTransportFailure = false;
216
281
  const inflightById = /* @__PURE__ */ new Map();
282
+ const inflightStatements = /* @__PURE__ */ new Map();
217
283
  const sendOrQueue = (statement) => {
284
+ const normalized = withStatementId2(statement);
218
285
  if (!transport) {
219
- queue.enqueue(statement);
286
+ queue.enqueue(normalized);
220
287
  if (isDevEnvironment() && !warnedNoTransport) {
221
288
  warnedNoTransport = true;
222
289
  console.warn(
@@ -225,35 +292,50 @@ function createXAPIClient(opts) {
225
292
  }
226
293
  return;
227
294
  }
228
- const existing = inflightById.get(statement.id);
295
+ const existing = inflightById.get(normalized.id);
229
296
  if (existing) {
230
297
  void existing.then(
231
298
  () => void 0,
232
299
  () => {
233
- sendOrQueue(statement);
300
+ sendOrQueue(normalized);
234
301
  }
235
302
  );
236
303
  return;
237
304
  }
238
- const transportFlight = Promise.resolve().then(() => transport(statement));
239
- const flight = transportFlight.catch(() => {
240
- queue.enqueue(statement);
305
+ inflightStatements.set(normalized.id, normalized);
306
+ const flight = Promise.resolve().then(async () => {
307
+ await transport(normalized);
308
+ queue.removeById(normalized.id);
309
+ }).catch(() => {
310
+ queue.enqueue(normalized);
241
311
  if (isDevEnvironment() && !warnedTransportFailure) {
242
312
  warnedTransportFailure = true;
243
313
  console.warn(
244
314
  "[lessonkit] xAPI transport failed; statement re-queued. Check your LRS endpoint or transport implementation."
245
315
  );
246
316
  }
317
+ throw new Error("xAPI transport failed");
247
318
  }).finally(() => {
248
- inflightById.delete(statement.id);
319
+ inflightById.delete(normalized.id);
320
+ inflightStatements.delete(normalized.id);
321
+ });
322
+ inflightById.set(normalized.id, flight);
323
+ void flight.catch(() => {
249
324
  });
250
- inflightById.set(statement.id, transportFlight);
251
- void flight;
252
325
  };
253
326
  const emit = (event) => {
254
- const statement = telemetryEventToXAPIStatement(event);
255
- if (!statement) return;
256
- sendOrQueue(statement);
327
+ try {
328
+ const statement = telemetryEventToXAPIStatement(event);
329
+ if (!statement) return;
330
+ sendOrQueue(statement);
331
+ } catch (err) {
332
+ if (isDevEnvironment()) {
333
+ console.warn(
334
+ "[lessonkit] xAPI mapping skipped:",
335
+ err instanceof Error ? err.message : err
336
+ );
337
+ }
338
+ }
257
339
  };
258
340
  return {
259
341
  send: (statement) => {
@@ -268,6 +350,22 @@ function createXAPIClient(opts) {
268
350
  await Promise.allSettled(flights);
269
351
  }
270
352
  },
353
+ flushOnExit: exitTransport ? () => {
354
+ const exitSentIds = /* @__PURE__ */ new Set();
355
+ for (const statement of inflightStatements.values()) {
356
+ if (exitSentIds.has(statement.id)) continue;
357
+ try {
358
+ exitTransport(statement);
359
+ exitSentIds.add(statement.id);
360
+ } catch {
361
+ }
362
+ }
363
+ queue.flushOnExit((statement) => {
364
+ if (exitSentIds.has(statement.id)) return;
365
+ exitTransport(statement);
366
+ exitSentIds.add(statement.id);
367
+ });
368
+ } : void 0,
271
369
  startedLesson: ({ lessonId }) => {
272
370
  if (!courseId) return;
273
371
  emit({
@@ -304,7 +402,122 @@ function createXAPIClient(opts) {
304
402
  }
305
403
  };
306
404
  }
405
+
406
+ // src/fetchTransport.ts
407
+ function resolveHeaders(headers) {
408
+ if (!headers) return { "Content-Type": "application/json" };
409
+ const resolved = typeof headers === "function" ? headers() : headers;
410
+ return { "Content-Type": "application/json", ...resolved };
411
+ }
412
+ 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
+ }
418
+ const controller = new AbortController();
419
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
420
+ timer.unref?.();
421
+ return controller.signal;
422
+ }
423
+ function sleep(ms) {
424
+ return new Promise((resolve) => setTimeout(resolve, ms));
425
+ }
426
+ function postStatement(url, statement, init) {
427
+ return fetch(url, {
428
+ method: "POST",
429
+ body: JSON.stringify(statement),
430
+ ...init
431
+ }).then((res) => {
432
+ if (!res.ok) {
433
+ throw new Error(`xAPI fetch failed: ${res.status} ${res.statusText}`);
434
+ }
435
+ });
436
+ }
437
+ function createFetchTransport(opts) {
438
+ const timeoutMs = opts.timeoutMs ?? 3e4;
439
+ const retries = opts.retries ?? 2;
440
+ const initialBackoffMs = opts.backoffMs ?? 250;
441
+ const maxBackoffMs = opts.maxBackoffMs ?? 5e3;
442
+ 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
+ }
459
+ }
460
+ };
461
+ const exitTransport = (statement) => {
462
+ try {
463
+ void postStatement(opts.url, statement, {
464
+ ...opts.init,
465
+ headers: resolveHeaders(opts.headers),
466
+ keepalive: true
467
+ }).catch(() => void 0);
468
+ } catch {
469
+ }
470
+ };
471
+ return { transport, exitTransport };
472
+ }
473
+ function createFetchBatchSink(opts) {
474
+ const timeoutMs = opts.timeoutMs ?? 3e4;
475
+ const retries = opts.retries ?? 2;
476
+ const initialBackoffMs = opts.backoffMs ?? 250;
477
+ const maxBackoffMs = opts.maxBackoffMs ?? 5e3;
478
+ 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;
499
+ }
500
+ }
501
+ };
502
+ return {
503
+ batchSink: (events) => postBatch(events, opts.init ?? {}),
504
+ exitBatchSink: (events) => {
505
+ try {
506
+ void fetch(opts.url, {
507
+ method: "POST",
508
+ body: JSON.stringify(events),
509
+ ...opts.init,
510
+ headers: resolveHeaders(opts.headers),
511
+ keepalive: true
512
+ }).catch(() => void 0);
513
+ } catch {
514
+ }
515
+ }
516
+ };
517
+ }
307
518
  export {
519
+ createFetchBatchSink,
520
+ createFetchTransport,
308
521
  createInMemoryXAPIQueue,
309
522
  createXAPIClient,
310
523
  telemetryEventToXAPIStatement
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessonkit/xapi",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
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.2.0"
51
+ "@lessonkit/core": "1.3.1"
52
52
  },
53
53
  "devDependencies": {
54
54
  "tsup": "^8.5.0",