@lessonkit/xapi 1.3.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,6 +20,8 @@ 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
@@ -52,6 +54,13 @@ function createInMemoryXAPIQueue(opts) {
52
54
  const notifyDepth = () => {
53
55
  opts?.onDepth?.(buffer.length);
54
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
+ };
55
64
  const runFlush = async (transport) => {
56
65
  while (buffer.length) {
57
66
  const statement = buffer[0];
@@ -86,6 +95,7 @@ function createInMemoryXAPIQueue(opts) {
86
95
  buffer.push(normalized);
87
96
  notifyDepth();
88
97
  },
98
+ removeById,
89
99
  size: () => buffer.length,
90
100
  flush: async (transport) => {
91
101
  if (flushInFlight) return flushInFlight;
@@ -94,6 +104,22 @@ function createInMemoryXAPIQueue(opts) {
94
104
  flushInFlight = null;
95
105
  });
96
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();
97
123
  }
98
124
  };
99
125
  }
@@ -273,6 +299,7 @@ function isDevEnvironment() {
273
299
  }
274
300
  function createXAPIClient(opts) {
275
301
  const transport = opts?.transport;
302
+ const exitTransport = opts?.exitTransport;
276
303
  const courseId = opts?.courseId;
277
304
  const queue = opts?.queue ?? createInMemoryXAPIQueue({
278
305
  maxSize: opts?.maxQueueSize,
@@ -282,6 +309,7 @@ function createXAPIClient(opts) {
282
309
  let warnedNoTransport = false;
283
310
  let warnedTransportFailure = false;
284
311
  const inflightById = /* @__PURE__ */ new Map();
312
+ const inflightStatements = /* @__PURE__ */ new Map();
285
313
  const sendOrQueue = (statement) => {
286
314
  const normalized = withStatementId2(statement);
287
315
  if (!transport) {
@@ -304,7 +332,11 @@ function createXAPIClient(opts) {
304
332
  );
305
333
  return;
306
334
  }
307
- const flight = Promise.resolve().then(() => transport(normalized)).catch(() => {
335
+ inflightStatements.set(normalized.id, normalized);
336
+ const flight = Promise.resolve().then(async () => {
337
+ await transport(normalized);
338
+ queue.removeById(normalized.id);
339
+ }).catch(() => {
308
340
  queue.enqueue(normalized);
309
341
  if (isDevEnvironment() && !warnedTransportFailure) {
310
342
  warnedTransportFailure = true;
@@ -315,6 +347,7 @@ function createXAPIClient(opts) {
315
347
  throw new Error("xAPI transport failed");
316
348
  }).finally(() => {
317
349
  inflightById.delete(normalized.id);
350
+ inflightStatements.delete(normalized.id);
318
351
  });
319
352
  inflightById.set(normalized.id, flight);
320
353
  void flight.catch(() => {
@@ -347,6 +380,22 @@ function createXAPIClient(opts) {
347
380
  await Promise.allSettled(flights);
348
381
  }
349
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,
350
399
  startedLesson: ({ lessonId }) => {
351
400
  if (!courseId) return;
352
401
  emit({
@@ -383,8 +432,123 @@ function createXAPIClient(opts) {
383
432
  }
384
433
  };
385
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
+ }
386
548
  // Annotate the CommonJS export names for ESM import in node:
387
549
  0 && (module.exports = {
550
+ createFetchBatchSink,
551
+ createFetchTransport,
388
552
  createInMemoryXAPIQueue,
389
553
  createXAPIClient,
390
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
@@ -24,6 +24,13 @@ function createInMemoryXAPIQueue(opts) {
24
24
  const notifyDepth = () => {
25
25
  opts?.onDepth?.(buffer.length);
26
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
+ };
27
34
  const runFlush = async (transport) => {
28
35
  while (buffer.length) {
29
36
  const statement = buffer[0];
@@ -58,6 +65,7 @@ function createInMemoryXAPIQueue(opts) {
58
65
  buffer.push(normalized);
59
66
  notifyDepth();
60
67
  },
68
+ removeById,
61
69
  size: () => buffer.length,
62
70
  flush: async (transport) => {
63
71
  if (flushInFlight) return flushInFlight;
@@ -66,6 +74,22 @@ function createInMemoryXAPIQueue(opts) {
66
74
  flushInFlight = null;
67
75
  });
68
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();
69
93
  }
70
94
  };
71
95
  }
@@ -245,6 +269,7 @@ function isDevEnvironment() {
245
269
  }
246
270
  function createXAPIClient(opts) {
247
271
  const transport = opts?.transport;
272
+ const exitTransport = opts?.exitTransport;
248
273
  const courseId = opts?.courseId;
249
274
  const queue = opts?.queue ?? createInMemoryXAPIQueue({
250
275
  maxSize: opts?.maxQueueSize,
@@ -254,6 +279,7 @@ function createXAPIClient(opts) {
254
279
  let warnedNoTransport = false;
255
280
  let warnedTransportFailure = false;
256
281
  const inflightById = /* @__PURE__ */ new Map();
282
+ const inflightStatements = /* @__PURE__ */ new Map();
257
283
  const sendOrQueue = (statement) => {
258
284
  const normalized = withStatementId2(statement);
259
285
  if (!transport) {
@@ -276,7 +302,11 @@ function createXAPIClient(opts) {
276
302
  );
277
303
  return;
278
304
  }
279
- const flight = Promise.resolve().then(() => transport(normalized)).catch(() => {
305
+ inflightStatements.set(normalized.id, normalized);
306
+ const flight = Promise.resolve().then(async () => {
307
+ await transport(normalized);
308
+ queue.removeById(normalized.id);
309
+ }).catch(() => {
280
310
  queue.enqueue(normalized);
281
311
  if (isDevEnvironment() && !warnedTransportFailure) {
282
312
  warnedTransportFailure = true;
@@ -287,6 +317,7 @@ function createXAPIClient(opts) {
287
317
  throw new Error("xAPI transport failed");
288
318
  }).finally(() => {
289
319
  inflightById.delete(normalized.id);
320
+ inflightStatements.delete(normalized.id);
290
321
  });
291
322
  inflightById.set(normalized.id, flight);
292
323
  void flight.catch(() => {
@@ -319,6 +350,22 @@ function createXAPIClient(opts) {
319
350
  await Promise.allSettled(flights);
320
351
  }
321
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,
322
369
  startedLesson: ({ lessonId }) => {
323
370
  if (!courseId) return;
324
371
  emit({
@@ -355,7 +402,122 @@ function createXAPIClient(opts) {
355
402
  }
356
403
  };
357
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
+ }
358
518
  export {
519
+ createFetchBatchSink,
520
+ createFetchTransport,
359
521
  createInMemoryXAPIQueue,
360
522
  createXAPIClient,
361
523
  telemetryEventToXAPIStatement
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessonkit/xapi",
3
- "version": "1.3.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.3.0"
51
+ "@lessonkit/core": "1.3.1"
52
52
  },
53
53
  "devDependencies": {
54
54
  "tsup": "^8.5.0",