@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 +18 -4
- package/dist/index.cjs +246 -31
- package/dist/index.d.cts +46 -1
- package/dist/index.d.ts +46 -1
- package/dist/index.js +244 -31
- 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,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
|
-
|
|
81
|
+
const normalized = withStatementId(statement);
|
|
82
|
+
if (buffer.some((s) => s.id === normalized.id)) return;
|
|
53
83
|
if (buffer.length >= maxSize) {
|
|
54
|
-
buffer.
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
325
|
+
const existing = inflightById.get(normalized.id);
|
|
257
326
|
if (existing) {
|
|
258
327
|
void existing.then(
|
|
259
328
|
() => void 0,
|
|
260
329
|
() => {
|
|
261
|
-
sendOrQueue(
|
|
330
|
+
sendOrQueue(normalized);
|
|
262
331
|
}
|
|
263
332
|
);
|
|
264
333
|
return;
|
|
265
334
|
}
|
|
266
|
-
|
|
267
|
-
const flight =
|
|
268
|
-
|
|
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(
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
51
|
+
const normalized = withStatementId(statement);
|
|
52
|
+
if (buffer.some((s) => s.id === normalized.id)) return;
|
|
25
53
|
if (buffer.length >= maxSize) {
|
|
26
|
-
buffer.
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
295
|
+
const existing = inflightById.get(normalized.id);
|
|
229
296
|
if (existing) {
|
|
230
297
|
void existing.then(
|
|
231
298
|
() => void 0,
|
|
232
299
|
() => {
|
|
233
|
-
sendOrQueue(
|
|
300
|
+
sendOrQueue(normalized);
|
|
234
301
|
}
|
|
235
302
|
);
|
|
236
303
|
return;
|
|
237
304
|
}
|
|
238
|
-
|
|
239
|
-
const flight =
|
|
240
|
-
|
|
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(
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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.
|
|
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.
|
|
51
|
+
"@lessonkit/core": "1.3.1"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"tsup": "^8.5.0",
|