@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.js
CHANGED
|
@@ -1,8 +1,116 @@
|
|
|
1
1
|
// src/id.ts
|
|
2
|
+
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;
|
|
3
|
+
function randomIdFallback() {
|
|
4
|
+
const g = globalThis;
|
|
5
|
+
if (g.crypto?.getRandomValues) {
|
|
6
|
+
const bytes = new Uint8Array(16);
|
|
7
|
+
g.crypto.getRandomValues(bytes);
|
|
8
|
+
return formatUuid(bytes);
|
|
9
|
+
}
|
|
10
|
+
throw new Error(
|
|
11
|
+
"[lessonkit] cryptoRandomId requires crypto.randomUUID or crypto.getRandomValues"
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
function formatUuid(bytes) {
|
|
15
|
+
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
16
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
17
|
+
}
|
|
18
|
+
function hashSeedToUuid(seed) {
|
|
19
|
+
const bytes = new Uint8Array(16);
|
|
20
|
+
let h0 = 2166136261;
|
|
21
|
+
let h1 = 2166136261 ^ 2654435769;
|
|
22
|
+
let h2 = 2166136261 ^ 2246822507;
|
|
23
|
+
let h3 = 2166136261 ^ 3266489909;
|
|
24
|
+
for (let i = 0; i < seed.length; i += 1) {
|
|
25
|
+
const c = seed.charCodeAt(i);
|
|
26
|
+
h0 = Math.imul(h0 ^ c, 16777619);
|
|
27
|
+
h1 = Math.imul(h1 ^ c + 1, 16777619);
|
|
28
|
+
h2 = Math.imul(h2 ^ c + 2, 16777619);
|
|
29
|
+
h3 = Math.imul(h3 ^ c + 3, 16777619);
|
|
30
|
+
}
|
|
31
|
+
bytes[0] = h0 >>> 24 & 255;
|
|
32
|
+
bytes[1] = h0 >>> 16 & 255;
|
|
33
|
+
bytes[2] = h0 >>> 8 & 255;
|
|
34
|
+
bytes[3] = h0 & 255;
|
|
35
|
+
bytes[4] = h1 >>> 24 & 255;
|
|
36
|
+
bytes[5] = h1 >>> 16 & 255;
|
|
37
|
+
bytes[6] = h1 >>> 8 & 15 | 80;
|
|
38
|
+
bytes[7] = h1 & 255;
|
|
39
|
+
bytes[8] = h2 >>> 24 & 63 | 128;
|
|
40
|
+
bytes[9] = h2 >>> 16 & 255;
|
|
41
|
+
bytes[10] = h2 >>> 8 & 255;
|
|
42
|
+
bytes[11] = h2 & 255;
|
|
43
|
+
bytes[12] = h3 >>> 24 & 255;
|
|
44
|
+
bytes[13] = h3 >>> 16 & 255;
|
|
45
|
+
bytes[14] = h3 >>> 8 & 255;
|
|
46
|
+
bytes[15] = h3 & 255;
|
|
47
|
+
return formatUuid(bytes);
|
|
48
|
+
}
|
|
2
49
|
function cryptoRandomId() {
|
|
3
50
|
const g = globalThis;
|
|
4
51
|
if (g.crypto?.randomUUID) return g.crypto.randomUUID();
|
|
5
|
-
return
|
|
52
|
+
return randomIdFallback();
|
|
53
|
+
}
|
|
54
|
+
var LIFECYCLE_EVENTS_FOR_STABLE_ID = /* @__PURE__ */ new Set([
|
|
55
|
+
"course_started",
|
|
56
|
+
"course_completed",
|
|
57
|
+
"lesson_started",
|
|
58
|
+
"lesson_completed"
|
|
59
|
+
]);
|
|
60
|
+
var ASSESSMENT_COMPLETION_EVENTS_FOR_STABLE_ID = /* @__PURE__ */ new Set([
|
|
61
|
+
"assessment_completed",
|
|
62
|
+
"quiz_completed"
|
|
63
|
+
]);
|
|
64
|
+
function stableTelemetryEventId(event) {
|
|
65
|
+
const seed = [
|
|
66
|
+
event.name,
|
|
67
|
+
event.courseId,
|
|
68
|
+
event.lessonId ?? "",
|
|
69
|
+
event.sessionId ?? "",
|
|
70
|
+
event.attemptId ?? ""
|
|
71
|
+
].join("|");
|
|
72
|
+
return hashSeedToUuid(seed);
|
|
73
|
+
}
|
|
74
|
+
function stableAssessmentCompletionEventId(event) {
|
|
75
|
+
const checkId = event.data && typeof event.data === "object" && "checkId" in event.data ? String(event.data.checkId ?? "") : "";
|
|
76
|
+
const seed = [
|
|
77
|
+
event.name,
|
|
78
|
+
event.courseId,
|
|
79
|
+
event.lessonId ?? "",
|
|
80
|
+
event.sessionId ?? "",
|
|
81
|
+
event.attemptId ?? "",
|
|
82
|
+
checkId
|
|
83
|
+
].join("|");
|
|
84
|
+
return hashSeedToUuid(seed);
|
|
85
|
+
}
|
|
86
|
+
function enrichTelemetryEventForXapi(event) {
|
|
87
|
+
if (event.id?.trim()) return event;
|
|
88
|
+
if (LIFECYCLE_EVENTS_FOR_STABLE_ID.has(event.name)) {
|
|
89
|
+
return { ...event, id: stableTelemetryEventId(event) };
|
|
90
|
+
}
|
|
91
|
+
if (ASSESSMENT_COMPLETION_EVENTS_FOR_STABLE_ID.has(event.name)) {
|
|
92
|
+
return {
|
|
93
|
+
...event,
|
|
94
|
+
id: stableAssessmentCompletionEventId(
|
|
95
|
+
event
|
|
96
|
+
)
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return event;
|
|
100
|
+
}
|
|
101
|
+
function deriveStatementId(event, objectId, verb) {
|
|
102
|
+
const eventId = event.id?.trim();
|
|
103
|
+
if (eventId && UUID_RE.test(eventId)) return eventId;
|
|
104
|
+
const seed = [
|
|
105
|
+
event.name,
|
|
106
|
+
event.courseId,
|
|
107
|
+
event.lessonId ?? "",
|
|
108
|
+
event.sessionId ?? "",
|
|
109
|
+
objectId,
|
|
110
|
+
verb,
|
|
111
|
+
event.timestamp
|
|
112
|
+
].join("|");
|
|
113
|
+
return hashSeedToUuid(seed);
|
|
6
114
|
}
|
|
7
115
|
|
|
8
116
|
// src/queue.ts
|
|
@@ -64,16 +172,26 @@ function createInMemoryXAPIQueue(opts) {
|
|
|
64
172
|
return {
|
|
65
173
|
enqueue: (statement) => {
|
|
66
174
|
const normalized = withStatementId(statement);
|
|
67
|
-
|
|
175
|
+
const existingIdx = buffer.findIndex((s) => s.id === normalized.id);
|
|
176
|
+
if (existingIdx >= 0) {
|
|
177
|
+
buffer[existingIdx] = normalized;
|
|
178
|
+
notifyDepth();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
68
181
|
if (buffer.length >= maxSize) {
|
|
69
182
|
if (headInFlight) {
|
|
70
183
|
if (buffer.length > 1) {
|
|
71
184
|
buffer.splice(1, 1);
|
|
185
|
+
opts?.onCap?.();
|
|
186
|
+
} else {
|
|
187
|
+
opts?.onCap?.();
|
|
188
|
+
opts?.onOverflow?.(normalized);
|
|
189
|
+
return;
|
|
72
190
|
}
|
|
73
191
|
} else {
|
|
74
192
|
buffer.shift();
|
|
193
|
+
opts?.onCap?.();
|
|
75
194
|
}
|
|
76
|
-
opts?.onCap?.();
|
|
77
195
|
}
|
|
78
196
|
buffer.push(normalized);
|
|
79
197
|
notifyDepth();
|
|
@@ -105,6 +223,60 @@ function createInMemoryXAPIQueue(opts) {
|
|
|
105
223
|
// src/client.ts
|
|
106
224
|
import { nowIso } from "@lessonkit/core";
|
|
107
225
|
|
|
226
|
+
// src/deadLetter.ts
|
|
227
|
+
var STORAGE_KEY = "lk-xapi-dead-letter";
|
|
228
|
+
var MAX_DEAD_LETTER = 200;
|
|
229
|
+
function readStorage() {
|
|
230
|
+
try {
|
|
231
|
+
const storage = globalThis.sessionStorage;
|
|
232
|
+
return storage ?? null;
|
|
233
|
+
} catch {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function loadDeadLetterStatements() {
|
|
238
|
+
const storage = readStorage();
|
|
239
|
+
if (!storage) return [];
|
|
240
|
+
try {
|
|
241
|
+
const raw = storage.getItem(STORAGE_KEY);
|
|
242
|
+
if (!raw) return [];
|
|
243
|
+
const parsed = JSON.parse(raw);
|
|
244
|
+
if (!Array.isArray(parsed)) return [];
|
|
245
|
+
return parsed.filter(
|
|
246
|
+
(item) => typeof item === "object" && item !== null && typeof item.id === "string"
|
|
247
|
+
);
|
|
248
|
+
} catch {
|
|
249
|
+
return [];
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
function persistDeadLetterStatement(statement) {
|
|
253
|
+
const storage = readStorage();
|
|
254
|
+
if (!storage) return;
|
|
255
|
+
try {
|
|
256
|
+
const existing = loadDeadLetterStatements();
|
|
257
|
+
if (existing.some((s) => s.id === statement.id)) return;
|
|
258
|
+
const next = [...existing, statement].slice(-MAX_DEAD_LETTER);
|
|
259
|
+
storage.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
260
|
+
} catch {
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
function removeDeadLetterStatement(id) {
|
|
264
|
+
const storage = readStorage();
|
|
265
|
+
if (!storage) return;
|
|
266
|
+
try {
|
|
267
|
+
const next = loadDeadLetterStatements().filter((s) => s.id !== id);
|
|
268
|
+
if (next.length === 0) {
|
|
269
|
+
storage.removeItem(STORAGE_KEY);
|
|
270
|
+
} else {
|
|
271
|
+
storage.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
272
|
+
}
|
|
273
|
+
} catch {
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
function clearDeadLetterStorage() {
|
|
277
|
+
readStorage()?.removeItem(STORAGE_KEY);
|
|
278
|
+
}
|
|
279
|
+
|
|
108
280
|
// src/telemetryMap.ts
|
|
109
281
|
import { buildLessonkitUrn } from "@lessonkit/core";
|
|
110
282
|
|
|
@@ -141,9 +313,9 @@ function buildXapiScoreResult(opts) {
|
|
|
141
313
|
}
|
|
142
314
|
return result;
|
|
143
315
|
}
|
|
144
|
-
function statementFor(objectId, verb, timestamp, extra) {
|
|
316
|
+
function statementFor(event, objectId, verb, timestamp, extra) {
|
|
145
317
|
return {
|
|
146
|
-
id:
|
|
318
|
+
id: deriveStatementId(event, objectId, verb),
|
|
147
319
|
timestamp,
|
|
148
320
|
verb,
|
|
149
321
|
object: { id: objectId },
|
|
@@ -151,8 +323,21 @@ function statementFor(objectId, verb, timestamp, extra) {
|
|
|
151
323
|
context: extra?.context
|
|
152
324
|
};
|
|
153
325
|
}
|
|
154
|
-
function
|
|
326
|
+
function sanitizeTelemetryEmbedSrc(src) {
|
|
327
|
+
try {
|
|
328
|
+
const url = new URL(src);
|
|
329
|
+
url.username = "";
|
|
330
|
+
url.password = "";
|
|
331
|
+
url.search = "";
|
|
332
|
+
url.hash = "";
|
|
333
|
+
return `${url.origin}${url.pathname}`;
|
|
334
|
+
} catch {
|
|
335
|
+
return src;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
function experiencedBlockStatement(event, courseId, lessonId, blockId, timestamp) {
|
|
155
339
|
return statementFor(
|
|
340
|
+
event,
|
|
156
341
|
buildLessonkitUrn({ courseId, lessonId, blockId }),
|
|
157
342
|
XAPIVerbs.experienced,
|
|
158
343
|
timestamp
|
|
@@ -163,20 +348,39 @@ var experiencedBlockMapper = (event, ctx) => {
|
|
|
163
348
|
const lessonId2 = event.lessonId;
|
|
164
349
|
const blockId2 = event.data?.blockId;
|
|
165
350
|
if (!lessonId2 || !blockId2 || typeof blockId2 !== "string") return null;
|
|
166
|
-
|
|
351
|
+
const kind = event.data?.kind;
|
|
352
|
+
const extensions = {};
|
|
353
|
+
if (kind === "embed_viewed" || kind === "chart_viewed") {
|
|
354
|
+
extensions["https://lessonkit.dev/xapi/interactionKind"] = kind;
|
|
355
|
+
const data = event.data;
|
|
356
|
+
if (kind === "embed_viewed" && data && typeof data.src === "string") {
|
|
357
|
+
extensions["https://lessonkit.dev/xapi/embedSrc"] = sanitizeTelemetryEmbedSrc(data.src);
|
|
358
|
+
}
|
|
359
|
+
if (kind === "chart_viewed" && data && typeof data.chartType === "string") {
|
|
360
|
+
extensions["https://lessonkit.dev/xapi/chartType"] = data.chartType;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return statementFor(
|
|
364
|
+
event,
|
|
365
|
+
buildLessonkitUrn({ courseId: ctx.courseId, lessonId: lessonId2, blockId: blockId2 }),
|
|
366
|
+
XAPIVerbs.experienced,
|
|
367
|
+
ctx.timestamp,
|
|
368
|
+
Object.keys(extensions).length > 0 ? { context: { extensions } } : void 0
|
|
369
|
+
);
|
|
167
370
|
}
|
|
168
371
|
const lessonId = event.lessonId;
|
|
169
372
|
const blockId = "data" in event && event.data && "blockId" in event.data ? event.data.blockId : void 0;
|
|
170
373
|
if (!lessonId || !blockId || typeof blockId !== "string") return null;
|
|
171
|
-
return experiencedBlockStatement(ctx.courseId, lessonId, blockId, ctx.timestamp);
|
|
374
|
+
return experiencedBlockStatement(event, ctx.courseId, lessonId, blockId, ctx.timestamp);
|
|
172
375
|
};
|
|
173
376
|
var TELEMETRY_XAPI_MAPPERS = {
|
|
174
|
-
course_started: (
|
|
175
|
-
course_completed: (
|
|
377
|
+
course_started: (event, ctx) => statementFor(event, buildLessonkitUrn({ courseId: ctx.courseId }), XAPIVerbs.initialized, ctx.timestamp),
|
|
378
|
+
course_completed: (event, ctx) => statementFor(event, buildLessonkitUrn({ courseId: ctx.courseId }), XAPIVerbs.completed, ctx.timestamp),
|
|
176
379
|
lesson_started: (event, ctx) => {
|
|
177
380
|
const lessonId = event.name === "lesson_started" ? event.lessonId : void 0;
|
|
178
381
|
if (!lessonId) return null;
|
|
179
382
|
return statementFor(
|
|
383
|
+
event,
|
|
180
384
|
buildLessonkitUrn({ courseId: ctx.courseId, lessonId }),
|
|
181
385
|
XAPIVerbs.initialized,
|
|
182
386
|
ctx.timestamp
|
|
@@ -194,9 +398,15 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
194
398
|
if (typeof data?.success === "boolean") result.success = data.success;
|
|
195
399
|
const score = buildXapiScoreResult({ score: data?.score, maxScore: data?.maxScore });
|
|
196
400
|
if (score) result.score = score;
|
|
197
|
-
return statementFor(
|
|
198
|
-
|
|
199
|
-
|
|
401
|
+
return statementFor(
|
|
402
|
+
event,
|
|
403
|
+
buildLessonkitUrn({ courseId: ctx.courseId, lessonId }),
|
|
404
|
+
XAPIVerbs.completed,
|
|
405
|
+
ctx.timestamp,
|
|
406
|
+
{
|
|
407
|
+
result: Object.keys(result).length ? result : void 0
|
|
408
|
+
}
|
|
409
|
+
);
|
|
200
410
|
},
|
|
201
411
|
lesson_time_on_task: () => null,
|
|
202
412
|
quiz_answered: (event, ctx) => {
|
|
@@ -204,6 +414,7 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
204
414
|
const result = {};
|
|
205
415
|
if (typeof event.data.correct === "boolean") result.success = event.data.correct;
|
|
206
416
|
return statementFor(
|
|
417
|
+
event,
|
|
207
418
|
buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
208
419
|
XAPIVerbs.answered,
|
|
209
420
|
ctx.timestamp,
|
|
@@ -214,6 +425,7 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
214
425
|
if (event.name !== "quiz_completed") return null;
|
|
215
426
|
const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
|
|
216
427
|
return statementFor(
|
|
428
|
+
event,
|
|
217
429
|
buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
218
430
|
XAPIVerbs.completed,
|
|
219
431
|
ctx.timestamp,
|
|
@@ -225,6 +437,7 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
225
437
|
const result = {};
|
|
226
438
|
if (typeof event.data.correct === "boolean") result.success = event.data.correct;
|
|
227
439
|
return statementFor(
|
|
440
|
+
event,
|
|
228
441
|
buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
229
442
|
XAPIVerbs.answered,
|
|
230
443
|
ctx.timestamp,
|
|
@@ -235,6 +448,7 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
235
448
|
if (event.name !== "assessment_completed") return null;
|
|
236
449
|
const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
|
|
237
450
|
return statementFor(
|
|
451
|
+
event,
|
|
238
452
|
buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
239
453
|
XAPIVerbs.completed,
|
|
240
454
|
ctx.timestamp,
|
|
@@ -256,6 +470,7 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
256
470
|
const blockId = event.data.blockId;
|
|
257
471
|
if (!lessonId || !blockId) return null;
|
|
258
472
|
return statementFor(
|
|
473
|
+
event,
|
|
259
474
|
buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId }),
|
|
260
475
|
XAPIVerbs.completed,
|
|
261
476
|
ctx.timestamp
|
|
@@ -270,20 +485,48 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
270
485
|
const blockId = event.data.blockId;
|
|
271
486
|
if (!lessonId || !blockId) return null;
|
|
272
487
|
return statementFor(
|
|
488
|
+
event,
|
|
273
489
|
buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId }),
|
|
274
490
|
XAPIVerbs.completed,
|
|
275
491
|
ctx.timestamp
|
|
276
492
|
);
|
|
493
|
+
},
|
|
494
|
+
branch_node_viewed: (event, ctx) => {
|
|
495
|
+
if (event.name !== "branch_node_viewed") return null;
|
|
496
|
+
const lessonId = event.lessonId;
|
|
497
|
+
const blockId = event.data.blockId;
|
|
498
|
+
const nodeId = event.data.nodeId;
|
|
499
|
+
if (!lessonId || !blockId || !nodeId) return null;
|
|
500
|
+
return statementFor(
|
|
501
|
+
event,
|
|
502
|
+
buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId, nodeId }),
|
|
503
|
+
XAPIVerbs.experienced,
|
|
504
|
+
ctx.timestamp
|
|
505
|
+
);
|
|
506
|
+
},
|
|
507
|
+
branch_selected: (event, ctx) => {
|
|
508
|
+
if (event.name !== "branch_selected") return null;
|
|
509
|
+
const lessonId = event.lessonId;
|
|
510
|
+
const blockId = event.data.blockId;
|
|
511
|
+
const toNodeId = event.data.toNodeId;
|
|
512
|
+
if (!lessonId || !blockId || !toNodeId) return null;
|
|
513
|
+
return statementFor(
|
|
514
|
+
event,
|
|
515
|
+
buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId, nodeId: toNodeId }),
|
|
516
|
+
XAPIVerbs.experienced,
|
|
517
|
+
ctx.timestamp
|
|
518
|
+
);
|
|
277
519
|
}
|
|
278
520
|
};
|
|
279
521
|
function telemetryEventToXAPIStatement(event) {
|
|
280
|
-
const
|
|
522
|
+
const enriched = enrichTelemetryEventForXapi(event);
|
|
523
|
+
const mapper = TELEMETRY_XAPI_MAPPERS[enriched.name];
|
|
281
524
|
if (!mapper) {
|
|
282
|
-
throw new Error(`Unhandled telemetry event: ${
|
|
525
|
+
throw new Error(`Unhandled telemetry event: ${enriched.name}`);
|
|
283
526
|
}
|
|
284
|
-
return mapper(
|
|
285
|
-
courseId:
|
|
286
|
-
timestamp:
|
|
527
|
+
return mapper(enriched, {
|
|
528
|
+
courseId: enriched.courseId,
|
|
529
|
+
timestamp: enriched.timestamp
|
|
287
530
|
});
|
|
288
531
|
}
|
|
289
532
|
|
|
@@ -301,26 +544,86 @@ function isDevEnvironment() {
|
|
|
301
544
|
const g = globalThis;
|
|
302
545
|
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
303
546
|
}
|
|
547
|
+
function defaultQueueCapHandler() {
|
|
548
|
+
if (isDevEnvironment()) {
|
|
549
|
+
console.warn("[lessonkit] xAPI queue reached capacity; oldest statement(s) dropped.");
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
function defaultHeadSkippedHandler(_statement, err) {
|
|
553
|
+
if (isDevEnvironment()) {
|
|
554
|
+
console.warn(
|
|
555
|
+
"[lessonkit] xAPI queue skipped statement after repeated transport failures:",
|
|
556
|
+
err instanceof Error ? err.message : err
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
304
560
|
function createXAPIClient(opts) {
|
|
305
561
|
const transport = opts?.transport;
|
|
306
562
|
const exitTransport = opts?.exitTransport;
|
|
307
563
|
const courseId = opts?.courseId;
|
|
308
564
|
const queue = opts?.queue ?? createInMemoryXAPIQueue({
|
|
309
565
|
maxSize: opts?.maxQueueSize,
|
|
566
|
+
maxHeadFailures: opts?.maxHeadFailures,
|
|
310
567
|
onDepth: opts?.onQueueDepth,
|
|
311
|
-
onCap: opts?.onQueueCap
|
|
568
|
+
onCap: opts?.onQueueCap ?? defaultQueueCapHandler,
|
|
569
|
+
onOverflow: (statement) => {
|
|
570
|
+
persistDeadLetterStatement(statement);
|
|
571
|
+
},
|
|
572
|
+
onHeadSkipped: (statement, err) => {
|
|
573
|
+
persistDeadLetterStatement(statement);
|
|
574
|
+
(opts?.onHeadSkipped ?? defaultHeadSkippedHandler)(statement, err);
|
|
575
|
+
}
|
|
312
576
|
});
|
|
313
577
|
let warnedNoTransport = false;
|
|
314
578
|
let warnedTransportFailure = false;
|
|
315
579
|
const inflightById = /* @__PURE__ */ new Map();
|
|
316
580
|
const inflightStatements = /* @__PURE__ */ new Map();
|
|
581
|
+
const pendingReplacement = /* @__PURE__ */ new Map();
|
|
582
|
+
const inflightPayload = /* @__PURE__ */ new Map();
|
|
583
|
+
const replacementWatcher = /* @__PURE__ */ new Set();
|
|
317
584
|
const exitDeliveredIds = /* @__PURE__ */ new Set();
|
|
318
585
|
const exitNetworkSentIds = /* @__PURE__ */ new Set();
|
|
586
|
+
const exitHandoffIds = /* @__PURE__ */ new Set();
|
|
587
|
+
let activeFlush = null;
|
|
588
|
+
for (const statement of loadDeadLetterStatements()) {
|
|
589
|
+
queue.enqueue(statement);
|
|
590
|
+
}
|
|
591
|
+
const hadDeadLetters = queue.size() > 0;
|
|
319
592
|
const deliveryTransport = transport ? async (statement) => {
|
|
320
593
|
if (exitNetworkSentIds.has(statement.id)) return;
|
|
321
594
|
await transport(statement);
|
|
595
|
+
removeDeadLetterStatement(statement.id);
|
|
322
596
|
} : void 0;
|
|
323
|
-
const
|
|
597
|
+
const markExitDelivered = (statement) => {
|
|
598
|
+
exitHandoffIds.delete(statement.id);
|
|
599
|
+
exitDeliveredIds.add(statement.id);
|
|
600
|
+
exitNetworkSentIds.add(statement.id);
|
|
601
|
+
removeDeadLetterStatement(statement.id);
|
|
602
|
+
};
|
|
603
|
+
const dispatchExitStatement = (statement) => {
|
|
604
|
+
if (exitDeliveredIds.has(statement.id)) return;
|
|
605
|
+
exitHandoffIds.add(statement.id);
|
|
606
|
+
try {
|
|
607
|
+
const result = exitTransport(statement);
|
|
608
|
+
if (result != null && typeof result.then === "function") {
|
|
609
|
+
void result.then(
|
|
610
|
+
() => markExitDelivered(statement),
|
|
611
|
+
() => {
|
|
612
|
+
exitHandoffIds.delete(statement.id);
|
|
613
|
+
persistDeadLetterStatement(statement);
|
|
614
|
+
}
|
|
615
|
+
);
|
|
616
|
+
} else {
|
|
617
|
+
markExitDelivered(statement);
|
|
618
|
+
}
|
|
619
|
+
} catch {
|
|
620
|
+
exitHandoffIds.delete(statement.id);
|
|
621
|
+
persistDeadLetterStatement(statement);
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
const pendingDuringFlush = [];
|
|
625
|
+
let flushInProgress = false;
|
|
626
|
+
const sendOrQueueInternal = (statement) => {
|
|
324
627
|
const normalized = withStatementId2(statement);
|
|
325
628
|
if (exitDeliveredIds.has(normalized.id)) return;
|
|
326
629
|
if (!deliveryTransport) {
|
|
@@ -335,20 +638,38 @@ function createXAPIClient(opts) {
|
|
|
335
638
|
}
|
|
336
639
|
const existing = inflightById.get(normalized.id);
|
|
337
640
|
if (existing) {
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
641
|
+
pendingReplacement.set(normalized.id, normalized);
|
|
642
|
+
inflightStatements.set(normalized.id, normalized);
|
|
643
|
+
if (!replacementWatcher.has(normalized.id)) {
|
|
644
|
+
replacementWatcher.add(normalized.id);
|
|
645
|
+
void existing.then(
|
|
646
|
+
() => {
|
|
647
|
+
replacementWatcher.delete(normalized.id);
|
|
648
|
+
const replacement = pendingReplacement.get(normalized.id);
|
|
649
|
+
const transported = inflightPayload.get(normalized.id);
|
|
650
|
+
pendingReplacement.delete(normalized.id);
|
|
651
|
+
inflightPayload.delete(normalized.id);
|
|
652
|
+
if (replacement && replacement !== transported) {
|
|
653
|
+
sendOrQueueInternal(replacement);
|
|
654
|
+
}
|
|
655
|
+
},
|
|
656
|
+
() => {
|
|
657
|
+
replacementWatcher.delete(normalized.id);
|
|
658
|
+
const replacement = pendingReplacement.get(normalized.id) ?? normalized;
|
|
659
|
+
pendingReplacement.delete(normalized.id);
|
|
660
|
+
sendOrQueueInternal(replacement);
|
|
661
|
+
}
|
|
662
|
+
);
|
|
663
|
+
}
|
|
344
664
|
return;
|
|
345
665
|
}
|
|
346
666
|
inflightStatements.set(normalized.id, normalized);
|
|
667
|
+
inflightPayload.set(normalized.id, normalized);
|
|
347
668
|
const flight = Promise.resolve().then(async () => {
|
|
348
669
|
await deliveryTransport(normalized);
|
|
349
670
|
queue.removeById(normalized.id);
|
|
350
671
|
}).catch((err) => {
|
|
351
|
-
if (exitDeliveredIds.has(normalized.id)) return;
|
|
672
|
+
if (exitDeliveredIds.has(normalized.id) || exitHandoffIds.has(normalized.id)) return;
|
|
352
673
|
queue.enqueue(normalized);
|
|
353
674
|
opts?.onTransportError?.(err);
|
|
354
675
|
if (isDevEnvironment() && !warnedTransportFailure) {
|
|
@@ -361,17 +682,28 @@ function createXAPIClient(opts) {
|
|
|
361
682
|
}).finally(() => {
|
|
362
683
|
inflightById.delete(normalized.id);
|
|
363
684
|
inflightStatements.delete(normalized.id);
|
|
685
|
+
if (!replacementWatcher.has(normalized.id)) {
|
|
686
|
+
inflightPayload.delete(normalized.id);
|
|
687
|
+
}
|
|
364
688
|
});
|
|
365
689
|
inflightById.set(normalized.id, flight);
|
|
366
690
|
void flight.catch(() => {
|
|
367
691
|
});
|
|
368
692
|
};
|
|
693
|
+
const sendOrQueue = (statement) => {
|
|
694
|
+
if (flushInProgress) {
|
|
695
|
+
pendingDuringFlush.push(statement);
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
sendOrQueueInternal(statement);
|
|
699
|
+
};
|
|
369
700
|
const emit = (event) => {
|
|
370
701
|
try {
|
|
371
702
|
const statement = telemetryEventToXAPIStatement(event);
|
|
372
703
|
if (!statement) return;
|
|
373
704
|
sendOrQueue(statement);
|
|
374
705
|
} catch (err) {
|
|
706
|
+
opts?.onMappingError?.(err);
|
|
375
707
|
if (isDevEnvironment()) {
|
|
376
708
|
console.warn(
|
|
377
709
|
"[lessonkit] xAPI mapping skipped:",
|
|
@@ -380,42 +712,66 @@ function createXAPIClient(opts) {
|
|
|
380
712
|
}
|
|
381
713
|
}
|
|
382
714
|
};
|
|
383
|
-
|
|
715
|
+
const runFlushLoop = async () => {
|
|
716
|
+
if (!deliveryTransport) return;
|
|
717
|
+
for (; ; ) {
|
|
718
|
+
await queue.flush(deliveryTransport);
|
|
719
|
+
const flights = [...inflightById.values()];
|
|
720
|
+
if (flights.length > 0) {
|
|
721
|
+
await Promise.all(flights);
|
|
722
|
+
}
|
|
723
|
+
if (queue.size() === 0 && inflightById.size === 0) break;
|
|
724
|
+
}
|
|
725
|
+
};
|
|
726
|
+
const client = {
|
|
384
727
|
send: (statement) => {
|
|
385
728
|
sendOrQueue(statement);
|
|
386
729
|
},
|
|
387
730
|
queueSize: () => queue.size(),
|
|
388
731
|
flush: async () => {
|
|
389
732
|
if (!deliveryTransport) return;
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
733
|
+
for (; ; ) {
|
|
734
|
+
if (activeFlush) {
|
|
735
|
+
await activeFlush;
|
|
736
|
+
} else {
|
|
737
|
+
flushInProgress = true;
|
|
738
|
+
activeFlush = (async () => {
|
|
739
|
+
try {
|
|
740
|
+
await runFlushLoop();
|
|
741
|
+
while (pendingDuringFlush.length > 0) {
|
|
742
|
+
const batch = pendingDuringFlush.splice(0, pendingDuringFlush.length);
|
|
743
|
+
for (const pending of batch) {
|
|
744
|
+
sendOrQueueInternal(pending);
|
|
745
|
+
}
|
|
746
|
+
await runFlushLoop();
|
|
747
|
+
}
|
|
748
|
+
} finally {
|
|
749
|
+
flushInProgress = false;
|
|
750
|
+
}
|
|
751
|
+
})().finally(() => {
|
|
752
|
+
activeFlush = null;
|
|
753
|
+
});
|
|
754
|
+
await activeFlush;
|
|
755
|
+
}
|
|
756
|
+
if (queue.size() === 0 && inflightById.size === 0) break;
|
|
397
757
|
}
|
|
398
758
|
},
|
|
399
759
|
flushOnExit: exitTransport ? () => {
|
|
400
760
|
const headId = queue.getHeadInFlightId?.();
|
|
401
761
|
if (headId) {
|
|
402
|
-
exitNetworkSentIds.add(headId);
|
|
403
|
-
exitDeliveredIds.add(headId);
|
|
404
762
|
opts.abortInFlight?.(headId);
|
|
763
|
+
const headStatement = inflightStatements.get(headId);
|
|
764
|
+
if (headStatement) {
|
|
765
|
+
dispatchExitStatement(headStatement);
|
|
766
|
+
}
|
|
405
767
|
}
|
|
406
768
|
for (const statement of inflightStatements.values()) {
|
|
407
|
-
|
|
408
|
-
exitDeliveredIds.add(statement.id);
|
|
769
|
+
if (statement.id === headId) continue;
|
|
409
770
|
opts.abortInFlight?.(statement.id);
|
|
771
|
+
dispatchExitStatement(statement);
|
|
410
772
|
}
|
|
411
773
|
queue.flushOnExit((statement) => {
|
|
412
|
-
|
|
413
|
-
exitNetworkSentIds.add(statement.id);
|
|
414
|
-
exitDeliveredIds.add(statement.id);
|
|
415
|
-
try {
|
|
416
|
-
exitTransport(statement);
|
|
417
|
-
} catch {
|
|
418
|
-
}
|
|
774
|
+
dispatchExitStatement(statement);
|
|
419
775
|
});
|
|
420
776
|
} : void 0,
|
|
421
777
|
startedLesson: ({ lessonId }) => {
|
|
@@ -453,6 +809,97 @@ function createXAPIClient(opts) {
|
|
|
453
809
|
});
|
|
454
810
|
}
|
|
455
811
|
};
|
|
812
|
+
if (hadDeadLetters && deliveryTransport) {
|
|
813
|
+
queueMicrotask(() => {
|
|
814
|
+
void client.flush().catch(() => void 0);
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
return client;
|
|
818
|
+
}
|
|
819
|
+
function resetXAPIDeadLetterForTests() {
|
|
820
|
+
clearDeadLetterStorage();
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// src/safeLrsUrl.ts
|
|
824
|
+
function isProductionRuntime() {
|
|
825
|
+
try {
|
|
826
|
+
if (import.meta.env?.PROD === true) return true;
|
|
827
|
+
} catch {
|
|
828
|
+
}
|
|
829
|
+
const g = globalThis;
|
|
830
|
+
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production";
|
|
831
|
+
}
|
|
832
|
+
function parseHostname(url) {
|
|
833
|
+
return url.hostname.replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
|
|
834
|
+
}
|
|
835
|
+
function isIpv4MappedAddress(hostname) {
|
|
836
|
+
const match = hostname.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
|
837
|
+
return match?.[1] ?? null;
|
|
838
|
+
}
|
|
839
|
+
function isLoopbackHost(hostname) {
|
|
840
|
+
const ipv4Mapped = isIpv4MappedAddress(hostname);
|
|
841
|
+
if (ipv4Mapped) return isLoopbackHost(ipv4Mapped);
|
|
842
|
+
return hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "127.0.0.1" || hostname === "::1" || hostname === "0.0.0.0";
|
|
843
|
+
}
|
|
844
|
+
function isLinkLocalOrMetadataHost(hostname) {
|
|
845
|
+
if (hostname === "169.254.169.254") return true;
|
|
846
|
+
if (/^169\.254\./.test(hostname)) return true;
|
|
847
|
+
if (/^fe80:/i.test(hostname)) return true;
|
|
848
|
+
return false;
|
|
849
|
+
}
|
|
850
|
+
function isRfc1918Host(hostname) {
|
|
851
|
+
const ipv4Mapped = isIpv4MappedAddress(hostname);
|
|
852
|
+
if (ipv4Mapped) return isRfc1918Host(ipv4Mapped);
|
|
853
|
+
if (/^10\./.test(hostname)) return true;
|
|
854
|
+
if (/^192\.168\./.test(hostname)) return true;
|
|
855
|
+
const parts = hostname.split(".").map(Number);
|
|
856
|
+
if (parts.length === 4 && parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
|
|
857
|
+
return false;
|
|
858
|
+
}
|
|
859
|
+
function isPrivateOrMetadataHost(hostname) {
|
|
860
|
+
return isLoopbackHost(hostname) || isLinkLocalOrMetadataHost(hostname) || isRfc1918Host(hostname);
|
|
861
|
+
}
|
|
862
|
+
function containsPathTraversal(path) {
|
|
863
|
+
if (path.includes("..")) return true;
|
|
864
|
+
let decoded = path;
|
|
865
|
+
for (let i = 0; i < 2; i++) {
|
|
866
|
+
try {
|
|
867
|
+
const next = decodeURIComponent(decoded.replace(/\+/g, " "));
|
|
868
|
+
if (next.includes("..")) return true;
|
|
869
|
+
if (next === decoded) break;
|
|
870
|
+
decoded = next;
|
|
871
|
+
} catch {
|
|
872
|
+
break;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
return false;
|
|
876
|
+
}
|
|
877
|
+
function assertSafeLrsUrl(url, opts) {
|
|
878
|
+
if (url.startsWith("/")) {
|
|
879
|
+
if (containsPathTraversal(url)) {
|
|
880
|
+
throw new Error(`Unsafe LRS URL: path traversal in "${url}"`);
|
|
881
|
+
}
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
let parsed;
|
|
885
|
+
try {
|
|
886
|
+
parsed = new URL(url);
|
|
887
|
+
} catch {
|
|
888
|
+
throw new Error(`Unsafe LRS URL: invalid URL "${url}"`);
|
|
889
|
+
}
|
|
890
|
+
if (containsPathTraversal(parsed.pathname)) {
|
|
891
|
+
throw new Error(`Unsafe LRS URL: path traversal in "${url}"`);
|
|
892
|
+
}
|
|
893
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
894
|
+
throw new Error(`Unsafe LRS URL: unsupported scheme "${parsed.protocol}"`);
|
|
895
|
+
}
|
|
896
|
+
if (isProductionRuntime() && parsed.protocol !== "https:") {
|
|
897
|
+
throw new Error("Unsafe LRS URL: HTTPS is required in production builds");
|
|
898
|
+
}
|
|
899
|
+
const hostname = parseHostname(parsed);
|
|
900
|
+
if (!opts?.allowPrivateHosts && isPrivateOrMetadataHost(hostname)) {
|
|
901
|
+
throw new Error(`Unsafe LRS URL: private or metadata host "${hostname}" is not allowed`);
|
|
902
|
+
}
|
|
456
903
|
}
|
|
457
904
|
|
|
458
905
|
// src/fetchTransport.ts
|
|
@@ -522,6 +969,7 @@ async function postWithRetry(post, retries, initialBackoffMs, maxBackoffMs) {
|
|
|
522
969
|
}
|
|
523
970
|
}
|
|
524
971
|
function createFetchTransport(opts) {
|
|
972
|
+
assertSafeLrsUrl(opts.url, { allowPrivateHosts: opts.allowPrivateHosts });
|
|
525
973
|
const timeoutMs = opts.timeoutMs ?? 3e4;
|
|
526
974
|
const rawRetries = opts.retries ?? 2;
|
|
527
975
|
const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
|
|
@@ -553,14 +1001,13 @@ function createFetchTransport(opts) {
|
|
|
553
1001
|
}
|
|
554
1002
|
};
|
|
555
1003
|
const exitTransport = (statement) => {
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
}
|
|
563
|
-
}
|
|
1004
|
+
return postStatement(opts.url, statement, {
|
|
1005
|
+
...opts.init,
|
|
1006
|
+
headers: resolveHeaders(opts.headers),
|
|
1007
|
+
keepalive: true
|
|
1008
|
+
}).catch(() => {
|
|
1009
|
+
throw new Error("xAPI keepalive delivery failed");
|
|
1010
|
+
});
|
|
564
1011
|
};
|
|
565
1012
|
const abortInFlight = (statementId) => {
|
|
566
1013
|
activeControllers.get(statementId)?.abort();
|
|
@@ -569,6 +1016,7 @@ function createFetchTransport(opts) {
|
|
|
569
1016
|
return { transport, exitTransport, abortInFlight };
|
|
570
1017
|
}
|
|
571
1018
|
function createFetchBatchSink(opts) {
|
|
1019
|
+
assertSafeLrsUrl(opts.url, { allowPrivateHosts: opts.allowPrivateHosts });
|
|
572
1020
|
const timeoutMs = opts.timeoutMs ?? 3e4;
|
|
573
1021
|
const rawRetries = opts.retries ?? 2;
|
|
574
1022
|
const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
|
|
@@ -592,26 +1040,31 @@ function createFetchBatchSink(opts) {
|
|
|
592
1040
|
return {
|
|
593
1041
|
batchSink: (events) => postBatch(events, opts.init ?? {}),
|
|
594
1042
|
exitBatchSink: (events) => {
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
1043
|
+
return fetch(opts.url, {
|
|
1044
|
+
method: "POST",
|
|
1045
|
+
body: JSON.stringify(events),
|
|
1046
|
+
...opts.init,
|
|
1047
|
+
headers: resolveHeaders(opts.headers),
|
|
1048
|
+
keepalive: true
|
|
1049
|
+
}).then((res) => {
|
|
1050
|
+
if (!res.ok) {
|
|
1051
|
+
throw new FetchHttpError(res.status, res.statusText, "batch");
|
|
1052
|
+
}
|
|
1053
|
+
});
|
|
605
1054
|
}
|
|
606
1055
|
};
|
|
607
1056
|
}
|
|
608
1057
|
export {
|
|
609
1058
|
FetchHttpError,
|
|
1059
|
+
assertSafeLrsUrl,
|
|
610
1060
|
createFetchBatchSink,
|
|
611
1061
|
createFetchTransport,
|
|
612
1062
|
createInMemoryXAPIQueue,
|
|
613
1063
|
createXAPIClient,
|
|
614
1064
|
isRetryableFetchError,
|
|
615
1065
|
isRetryableFetchHttpStatus,
|
|
1066
|
+
loadDeadLetterStatements,
|
|
1067
|
+
persistDeadLetterStatement,
|
|
1068
|
+
resetXAPIDeadLetterForTests,
|
|
616
1069
|
telemetryEventToXAPIStatement
|
|
617
1070
|
};
|