@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 +18 -4
- package/dist/index.cjs +275 -15
- package/dist/index.d.cts +66 -1
- package/dist/index.d.ts +66 -1
- package/dist/index.js +270 -15
- package/package.json +2 -2
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
|
|
25
|
-
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
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 (!
|
|
344
|
-
await queue.flush(
|
|
422
|
+
if (!deliveryTransport) return;
|
|
423
|
+
await queue.flush(deliveryTransport);
|
|
345
424
|
const flights = [...inflightById.values()];
|
|
346
425
|
if (flights.length > 0) {
|
|
347
|
-
await Promise.
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
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 (!
|
|
316
|
-
await queue.flush(
|
|
389
|
+
if (!deliveryTransport) return;
|
|
390
|
+
await queue.flush(deliveryTransport);
|
|
317
391
|
const flights = [...inflightById.values()];
|
|
318
392
|
if (flights.length > 0) {
|
|
319
|
-
await Promise.
|
|
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
|
+
"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.
|
|
51
|
+
"@lessonkit/core": "1.4.0"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"tsup": "^8.5.0",
|