@lessonkit/xapi 1.4.0 → 1.5.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 +2 -2
- package/dist/index.cjs +522 -64
- package/dist/index.d.cts +27 -2
- package/dist/index.d.ts +27 -2
- package/dist/index.js +517 -64
- package/package.json +4 -4
package/dist/index.cjs
CHANGED
|
@@ -21,21 +21,133 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
23
|
FetchHttpError: () => FetchHttpError,
|
|
24
|
+
assertSafeLrsUrl: () => assertSafeLrsUrl,
|
|
24
25
|
createFetchBatchSink: () => createFetchBatchSink,
|
|
25
26
|
createFetchTransport: () => createFetchTransport,
|
|
26
27
|
createInMemoryXAPIQueue: () => createInMemoryXAPIQueue,
|
|
27
28
|
createXAPIClient: () => createXAPIClient,
|
|
28
29
|
isRetryableFetchError: () => isRetryableFetchError,
|
|
29
30
|
isRetryableFetchHttpStatus: () => isRetryableFetchHttpStatus,
|
|
31
|
+
loadDeadLetterStatements: () => loadDeadLetterStatements,
|
|
32
|
+
persistDeadLetterStatement: () => persistDeadLetterStatement,
|
|
33
|
+
resetXAPIDeadLetterForTests: () => resetXAPIDeadLetterForTests,
|
|
30
34
|
telemetryEventToXAPIStatement: () => telemetryEventToXAPIStatement
|
|
31
35
|
});
|
|
32
36
|
module.exports = __toCommonJS(index_exports);
|
|
33
37
|
|
|
34
38
|
// src/id.ts
|
|
39
|
+
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
40
|
+
function randomIdFallback() {
|
|
41
|
+
const g = globalThis;
|
|
42
|
+
if (g.crypto?.getRandomValues) {
|
|
43
|
+
const bytes = new Uint8Array(16);
|
|
44
|
+
g.crypto.getRandomValues(bytes);
|
|
45
|
+
return formatUuid(bytes);
|
|
46
|
+
}
|
|
47
|
+
throw new Error(
|
|
48
|
+
"[lessonkit] cryptoRandomId requires crypto.randomUUID or crypto.getRandomValues"
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
function formatUuid(bytes) {
|
|
52
|
+
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
53
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
54
|
+
}
|
|
55
|
+
function hashSeedToUuid(seed) {
|
|
56
|
+
const bytes = new Uint8Array(16);
|
|
57
|
+
let h0 = 2166136261;
|
|
58
|
+
let h1 = 2166136261 ^ 2654435769;
|
|
59
|
+
let h2 = 2166136261 ^ 2246822507;
|
|
60
|
+
let h3 = 2166136261 ^ 3266489909;
|
|
61
|
+
for (let i = 0; i < seed.length; i += 1) {
|
|
62
|
+
const c = seed.charCodeAt(i);
|
|
63
|
+
h0 = Math.imul(h0 ^ c, 16777619);
|
|
64
|
+
h1 = Math.imul(h1 ^ c + 1, 16777619);
|
|
65
|
+
h2 = Math.imul(h2 ^ c + 2, 16777619);
|
|
66
|
+
h3 = Math.imul(h3 ^ c + 3, 16777619);
|
|
67
|
+
}
|
|
68
|
+
bytes[0] = h0 >>> 24 & 255;
|
|
69
|
+
bytes[1] = h0 >>> 16 & 255;
|
|
70
|
+
bytes[2] = h0 >>> 8 & 255;
|
|
71
|
+
bytes[3] = h0 & 255;
|
|
72
|
+
bytes[4] = h1 >>> 24 & 255;
|
|
73
|
+
bytes[5] = h1 >>> 16 & 255;
|
|
74
|
+
bytes[6] = h1 >>> 8 & 15 | 80;
|
|
75
|
+
bytes[7] = h1 & 255;
|
|
76
|
+
bytes[8] = h2 >>> 24 & 63 | 128;
|
|
77
|
+
bytes[9] = h2 >>> 16 & 255;
|
|
78
|
+
bytes[10] = h2 >>> 8 & 255;
|
|
79
|
+
bytes[11] = h2 & 255;
|
|
80
|
+
bytes[12] = h3 >>> 24 & 255;
|
|
81
|
+
bytes[13] = h3 >>> 16 & 255;
|
|
82
|
+
bytes[14] = h3 >>> 8 & 255;
|
|
83
|
+
bytes[15] = h3 & 255;
|
|
84
|
+
return formatUuid(bytes);
|
|
85
|
+
}
|
|
35
86
|
function cryptoRandomId() {
|
|
36
87
|
const g = globalThis;
|
|
37
88
|
if (g.crypto?.randomUUID) return g.crypto.randomUUID();
|
|
38
|
-
return
|
|
89
|
+
return randomIdFallback();
|
|
90
|
+
}
|
|
91
|
+
var LIFECYCLE_EVENTS_FOR_STABLE_ID = /* @__PURE__ */ new Set([
|
|
92
|
+
"course_started",
|
|
93
|
+
"course_completed",
|
|
94
|
+
"lesson_started",
|
|
95
|
+
"lesson_completed"
|
|
96
|
+
]);
|
|
97
|
+
var ASSESSMENT_COMPLETION_EVENTS_FOR_STABLE_ID = /* @__PURE__ */ new Set([
|
|
98
|
+
"assessment_completed",
|
|
99
|
+
"quiz_completed"
|
|
100
|
+
]);
|
|
101
|
+
function stableTelemetryEventId(event) {
|
|
102
|
+
const seed = [
|
|
103
|
+
event.name,
|
|
104
|
+
event.courseId,
|
|
105
|
+
event.lessonId ?? "",
|
|
106
|
+
event.sessionId ?? "",
|
|
107
|
+
event.attemptId ?? ""
|
|
108
|
+
].join("|");
|
|
109
|
+
return hashSeedToUuid(seed);
|
|
110
|
+
}
|
|
111
|
+
function stableAssessmentCompletionEventId(event) {
|
|
112
|
+
const checkId = event.data && typeof event.data === "object" && "checkId" in event.data ? String(event.data.checkId ?? "") : "";
|
|
113
|
+
const seed = [
|
|
114
|
+
event.name,
|
|
115
|
+
event.courseId,
|
|
116
|
+
event.lessonId ?? "",
|
|
117
|
+
event.sessionId ?? "",
|
|
118
|
+
event.attemptId ?? "",
|
|
119
|
+
checkId
|
|
120
|
+
].join("|");
|
|
121
|
+
return hashSeedToUuid(seed);
|
|
122
|
+
}
|
|
123
|
+
function enrichTelemetryEventForXapi(event) {
|
|
124
|
+
if (event.id?.trim()) return event;
|
|
125
|
+
if (LIFECYCLE_EVENTS_FOR_STABLE_ID.has(event.name)) {
|
|
126
|
+
return { ...event, id: stableTelemetryEventId(event) };
|
|
127
|
+
}
|
|
128
|
+
if (ASSESSMENT_COMPLETION_EVENTS_FOR_STABLE_ID.has(event.name)) {
|
|
129
|
+
return {
|
|
130
|
+
...event,
|
|
131
|
+
id: stableAssessmentCompletionEventId(
|
|
132
|
+
event
|
|
133
|
+
)
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return event;
|
|
137
|
+
}
|
|
138
|
+
function deriveStatementId(event, objectId, verb) {
|
|
139
|
+
const eventId = event.id?.trim();
|
|
140
|
+
if (eventId && UUID_RE.test(eventId)) return eventId;
|
|
141
|
+
const seed = [
|
|
142
|
+
event.name,
|
|
143
|
+
event.courseId,
|
|
144
|
+
event.lessonId ?? "",
|
|
145
|
+
event.sessionId ?? "",
|
|
146
|
+
objectId,
|
|
147
|
+
verb,
|
|
148
|
+
event.timestamp
|
|
149
|
+
].join("|");
|
|
150
|
+
return hashSeedToUuid(seed);
|
|
39
151
|
}
|
|
40
152
|
|
|
41
153
|
// src/queue.ts
|
|
@@ -97,16 +209,26 @@ function createInMemoryXAPIQueue(opts) {
|
|
|
97
209
|
return {
|
|
98
210
|
enqueue: (statement) => {
|
|
99
211
|
const normalized = withStatementId(statement);
|
|
100
|
-
|
|
212
|
+
const existingIdx = buffer.findIndex((s) => s.id === normalized.id);
|
|
213
|
+
if (existingIdx >= 0) {
|
|
214
|
+
buffer[existingIdx] = normalized;
|
|
215
|
+
notifyDepth();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
101
218
|
if (buffer.length >= maxSize) {
|
|
102
219
|
if (headInFlight) {
|
|
103
220
|
if (buffer.length > 1) {
|
|
104
221
|
buffer.splice(1, 1);
|
|
222
|
+
opts?.onCap?.();
|
|
223
|
+
} else {
|
|
224
|
+
opts?.onCap?.();
|
|
225
|
+
opts?.onOverflow?.(normalized);
|
|
226
|
+
return;
|
|
105
227
|
}
|
|
106
228
|
} else {
|
|
107
229
|
buffer.shift();
|
|
230
|
+
opts?.onCap?.();
|
|
108
231
|
}
|
|
109
|
-
opts?.onCap?.();
|
|
110
232
|
}
|
|
111
233
|
buffer.push(normalized);
|
|
112
234
|
notifyDepth();
|
|
@@ -138,6 +260,60 @@ function createInMemoryXAPIQueue(opts) {
|
|
|
138
260
|
// src/client.ts
|
|
139
261
|
var import_core2 = require("@lessonkit/core");
|
|
140
262
|
|
|
263
|
+
// src/deadLetter.ts
|
|
264
|
+
var STORAGE_KEY = "lk-xapi-dead-letter";
|
|
265
|
+
var MAX_DEAD_LETTER = 200;
|
|
266
|
+
function readStorage() {
|
|
267
|
+
try {
|
|
268
|
+
const storage = globalThis.sessionStorage;
|
|
269
|
+
return storage ?? null;
|
|
270
|
+
} catch {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
function loadDeadLetterStatements() {
|
|
275
|
+
const storage = readStorage();
|
|
276
|
+
if (!storage) return [];
|
|
277
|
+
try {
|
|
278
|
+
const raw = storage.getItem(STORAGE_KEY);
|
|
279
|
+
if (!raw) return [];
|
|
280
|
+
const parsed = JSON.parse(raw);
|
|
281
|
+
if (!Array.isArray(parsed)) return [];
|
|
282
|
+
return parsed.filter(
|
|
283
|
+
(item) => typeof item === "object" && item !== null && typeof item.id === "string"
|
|
284
|
+
);
|
|
285
|
+
} catch {
|
|
286
|
+
return [];
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
function persistDeadLetterStatement(statement) {
|
|
290
|
+
const storage = readStorage();
|
|
291
|
+
if (!storage) return;
|
|
292
|
+
try {
|
|
293
|
+
const existing = loadDeadLetterStatements();
|
|
294
|
+
if (existing.some((s) => s.id === statement.id)) return;
|
|
295
|
+
const next = [...existing, statement].slice(-MAX_DEAD_LETTER);
|
|
296
|
+
storage.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
297
|
+
} catch {
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function removeDeadLetterStatement(id) {
|
|
301
|
+
const storage = readStorage();
|
|
302
|
+
if (!storage) return;
|
|
303
|
+
try {
|
|
304
|
+
const next = loadDeadLetterStatements().filter((s) => s.id !== id);
|
|
305
|
+
if (next.length === 0) {
|
|
306
|
+
storage.removeItem(STORAGE_KEY);
|
|
307
|
+
} else {
|
|
308
|
+
storage.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
309
|
+
}
|
|
310
|
+
} catch {
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
function clearDeadLetterStorage() {
|
|
314
|
+
readStorage()?.removeItem(STORAGE_KEY);
|
|
315
|
+
}
|
|
316
|
+
|
|
141
317
|
// src/telemetryMap.ts
|
|
142
318
|
var import_core = require("@lessonkit/core");
|
|
143
319
|
|
|
@@ -174,9 +350,9 @@ function buildXapiScoreResult(opts) {
|
|
|
174
350
|
}
|
|
175
351
|
return result;
|
|
176
352
|
}
|
|
177
|
-
function statementFor(objectId, verb, timestamp, extra) {
|
|
353
|
+
function statementFor(event, objectId, verb, timestamp, extra) {
|
|
178
354
|
return {
|
|
179
|
-
id:
|
|
355
|
+
id: deriveStatementId(event, objectId, verb),
|
|
180
356
|
timestamp,
|
|
181
357
|
verb,
|
|
182
358
|
object: { id: objectId },
|
|
@@ -184,8 +360,21 @@ function statementFor(objectId, verb, timestamp, extra) {
|
|
|
184
360
|
context: extra?.context
|
|
185
361
|
};
|
|
186
362
|
}
|
|
187
|
-
function
|
|
363
|
+
function sanitizeTelemetryEmbedSrc(src) {
|
|
364
|
+
try {
|
|
365
|
+
const url = new URL(src);
|
|
366
|
+
url.username = "";
|
|
367
|
+
url.password = "";
|
|
368
|
+
url.search = "";
|
|
369
|
+
url.hash = "";
|
|
370
|
+
return `${url.origin}${url.pathname}`;
|
|
371
|
+
} catch {
|
|
372
|
+
return src;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function experiencedBlockStatement(event, courseId, lessonId, blockId, timestamp) {
|
|
188
376
|
return statementFor(
|
|
377
|
+
event,
|
|
189
378
|
(0, import_core.buildLessonkitUrn)({ courseId, lessonId, blockId }),
|
|
190
379
|
XAPIVerbs.experienced,
|
|
191
380
|
timestamp
|
|
@@ -196,20 +385,39 @@ var experiencedBlockMapper = (event, ctx) => {
|
|
|
196
385
|
const lessonId2 = event.lessonId;
|
|
197
386
|
const blockId2 = event.data?.blockId;
|
|
198
387
|
if (!lessonId2 || !blockId2 || typeof blockId2 !== "string") return null;
|
|
199
|
-
|
|
388
|
+
const kind = event.data?.kind;
|
|
389
|
+
const extensions = {};
|
|
390
|
+
if (kind === "embed_viewed" || kind === "chart_viewed") {
|
|
391
|
+
extensions["https://lessonkit.dev/xapi/interactionKind"] = kind;
|
|
392
|
+
const data = event.data;
|
|
393
|
+
if (kind === "embed_viewed" && data && typeof data.src === "string") {
|
|
394
|
+
extensions["https://lessonkit.dev/xapi/embedSrc"] = sanitizeTelemetryEmbedSrc(data.src);
|
|
395
|
+
}
|
|
396
|
+
if (kind === "chart_viewed" && data && typeof data.chartType === "string") {
|
|
397
|
+
extensions["https://lessonkit.dev/xapi/chartType"] = data.chartType;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return statementFor(
|
|
401
|
+
event,
|
|
402
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: lessonId2, blockId: blockId2 }),
|
|
403
|
+
XAPIVerbs.experienced,
|
|
404
|
+
ctx.timestamp,
|
|
405
|
+
Object.keys(extensions).length > 0 ? { context: { extensions } } : void 0
|
|
406
|
+
);
|
|
200
407
|
}
|
|
201
408
|
const lessonId = event.lessonId;
|
|
202
409
|
const blockId = "data" in event && event.data && "blockId" in event.data ? event.data.blockId : void 0;
|
|
203
410
|
if (!lessonId || !blockId || typeof blockId !== "string") return null;
|
|
204
|
-
return experiencedBlockStatement(ctx.courseId, lessonId, blockId, ctx.timestamp);
|
|
411
|
+
return experiencedBlockStatement(event, ctx.courseId, lessonId, blockId, ctx.timestamp);
|
|
205
412
|
};
|
|
206
413
|
var TELEMETRY_XAPI_MAPPERS = {
|
|
207
|
-
course_started: (
|
|
208
|
-
course_completed: (
|
|
414
|
+
course_started: (event, ctx) => statementFor(event, (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId }), XAPIVerbs.initialized, ctx.timestamp),
|
|
415
|
+
course_completed: (event, ctx) => statementFor(event, (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId }), XAPIVerbs.completed, ctx.timestamp),
|
|
209
416
|
lesson_started: (event, ctx) => {
|
|
210
417
|
const lessonId = event.name === "lesson_started" ? event.lessonId : void 0;
|
|
211
418
|
if (!lessonId) return null;
|
|
212
419
|
return statementFor(
|
|
420
|
+
event,
|
|
213
421
|
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId }),
|
|
214
422
|
XAPIVerbs.initialized,
|
|
215
423
|
ctx.timestamp
|
|
@@ -227,9 +435,15 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
227
435
|
if (typeof data?.success === "boolean") result.success = data.success;
|
|
228
436
|
const score = buildXapiScoreResult({ score: data?.score, maxScore: data?.maxScore });
|
|
229
437
|
if (score) result.score = score;
|
|
230
|
-
return statementFor(
|
|
231
|
-
|
|
232
|
-
|
|
438
|
+
return statementFor(
|
|
439
|
+
event,
|
|
440
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId }),
|
|
441
|
+
XAPIVerbs.completed,
|
|
442
|
+
ctx.timestamp,
|
|
443
|
+
{
|
|
444
|
+
result: Object.keys(result).length ? result : void 0
|
|
445
|
+
}
|
|
446
|
+
);
|
|
233
447
|
},
|
|
234
448
|
lesson_time_on_task: () => null,
|
|
235
449
|
quiz_answered: (event, ctx) => {
|
|
@@ -237,6 +451,7 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
237
451
|
const result = {};
|
|
238
452
|
if (typeof event.data.correct === "boolean") result.success = event.data.correct;
|
|
239
453
|
return statementFor(
|
|
454
|
+
event,
|
|
240
455
|
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
241
456
|
XAPIVerbs.answered,
|
|
242
457
|
ctx.timestamp,
|
|
@@ -247,6 +462,7 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
247
462
|
if (event.name !== "quiz_completed") return null;
|
|
248
463
|
const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
|
|
249
464
|
return statementFor(
|
|
465
|
+
event,
|
|
250
466
|
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
251
467
|
XAPIVerbs.completed,
|
|
252
468
|
ctx.timestamp,
|
|
@@ -258,6 +474,7 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
258
474
|
const result = {};
|
|
259
475
|
if (typeof event.data.correct === "boolean") result.success = event.data.correct;
|
|
260
476
|
return statementFor(
|
|
477
|
+
event,
|
|
261
478
|
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
262
479
|
XAPIVerbs.answered,
|
|
263
480
|
ctx.timestamp,
|
|
@@ -268,6 +485,7 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
268
485
|
if (event.name !== "assessment_completed") return null;
|
|
269
486
|
const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
|
|
270
487
|
return statementFor(
|
|
488
|
+
event,
|
|
271
489
|
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
272
490
|
XAPIVerbs.completed,
|
|
273
491
|
ctx.timestamp,
|
|
@@ -289,6 +507,7 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
289
507
|
const blockId = event.data.blockId;
|
|
290
508
|
if (!lessonId || !blockId) return null;
|
|
291
509
|
return statementFor(
|
|
510
|
+
event,
|
|
292
511
|
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId }),
|
|
293
512
|
XAPIVerbs.completed,
|
|
294
513
|
ctx.timestamp
|
|
@@ -303,20 +522,48 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
303
522
|
const blockId = event.data.blockId;
|
|
304
523
|
if (!lessonId || !blockId) return null;
|
|
305
524
|
return statementFor(
|
|
525
|
+
event,
|
|
306
526
|
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId }),
|
|
307
527
|
XAPIVerbs.completed,
|
|
308
528
|
ctx.timestamp
|
|
309
529
|
);
|
|
530
|
+
},
|
|
531
|
+
branch_node_viewed: (event, ctx) => {
|
|
532
|
+
if (event.name !== "branch_node_viewed") return null;
|
|
533
|
+
const lessonId = event.lessonId;
|
|
534
|
+
const blockId = event.data.blockId;
|
|
535
|
+
const nodeId = event.data.nodeId;
|
|
536
|
+
if (!lessonId || !blockId || !nodeId) return null;
|
|
537
|
+
return statementFor(
|
|
538
|
+
event,
|
|
539
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId, nodeId }),
|
|
540
|
+
XAPIVerbs.experienced,
|
|
541
|
+
ctx.timestamp
|
|
542
|
+
);
|
|
543
|
+
},
|
|
544
|
+
branch_selected: (event, ctx) => {
|
|
545
|
+
if (event.name !== "branch_selected") return null;
|
|
546
|
+
const lessonId = event.lessonId;
|
|
547
|
+
const blockId = event.data.blockId;
|
|
548
|
+
const toNodeId = event.data.toNodeId;
|
|
549
|
+
if (!lessonId || !blockId || !toNodeId) return null;
|
|
550
|
+
return statementFor(
|
|
551
|
+
event,
|
|
552
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId, nodeId: toNodeId }),
|
|
553
|
+
XAPIVerbs.experienced,
|
|
554
|
+
ctx.timestamp
|
|
555
|
+
);
|
|
310
556
|
}
|
|
311
557
|
};
|
|
312
558
|
function telemetryEventToXAPIStatement(event) {
|
|
313
|
-
const
|
|
559
|
+
const enriched = enrichTelemetryEventForXapi(event);
|
|
560
|
+
const mapper = TELEMETRY_XAPI_MAPPERS[enriched.name];
|
|
314
561
|
if (!mapper) {
|
|
315
|
-
throw new Error(`Unhandled telemetry event: ${
|
|
562
|
+
throw new Error(`Unhandled telemetry event: ${enriched.name}`);
|
|
316
563
|
}
|
|
317
|
-
return mapper(
|
|
318
|
-
courseId:
|
|
319
|
-
timestamp:
|
|
564
|
+
return mapper(enriched, {
|
|
565
|
+
courseId: enriched.courseId,
|
|
566
|
+
timestamp: enriched.timestamp
|
|
320
567
|
});
|
|
321
568
|
}
|
|
322
569
|
|
|
@@ -334,26 +581,86 @@ function isDevEnvironment() {
|
|
|
334
581
|
const g = globalThis;
|
|
335
582
|
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
336
583
|
}
|
|
584
|
+
function defaultQueueCapHandler() {
|
|
585
|
+
if (isDevEnvironment()) {
|
|
586
|
+
console.warn("[lessonkit] xAPI queue reached capacity; oldest statement(s) dropped.");
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
function defaultHeadSkippedHandler(_statement, err) {
|
|
590
|
+
if (isDevEnvironment()) {
|
|
591
|
+
console.warn(
|
|
592
|
+
"[lessonkit] xAPI queue skipped statement after repeated transport failures:",
|
|
593
|
+
err instanceof Error ? err.message : err
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
337
597
|
function createXAPIClient(opts) {
|
|
338
598
|
const transport = opts?.transport;
|
|
339
599
|
const exitTransport = opts?.exitTransport;
|
|
340
600
|
const courseId = opts?.courseId;
|
|
341
601
|
const queue = opts?.queue ?? createInMemoryXAPIQueue({
|
|
342
602
|
maxSize: opts?.maxQueueSize,
|
|
603
|
+
maxHeadFailures: opts?.maxHeadFailures,
|
|
343
604
|
onDepth: opts?.onQueueDepth,
|
|
344
|
-
onCap: opts?.onQueueCap
|
|
605
|
+
onCap: opts?.onQueueCap ?? defaultQueueCapHandler,
|
|
606
|
+
onOverflow: (statement) => {
|
|
607
|
+
persistDeadLetterStatement(statement);
|
|
608
|
+
},
|
|
609
|
+
onHeadSkipped: (statement, err) => {
|
|
610
|
+
persistDeadLetterStatement(statement);
|
|
611
|
+
(opts?.onHeadSkipped ?? defaultHeadSkippedHandler)(statement, err);
|
|
612
|
+
}
|
|
345
613
|
});
|
|
346
614
|
let warnedNoTransport = false;
|
|
347
615
|
let warnedTransportFailure = false;
|
|
348
616
|
const inflightById = /* @__PURE__ */ new Map();
|
|
349
617
|
const inflightStatements = /* @__PURE__ */ new Map();
|
|
618
|
+
const pendingReplacement = /* @__PURE__ */ new Map();
|
|
619
|
+
const inflightPayload = /* @__PURE__ */ new Map();
|
|
620
|
+
const replacementWatcher = /* @__PURE__ */ new Set();
|
|
350
621
|
const exitDeliveredIds = /* @__PURE__ */ new Set();
|
|
351
622
|
const exitNetworkSentIds = /* @__PURE__ */ new Set();
|
|
623
|
+
const exitHandoffIds = /* @__PURE__ */ new Set();
|
|
624
|
+
let activeFlush = null;
|
|
625
|
+
for (const statement of loadDeadLetterStatements()) {
|
|
626
|
+
queue.enqueue(statement);
|
|
627
|
+
}
|
|
628
|
+
const hadDeadLetters = queue.size() > 0;
|
|
352
629
|
const deliveryTransport = transport ? async (statement) => {
|
|
353
630
|
if (exitNetworkSentIds.has(statement.id)) return;
|
|
354
631
|
await transport(statement);
|
|
632
|
+
removeDeadLetterStatement(statement.id);
|
|
355
633
|
} : void 0;
|
|
356
|
-
const
|
|
634
|
+
const markExitDelivered = (statement) => {
|
|
635
|
+
exitHandoffIds.delete(statement.id);
|
|
636
|
+
exitDeliveredIds.add(statement.id);
|
|
637
|
+
exitNetworkSentIds.add(statement.id);
|
|
638
|
+
removeDeadLetterStatement(statement.id);
|
|
639
|
+
};
|
|
640
|
+
const dispatchExitStatement = (statement) => {
|
|
641
|
+
if (exitDeliveredIds.has(statement.id)) return;
|
|
642
|
+
exitHandoffIds.add(statement.id);
|
|
643
|
+
try {
|
|
644
|
+
const result = exitTransport(statement);
|
|
645
|
+
if (result != null && typeof result.then === "function") {
|
|
646
|
+
void result.then(
|
|
647
|
+
() => markExitDelivered(statement),
|
|
648
|
+
() => {
|
|
649
|
+
exitHandoffIds.delete(statement.id);
|
|
650
|
+
persistDeadLetterStatement(statement);
|
|
651
|
+
}
|
|
652
|
+
);
|
|
653
|
+
} else {
|
|
654
|
+
markExitDelivered(statement);
|
|
655
|
+
}
|
|
656
|
+
} catch {
|
|
657
|
+
exitHandoffIds.delete(statement.id);
|
|
658
|
+
persistDeadLetterStatement(statement);
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
const pendingDuringFlush = [];
|
|
662
|
+
let flushInProgress = false;
|
|
663
|
+
const sendOrQueueInternal = (statement) => {
|
|
357
664
|
const normalized = withStatementId2(statement);
|
|
358
665
|
if (exitDeliveredIds.has(normalized.id)) return;
|
|
359
666
|
if (!deliveryTransport) {
|
|
@@ -368,20 +675,38 @@ function createXAPIClient(opts) {
|
|
|
368
675
|
}
|
|
369
676
|
const existing = inflightById.get(normalized.id);
|
|
370
677
|
if (existing) {
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
678
|
+
pendingReplacement.set(normalized.id, normalized);
|
|
679
|
+
inflightStatements.set(normalized.id, normalized);
|
|
680
|
+
if (!replacementWatcher.has(normalized.id)) {
|
|
681
|
+
replacementWatcher.add(normalized.id);
|
|
682
|
+
void existing.then(
|
|
683
|
+
() => {
|
|
684
|
+
replacementWatcher.delete(normalized.id);
|
|
685
|
+
const replacement = pendingReplacement.get(normalized.id);
|
|
686
|
+
const transported = inflightPayload.get(normalized.id);
|
|
687
|
+
pendingReplacement.delete(normalized.id);
|
|
688
|
+
inflightPayload.delete(normalized.id);
|
|
689
|
+
if (replacement && replacement !== transported) {
|
|
690
|
+
sendOrQueueInternal(replacement);
|
|
691
|
+
}
|
|
692
|
+
},
|
|
693
|
+
() => {
|
|
694
|
+
replacementWatcher.delete(normalized.id);
|
|
695
|
+
const replacement = pendingReplacement.get(normalized.id) ?? normalized;
|
|
696
|
+
pendingReplacement.delete(normalized.id);
|
|
697
|
+
sendOrQueueInternal(replacement);
|
|
698
|
+
}
|
|
699
|
+
);
|
|
700
|
+
}
|
|
377
701
|
return;
|
|
378
702
|
}
|
|
379
703
|
inflightStatements.set(normalized.id, normalized);
|
|
704
|
+
inflightPayload.set(normalized.id, normalized);
|
|
380
705
|
const flight = Promise.resolve().then(async () => {
|
|
381
706
|
await deliveryTransport(normalized);
|
|
382
707
|
queue.removeById(normalized.id);
|
|
383
708
|
}).catch((err) => {
|
|
384
|
-
if (exitDeliveredIds.has(normalized.id)) return;
|
|
709
|
+
if (exitDeliveredIds.has(normalized.id) || exitHandoffIds.has(normalized.id)) return;
|
|
385
710
|
queue.enqueue(normalized);
|
|
386
711
|
opts?.onTransportError?.(err);
|
|
387
712
|
if (isDevEnvironment() && !warnedTransportFailure) {
|
|
@@ -394,17 +719,28 @@ function createXAPIClient(opts) {
|
|
|
394
719
|
}).finally(() => {
|
|
395
720
|
inflightById.delete(normalized.id);
|
|
396
721
|
inflightStatements.delete(normalized.id);
|
|
722
|
+
if (!replacementWatcher.has(normalized.id)) {
|
|
723
|
+
inflightPayload.delete(normalized.id);
|
|
724
|
+
}
|
|
397
725
|
});
|
|
398
726
|
inflightById.set(normalized.id, flight);
|
|
399
727
|
void flight.catch(() => {
|
|
400
728
|
});
|
|
401
729
|
};
|
|
730
|
+
const sendOrQueue = (statement) => {
|
|
731
|
+
if (flushInProgress) {
|
|
732
|
+
pendingDuringFlush.push(statement);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
sendOrQueueInternal(statement);
|
|
736
|
+
};
|
|
402
737
|
const emit = (event) => {
|
|
403
738
|
try {
|
|
404
739
|
const statement = telemetryEventToXAPIStatement(event);
|
|
405
740
|
if (!statement) return;
|
|
406
741
|
sendOrQueue(statement);
|
|
407
742
|
} catch (err) {
|
|
743
|
+
opts?.onMappingError?.(err);
|
|
408
744
|
if (isDevEnvironment()) {
|
|
409
745
|
console.warn(
|
|
410
746
|
"[lessonkit] xAPI mapping skipped:",
|
|
@@ -413,42 +749,66 @@ function createXAPIClient(opts) {
|
|
|
413
749
|
}
|
|
414
750
|
}
|
|
415
751
|
};
|
|
416
|
-
|
|
752
|
+
const runFlushLoop = async () => {
|
|
753
|
+
if (!deliveryTransport) return;
|
|
754
|
+
for (; ; ) {
|
|
755
|
+
await queue.flush(deliveryTransport);
|
|
756
|
+
const flights = [...inflightById.values()];
|
|
757
|
+
if (flights.length > 0) {
|
|
758
|
+
await Promise.all(flights);
|
|
759
|
+
}
|
|
760
|
+
if (queue.size() === 0 && inflightById.size === 0) break;
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
const client = {
|
|
417
764
|
send: (statement) => {
|
|
418
765
|
sendOrQueue(statement);
|
|
419
766
|
},
|
|
420
767
|
queueSize: () => queue.size(),
|
|
421
768
|
flush: async () => {
|
|
422
769
|
if (!deliveryTransport) return;
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
770
|
+
for (; ; ) {
|
|
771
|
+
if (activeFlush) {
|
|
772
|
+
await activeFlush;
|
|
773
|
+
} else {
|
|
774
|
+
flushInProgress = true;
|
|
775
|
+
activeFlush = (async () => {
|
|
776
|
+
try {
|
|
777
|
+
await runFlushLoop();
|
|
778
|
+
while (pendingDuringFlush.length > 0) {
|
|
779
|
+
const batch = pendingDuringFlush.splice(0, pendingDuringFlush.length);
|
|
780
|
+
for (const pending of batch) {
|
|
781
|
+
sendOrQueueInternal(pending);
|
|
782
|
+
}
|
|
783
|
+
await runFlushLoop();
|
|
784
|
+
}
|
|
785
|
+
} finally {
|
|
786
|
+
flushInProgress = false;
|
|
787
|
+
}
|
|
788
|
+
})().finally(() => {
|
|
789
|
+
activeFlush = null;
|
|
790
|
+
});
|
|
791
|
+
await activeFlush;
|
|
792
|
+
}
|
|
793
|
+
if (queue.size() === 0 && inflightById.size === 0) break;
|
|
430
794
|
}
|
|
431
795
|
},
|
|
432
796
|
flushOnExit: exitTransport ? () => {
|
|
433
797
|
const headId = queue.getHeadInFlightId?.();
|
|
434
798
|
if (headId) {
|
|
435
|
-
exitNetworkSentIds.add(headId);
|
|
436
|
-
exitDeliveredIds.add(headId);
|
|
437
799
|
opts.abortInFlight?.(headId);
|
|
800
|
+
const headStatement = inflightStatements.get(headId);
|
|
801
|
+
if (headStatement) {
|
|
802
|
+
dispatchExitStatement(headStatement);
|
|
803
|
+
}
|
|
438
804
|
}
|
|
439
805
|
for (const statement of inflightStatements.values()) {
|
|
440
|
-
|
|
441
|
-
exitDeliveredIds.add(statement.id);
|
|
806
|
+
if (statement.id === headId) continue;
|
|
442
807
|
opts.abortInFlight?.(statement.id);
|
|
808
|
+
dispatchExitStatement(statement);
|
|
443
809
|
}
|
|
444
810
|
queue.flushOnExit((statement) => {
|
|
445
|
-
|
|
446
|
-
exitNetworkSentIds.add(statement.id);
|
|
447
|
-
exitDeliveredIds.add(statement.id);
|
|
448
|
-
try {
|
|
449
|
-
exitTransport(statement);
|
|
450
|
-
} catch {
|
|
451
|
-
}
|
|
811
|
+
dispatchExitStatement(statement);
|
|
452
812
|
});
|
|
453
813
|
} : void 0,
|
|
454
814
|
startedLesson: ({ lessonId }) => {
|
|
@@ -486,6 +846,98 @@ function createXAPIClient(opts) {
|
|
|
486
846
|
});
|
|
487
847
|
}
|
|
488
848
|
};
|
|
849
|
+
if (hadDeadLetters && deliveryTransport) {
|
|
850
|
+
queueMicrotask(() => {
|
|
851
|
+
void client.flush().catch(() => void 0);
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
return client;
|
|
855
|
+
}
|
|
856
|
+
function resetXAPIDeadLetterForTests() {
|
|
857
|
+
clearDeadLetterStorage();
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// src/safeLrsUrl.ts
|
|
861
|
+
var import_meta = {};
|
|
862
|
+
function isProductionRuntime() {
|
|
863
|
+
try {
|
|
864
|
+
if (import_meta.env?.PROD === true) return true;
|
|
865
|
+
} catch {
|
|
866
|
+
}
|
|
867
|
+
const g = globalThis;
|
|
868
|
+
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production";
|
|
869
|
+
}
|
|
870
|
+
function parseHostname(url) {
|
|
871
|
+
return url.hostname.replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
|
|
872
|
+
}
|
|
873
|
+
function isIpv4MappedAddress(hostname) {
|
|
874
|
+
const match = hostname.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
|
875
|
+
return match?.[1] ?? null;
|
|
876
|
+
}
|
|
877
|
+
function isLoopbackHost(hostname) {
|
|
878
|
+
const ipv4Mapped = isIpv4MappedAddress(hostname);
|
|
879
|
+
if (ipv4Mapped) return isLoopbackHost(ipv4Mapped);
|
|
880
|
+
return hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "127.0.0.1" || hostname === "::1" || hostname === "0.0.0.0";
|
|
881
|
+
}
|
|
882
|
+
function isLinkLocalOrMetadataHost(hostname) {
|
|
883
|
+
if (hostname === "169.254.169.254") return true;
|
|
884
|
+
if (/^169\.254\./.test(hostname)) return true;
|
|
885
|
+
if (/^fe80:/i.test(hostname)) return true;
|
|
886
|
+
return false;
|
|
887
|
+
}
|
|
888
|
+
function isRfc1918Host(hostname) {
|
|
889
|
+
const ipv4Mapped = isIpv4MappedAddress(hostname);
|
|
890
|
+
if (ipv4Mapped) return isRfc1918Host(ipv4Mapped);
|
|
891
|
+
if (/^10\./.test(hostname)) return true;
|
|
892
|
+
if (/^192\.168\./.test(hostname)) return true;
|
|
893
|
+
const parts = hostname.split(".").map(Number);
|
|
894
|
+
if (parts.length === 4 && parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
|
|
895
|
+
return false;
|
|
896
|
+
}
|
|
897
|
+
function isPrivateOrMetadataHost(hostname) {
|
|
898
|
+
return isLoopbackHost(hostname) || isLinkLocalOrMetadataHost(hostname) || isRfc1918Host(hostname);
|
|
899
|
+
}
|
|
900
|
+
function containsPathTraversal(path) {
|
|
901
|
+
if (path.includes("..")) return true;
|
|
902
|
+
let decoded = path;
|
|
903
|
+
for (let i = 0; i < 2; i++) {
|
|
904
|
+
try {
|
|
905
|
+
const next = decodeURIComponent(decoded.replace(/\+/g, " "));
|
|
906
|
+
if (next.includes("..")) return true;
|
|
907
|
+
if (next === decoded) break;
|
|
908
|
+
decoded = next;
|
|
909
|
+
} catch {
|
|
910
|
+
break;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
return false;
|
|
914
|
+
}
|
|
915
|
+
function assertSafeLrsUrl(url, opts) {
|
|
916
|
+
if (url.startsWith("/")) {
|
|
917
|
+
if (containsPathTraversal(url)) {
|
|
918
|
+
throw new Error(`Unsafe LRS URL: path traversal in "${url}"`);
|
|
919
|
+
}
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
let parsed;
|
|
923
|
+
try {
|
|
924
|
+
parsed = new URL(url);
|
|
925
|
+
} catch {
|
|
926
|
+
throw new Error(`Unsafe LRS URL: invalid URL "${url}"`);
|
|
927
|
+
}
|
|
928
|
+
if (containsPathTraversal(parsed.pathname)) {
|
|
929
|
+
throw new Error(`Unsafe LRS URL: path traversal in "${url}"`);
|
|
930
|
+
}
|
|
931
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
932
|
+
throw new Error(`Unsafe LRS URL: unsupported scheme "${parsed.protocol}"`);
|
|
933
|
+
}
|
|
934
|
+
if (isProductionRuntime() && parsed.protocol !== "https:") {
|
|
935
|
+
throw new Error("Unsafe LRS URL: HTTPS is required in production builds");
|
|
936
|
+
}
|
|
937
|
+
const hostname = parseHostname(parsed);
|
|
938
|
+
if (!opts?.allowPrivateHosts && isPrivateOrMetadataHost(hostname)) {
|
|
939
|
+
throw new Error(`Unsafe LRS URL: private or metadata host "${hostname}" is not allowed`);
|
|
940
|
+
}
|
|
489
941
|
}
|
|
490
942
|
|
|
491
943
|
// src/fetchTransport.ts
|
|
@@ -555,6 +1007,7 @@ async function postWithRetry(post, retries, initialBackoffMs, maxBackoffMs) {
|
|
|
555
1007
|
}
|
|
556
1008
|
}
|
|
557
1009
|
function createFetchTransport(opts) {
|
|
1010
|
+
assertSafeLrsUrl(opts.url, { allowPrivateHosts: opts.allowPrivateHosts });
|
|
558
1011
|
const timeoutMs = opts.timeoutMs ?? 3e4;
|
|
559
1012
|
const rawRetries = opts.retries ?? 2;
|
|
560
1013
|
const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
|
|
@@ -586,14 +1039,13 @@ function createFetchTransport(opts) {
|
|
|
586
1039
|
}
|
|
587
1040
|
};
|
|
588
1041
|
const exitTransport = (statement) => {
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
}
|
|
596
|
-
}
|
|
1042
|
+
return postStatement(opts.url, statement, {
|
|
1043
|
+
...opts.init,
|
|
1044
|
+
headers: resolveHeaders(opts.headers),
|
|
1045
|
+
keepalive: true
|
|
1046
|
+
}).catch(() => {
|
|
1047
|
+
throw new Error("xAPI keepalive delivery failed");
|
|
1048
|
+
});
|
|
597
1049
|
};
|
|
598
1050
|
const abortInFlight = (statementId) => {
|
|
599
1051
|
activeControllers.get(statementId)?.abort();
|
|
@@ -602,6 +1054,7 @@ function createFetchTransport(opts) {
|
|
|
602
1054
|
return { transport, exitTransport, abortInFlight };
|
|
603
1055
|
}
|
|
604
1056
|
function createFetchBatchSink(opts) {
|
|
1057
|
+
assertSafeLrsUrl(opts.url, { allowPrivateHosts: opts.allowPrivateHosts });
|
|
605
1058
|
const timeoutMs = opts.timeoutMs ?? 3e4;
|
|
606
1059
|
const rawRetries = opts.retries ?? 2;
|
|
607
1060
|
const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
|
|
@@ -625,27 +1078,32 @@ function createFetchBatchSink(opts) {
|
|
|
625
1078
|
return {
|
|
626
1079
|
batchSink: (events) => postBatch(events, opts.init ?? {}),
|
|
627
1080
|
exitBatchSink: (events) => {
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
1081
|
+
return fetch(opts.url, {
|
|
1082
|
+
method: "POST",
|
|
1083
|
+
body: JSON.stringify(events),
|
|
1084
|
+
...opts.init,
|
|
1085
|
+
headers: resolveHeaders(opts.headers),
|
|
1086
|
+
keepalive: true
|
|
1087
|
+
}).then((res) => {
|
|
1088
|
+
if (!res.ok) {
|
|
1089
|
+
throw new FetchHttpError(res.status, res.statusText, "batch");
|
|
1090
|
+
}
|
|
1091
|
+
});
|
|
638
1092
|
}
|
|
639
1093
|
};
|
|
640
1094
|
}
|
|
641
1095
|
// Annotate the CommonJS export names for ESM import in node:
|
|
642
1096
|
0 && (module.exports = {
|
|
643
1097
|
FetchHttpError,
|
|
1098
|
+
assertSafeLrsUrl,
|
|
644
1099
|
createFetchBatchSink,
|
|
645
1100
|
createFetchTransport,
|
|
646
1101
|
createInMemoryXAPIQueue,
|
|
647
1102
|
createXAPIClient,
|
|
648
1103
|
isRetryableFetchError,
|
|
649
1104
|
isRetryableFetchHttpStatus,
|
|
1105
|
+
loadDeadLetterStatements,
|
|
1106
|
+
persistDeadLetterStatement,
|
|
1107
|
+
resetXAPIDeadLetterForTests,
|
|
650
1108
|
telemetryEventToXAPIStatement
|
|
651
1109
|
});
|