@lessonkit/xapi 1.3.1 → 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 +684 -130
- package/dist/index.d.cts +47 -2
- package/dist/index.d.ts +47 -2
- package/dist/index.js +676 -130
- 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
|
|
@@ -16,11 +124,15 @@ function withStatementId(statement) {
|
|
|
16
124
|
return statement;
|
|
17
125
|
}
|
|
18
126
|
var DEFAULT_MAX_QUEUE_SIZE = 1e3;
|
|
127
|
+
var DEFAULT_MAX_HEAD_FAILURES = 10;
|
|
19
128
|
function createInMemoryXAPIQueue(opts) {
|
|
20
129
|
const maxSize = opts?.maxSize ?? DEFAULT_MAX_QUEUE_SIZE;
|
|
130
|
+
const maxHeadFailures = opts?.maxHeadFailures ?? DEFAULT_MAX_HEAD_FAILURES;
|
|
21
131
|
const buffer = [];
|
|
22
132
|
let flushInFlight = null;
|
|
23
133
|
let headInFlight = false;
|
|
134
|
+
let headInFlightId;
|
|
135
|
+
let headFailureCount = 0;
|
|
24
136
|
const notifyDepth = () => {
|
|
25
137
|
opts?.onDepth?.(buffer.length);
|
|
26
138
|
};
|
|
@@ -35,32 +147,51 @@ function createInMemoryXAPIQueue(opts) {
|
|
|
35
147
|
while (buffer.length) {
|
|
36
148
|
const statement = buffer[0];
|
|
37
149
|
headInFlight = true;
|
|
150
|
+
headInFlightId = statement.id;
|
|
38
151
|
try {
|
|
39
152
|
await transport(statement);
|
|
40
153
|
buffer.shift();
|
|
154
|
+
headFailureCount = 0;
|
|
41
155
|
notifyDepth();
|
|
42
|
-
} catch {
|
|
43
|
-
|
|
156
|
+
} catch (err) {
|
|
157
|
+
headFailureCount += 1;
|
|
158
|
+
if (headFailureCount >= maxHeadFailures) {
|
|
159
|
+
buffer.shift();
|
|
160
|
+
headFailureCount = 0;
|
|
161
|
+
notifyDepth();
|
|
162
|
+
opts?.onHeadSkipped?.(statement, err);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
throw err;
|
|
44
166
|
} finally {
|
|
45
167
|
headInFlight = false;
|
|
168
|
+
headInFlightId = void 0;
|
|
46
169
|
}
|
|
47
170
|
}
|
|
48
171
|
};
|
|
49
172
|
return {
|
|
50
173
|
enqueue: (statement) => {
|
|
51
174
|
const normalized = withStatementId(statement);
|
|
52
|
-
|
|
175
|
+
const existingIdx = buffer.findIndex((s) => s.id === normalized.id);
|
|
176
|
+
if (existingIdx >= 0) {
|
|
177
|
+
buffer[existingIdx] = normalized;
|
|
178
|
+
notifyDepth();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
53
181
|
if (buffer.length >= maxSize) {
|
|
54
|
-
if (headInFlight && buffer.length <= 1) {
|
|
55
|
-
opts?.onCap?.();
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
182
|
if (headInFlight) {
|
|
59
|
-
buffer.
|
|
183
|
+
if (buffer.length > 1) {
|
|
184
|
+
buffer.splice(1, 1);
|
|
185
|
+
opts?.onCap?.();
|
|
186
|
+
} else {
|
|
187
|
+
opts?.onCap?.();
|
|
188
|
+
opts?.onOverflow?.(normalized);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
60
191
|
} else {
|
|
61
192
|
buffer.shift();
|
|
193
|
+
opts?.onCap?.();
|
|
62
194
|
}
|
|
63
|
-
opts?.onCap?.();
|
|
64
195
|
}
|
|
65
196
|
buffer.push(normalized);
|
|
66
197
|
notifyDepth();
|
|
@@ -76,27 +207,76 @@ function createInMemoryXAPIQueue(opts) {
|
|
|
76
207
|
return flushInFlight;
|
|
77
208
|
},
|
|
78
209
|
flushOnExit: (exitTransport) => {
|
|
79
|
-
const
|
|
80
|
-
for (let i = startIdx; i < buffer.length; i++) {
|
|
81
|
-
const statement = buffer[i];
|
|
210
|
+
for (const statement of buffer) {
|
|
82
211
|
try {
|
|
83
212
|
exitTransport(statement);
|
|
84
213
|
} catch {
|
|
85
214
|
}
|
|
86
215
|
}
|
|
87
|
-
|
|
88
|
-
buffer.length = 0;
|
|
89
|
-
} else if (buffer.length > 1) {
|
|
90
|
-
buffer.splice(1);
|
|
91
|
-
}
|
|
216
|
+
buffer.length = 0;
|
|
92
217
|
notifyDepth();
|
|
93
|
-
}
|
|
218
|
+
},
|
|
219
|
+
getHeadInFlightId: () => headInFlightId
|
|
94
220
|
};
|
|
95
221
|
}
|
|
96
222
|
|
|
97
223
|
// src/client.ts
|
|
98
224
|
import { nowIso } from "@lessonkit/core";
|
|
99
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
|
+
|
|
100
280
|
// src/telemetryMap.ts
|
|
101
281
|
import { buildLessonkitUrn } from "@lessonkit/core";
|
|
102
282
|
|
|
@@ -133,9 +313,9 @@ function buildXapiScoreResult(opts) {
|
|
|
133
313
|
}
|
|
134
314
|
return result;
|
|
135
315
|
}
|
|
136
|
-
function statementFor(objectId, verb, timestamp, extra) {
|
|
316
|
+
function statementFor(event, objectId, verb, timestamp, extra) {
|
|
137
317
|
return {
|
|
138
|
-
id:
|
|
318
|
+
id: deriveStatementId(event, objectId, verb),
|
|
139
319
|
timestamp,
|
|
140
320
|
verb,
|
|
141
321
|
object: { id: objectId },
|
|
@@ -143,8 +323,21 @@ function statementFor(objectId, verb, timestamp, extra) {
|
|
|
143
323
|
context: extra?.context
|
|
144
324
|
};
|
|
145
325
|
}
|
|
146
|
-
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) {
|
|
147
339
|
return statementFor(
|
|
340
|
+
event,
|
|
148
341
|
buildLessonkitUrn({ courseId, lessonId, blockId }),
|
|
149
342
|
XAPIVerbs.experienced,
|
|
150
343
|
timestamp
|
|
@@ -155,20 +348,39 @@ var experiencedBlockMapper = (event, ctx) => {
|
|
|
155
348
|
const lessonId2 = event.lessonId;
|
|
156
349
|
const blockId2 = event.data?.blockId;
|
|
157
350
|
if (!lessonId2 || !blockId2 || typeof blockId2 !== "string") return null;
|
|
158
|
-
|
|
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
|
+
);
|
|
159
370
|
}
|
|
160
371
|
const lessonId = event.lessonId;
|
|
161
372
|
const blockId = "data" in event && event.data && "blockId" in event.data ? event.data.blockId : void 0;
|
|
162
373
|
if (!lessonId || !blockId || typeof blockId !== "string") return null;
|
|
163
|
-
return experiencedBlockStatement(ctx.courseId, lessonId, blockId, ctx.timestamp);
|
|
374
|
+
return experiencedBlockStatement(event, ctx.courseId, lessonId, blockId, ctx.timestamp);
|
|
164
375
|
};
|
|
165
376
|
var TELEMETRY_XAPI_MAPPERS = {
|
|
166
|
-
course_started: (
|
|
167
|
-
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),
|
|
168
379
|
lesson_started: (event, ctx) => {
|
|
169
380
|
const lessonId = event.name === "lesson_started" ? event.lessonId : void 0;
|
|
170
381
|
if (!lessonId) return null;
|
|
171
382
|
return statementFor(
|
|
383
|
+
event,
|
|
172
384
|
buildLessonkitUrn({ courseId: ctx.courseId, lessonId }),
|
|
173
385
|
XAPIVerbs.initialized,
|
|
174
386
|
ctx.timestamp
|
|
@@ -186,9 +398,15 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
186
398
|
if (typeof data?.success === "boolean") result.success = data.success;
|
|
187
399
|
const score = buildXapiScoreResult({ score: data?.score, maxScore: data?.maxScore });
|
|
188
400
|
if (score) result.score = score;
|
|
189
|
-
return statementFor(
|
|
190
|
-
|
|
191
|
-
|
|
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
|
+
);
|
|
192
410
|
},
|
|
193
411
|
lesson_time_on_task: () => null,
|
|
194
412
|
quiz_answered: (event, ctx) => {
|
|
@@ -196,6 +414,7 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
196
414
|
const result = {};
|
|
197
415
|
if (typeof event.data.correct === "boolean") result.success = event.data.correct;
|
|
198
416
|
return statementFor(
|
|
417
|
+
event,
|
|
199
418
|
buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
200
419
|
XAPIVerbs.answered,
|
|
201
420
|
ctx.timestamp,
|
|
@@ -206,6 +425,7 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
206
425
|
if (event.name !== "quiz_completed") return null;
|
|
207
426
|
const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
|
|
208
427
|
return statementFor(
|
|
428
|
+
event,
|
|
209
429
|
buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
210
430
|
XAPIVerbs.completed,
|
|
211
431
|
ctx.timestamp,
|
|
@@ -217,6 +437,7 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
217
437
|
const result = {};
|
|
218
438
|
if (typeof event.data.correct === "boolean") result.success = event.data.correct;
|
|
219
439
|
return statementFor(
|
|
440
|
+
event,
|
|
220
441
|
buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
221
442
|
XAPIVerbs.answered,
|
|
222
443
|
ctx.timestamp,
|
|
@@ -227,6 +448,7 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
227
448
|
if (event.name !== "assessment_completed") return null;
|
|
228
449
|
const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
|
|
229
450
|
return statementFor(
|
|
451
|
+
event,
|
|
230
452
|
buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
231
453
|
XAPIVerbs.completed,
|
|
232
454
|
ctx.timestamp,
|
|
@@ -240,16 +462,71 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
240
462
|
hotspot_opened: experiencedBlockMapper,
|
|
241
463
|
accordion_section_toggled: experiencedBlockMapper,
|
|
242
464
|
flashcard_flipped: experiencedBlockMapper,
|
|
243
|
-
image_slider_changed: experiencedBlockMapper
|
|
465
|
+
image_slider_changed: experiencedBlockMapper,
|
|
466
|
+
video_cue_reached: experiencedBlockMapper,
|
|
467
|
+
video_segment_completed: (event, ctx) => {
|
|
468
|
+
if (event.name !== "video_segment_completed") return null;
|
|
469
|
+
const lessonId = event.lessonId;
|
|
470
|
+
const blockId = event.data.blockId;
|
|
471
|
+
if (!lessonId || !blockId) return null;
|
|
472
|
+
return statementFor(
|
|
473
|
+
event,
|
|
474
|
+
buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId }),
|
|
475
|
+
XAPIVerbs.completed,
|
|
476
|
+
ctx.timestamp
|
|
477
|
+
);
|
|
478
|
+
},
|
|
479
|
+
memory_card_flipped: experiencedBlockMapper,
|
|
480
|
+
information_wall_search: experiencedBlockMapper,
|
|
481
|
+
parallax_slide_viewed: experiencedBlockMapper,
|
|
482
|
+
questionnaire_submitted: (event, ctx) => {
|
|
483
|
+
if (event.name !== "questionnaire_submitted") return null;
|
|
484
|
+
const lessonId = event.lessonId;
|
|
485
|
+
const blockId = event.data.blockId;
|
|
486
|
+
if (!lessonId || !blockId) return null;
|
|
487
|
+
return statementFor(
|
|
488
|
+
event,
|
|
489
|
+
buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId }),
|
|
490
|
+
XAPIVerbs.completed,
|
|
491
|
+
ctx.timestamp
|
|
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
|
+
);
|
|
519
|
+
}
|
|
244
520
|
};
|
|
245
521
|
function telemetryEventToXAPIStatement(event) {
|
|
246
|
-
const
|
|
522
|
+
const enriched = enrichTelemetryEventForXapi(event);
|
|
523
|
+
const mapper = TELEMETRY_XAPI_MAPPERS[enriched.name];
|
|
247
524
|
if (!mapper) {
|
|
248
|
-
throw new Error(`Unhandled telemetry event: ${
|
|
525
|
+
throw new Error(`Unhandled telemetry event: ${enriched.name}`);
|
|
249
526
|
}
|
|
250
|
-
return mapper(
|
|
251
|
-
courseId:
|
|
252
|
-
timestamp:
|
|
527
|
+
return mapper(enriched, {
|
|
528
|
+
courseId: enriched.courseId,
|
|
529
|
+
timestamp: enriched.timestamp
|
|
253
530
|
});
|
|
254
531
|
}
|
|
255
532
|
|
|
@@ -267,22 +544,89 @@ function isDevEnvironment() {
|
|
|
267
544
|
const g = globalThis;
|
|
268
545
|
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
269
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
|
+
}
|
|
270
560
|
function createXAPIClient(opts) {
|
|
271
561
|
const transport = opts?.transport;
|
|
272
562
|
const exitTransport = opts?.exitTransport;
|
|
273
563
|
const courseId = opts?.courseId;
|
|
274
564
|
const queue = opts?.queue ?? createInMemoryXAPIQueue({
|
|
275
565
|
maxSize: opts?.maxQueueSize,
|
|
566
|
+
maxHeadFailures: opts?.maxHeadFailures,
|
|
276
567
|
onDepth: opts?.onQueueDepth,
|
|
277
|
-
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
|
+
}
|
|
278
576
|
});
|
|
279
577
|
let warnedNoTransport = false;
|
|
280
578
|
let warnedTransportFailure = false;
|
|
281
579
|
const inflightById = /* @__PURE__ */ new Map();
|
|
282
580
|
const inflightStatements = /* @__PURE__ */ new Map();
|
|
283
|
-
const
|
|
581
|
+
const pendingReplacement = /* @__PURE__ */ new Map();
|
|
582
|
+
const inflightPayload = /* @__PURE__ */ new Map();
|
|
583
|
+
const replacementWatcher = /* @__PURE__ */ new Set();
|
|
584
|
+
const exitDeliveredIds = /* @__PURE__ */ new Set();
|
|
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;
|
|
592
|
+
const deliveryTransport = transport ? async (statement) => {
|
|
593
|
+
if (exitNetworkSentIds.has(statement.id)) return;
|
|
594
|
+
await transport(statement);
|
|
595
|
+
removeDeadLetterStatement(statement.id);
|
|
596
|
+
} : void 0;
|
|
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) => {
|
|
284
627
|
const normalized = withStatementId2(statement);
|
|
285
|
-
if (
|
|
628
|
+
if (exitDeliveredIds.has(normalized.id)) return;
|
|
629
|
+
if (!deliveryTransport) {
|
|
286
630
|
queue.enqueue(normalized);
|
|
287
631
|
if (isDevEnvironment() && !warnedNoTransport) {
|
|
288
632
|
warnedNoTransport = true;
|
|
@@ -294,41 +638,72 @@ function createXAPIClient(opts) {
|
|
|
294
638
|
}
|
|
295
639
|
const existing = inflightById.get(normalized.id);
|
|
296
640
|
if (existing) {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
+
}
|
|
303
664
|
return;
|
|
304
665
|
}
|
|
305
666
|
inflightStatements.set(normalized.id, normalized);
|
|
667
|
+
inflightPayload.set(normalized.id, normalized);
|
|
306
668
|
const flight = Promise.resolve().then(async () => {
|
|
307
|
-
await
|
|
669
|
+
await deliveryTransport(normalized);
|
|
308
670
|
queue.removeById(normalized.id);
|
|
309
|
-
}).catch(() => {
|
|
671
|
+
}).catch((err) => {
|
|
672
|
+
if (exitDeliveredIds.has(normalized.id) || exitHandoffIds.has(normalized.id)) return;
|
|
310
673
|
queue.enqueue(normalized);
|
|
674
|
+
opts?.onTransportError?.(err);
|
|
311
675
|
if (isDevEnvironment() && !warnedTransportFailure) {
|
|
312
676
|
warnedTransportFailure = true;
|
|
313
677
|
console.warn(
|
|
314
678
|
"[lessonkit] xAPI transport failed; statement re-queued. Check your LRS endpoint or transport implementation."
|
|
315
679
|
);
|
|
316
680
|
}
|
|
317
|
-
throw new Error("xAPI transport failed");
|
|
681
|
+
throw err instanceof Error ? err : new Error("xAPI transport failed", { cause: err });
|
|
318
682
|
}).finally(() => {
|
|
319
683
|
inflightById.delete(normalized.id);
|
|
320
684
|
inflightStatements.delete(normalized.id);
|
|
685
|
+
if (!replacementWatcher.has(normalized.id)) {
|
|
686
|
+
inflightPayload.delete(normalized.id);
|
|
687
|
+
}
|
|
321
688
|
});
|
|
322
689
|
inflightById.set(normalized.id, flight);
|
|
323
690
|
void flight.catch(() => {
|
|
324
691
|
});
|
|
325
692
|
};
|
|
693
|
+
const sendOrQueue = (statement) => {
|
|
694
|
+
if (flushInProgress) {
|
|
695
|
+
pendingDuringFlush.push(statement);
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
sendOrQueueInternal(statement);
|
|
699
|
+
};
|
|
326
700
|
const emit = (event) => {
|
|
327
701
|
try {
|
|
328
702
|
const statement = telemetryEventToXAPIStatement(event);
|
|
329
703
|
if (!statement) return;
|
|
330
704
|
sendOrQueue(statement);
|
|
331
705
|
} catch (err) {
|
|
706
|
+
opts?.onMappingError?.(err);
|
|
332
707
|
if (isDevEnvironment()) {
|
|
333
708
|
console.warn(
|
|
334
709
|
"[lessonkit] xAPI mapping skipped:",
|
|
@@ -337,33 +712,66 @@ function createXAPIClient(opts) {
|
|
|
337
712
|
}
|
|
338
713
|
}
|
|
339
714
|
};
|
|
340
|
-
|
|
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 = {
|
|
341
727
|
send: (statement) => {
|
|
342
728
|
sendOrQueue(statement);
|
|
343
729
|
},
|
|
344
730
|
queueSize: () => queue.size(),
|
|
345
731
|
flush: async () => {
|
|
346
|
-
if (!
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
732
|
+
if (!deliveryTransport) return;
|
|
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;
|
|
351
757
|
}
|
|
352
758
|
},
|
|
353
759
|
flushOnExit: exitTransport ? () => {
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
} catch {
|
|
760
|
+
const headId = queue.getHeadInFlightId?.();
|
|
761
|
+
if (headId) {
|
|
762
|
+
opts.abortInFlight?.(headId);
|
|
763
|
+
const headStatement = inflightStatements.get(headId);
|
|
764
|
+
if (headStatement) {
|
|
765
|
+
dispatchExitStatement(headStatement);
|
|
361
766
|
}
|
|
362
767
|
}
|
|
768
|
+
for (const statement of inflightStatements.values()) {
|
|
769
|
+
if (statement.id === headId) continue;
|
|
770
|
+
opts.abortInFlight?.(statement.id);
|
|
771
|
+
dispatchExitStatement(statement);
|
|
772
|
+
}
|
|
363
773
|
queue.flushOnExit((statement) => {
|
|
364
|
-
|
|
365
|
-
exitTransport(statement);
|
|
366
|
-
exitSentIds.add(statement.id);
|
|
774
|
+
dispatchExitStatement(statement);
|
|
367
775
|
});
|
|
368
776
|
} : void 0,
|
|
369
777
|
startedLesson: ({ lessonId }) => {
|
|
@@ -401,124 +809,262 @@ function createXAPIClient(opts) {
|
|
|
401
809
|
});
|
|
402
810
|
}
|
|
403
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
|
+
}
|
|
404
903
|
}
|
|
405
904
|
|
|
406
905
|
// src/fetchTransport.ts
|
|
906
|
+
var FetchHttpError = class extends Error {
|
|
907
|
+
status;
|
|
908
|
+
constructor(status, statusText, kind = "xapi") {
|
|
909
|
+
super(
|
|
910
|
+
kind === "xapi" ? `xAPI fetch failed: ${status} ${statusText}` : `telemetry batch fetch failed: ${status} ${statusText}`
|
|
911
|
+
);
|
|
912
|
+
this.name = "FetchHttpError";
|
|
913
|
+
this.status = status;
|
|
914
|
+
}
|
|
915
|
+
};
|
|
916
|
+
function isRetryableFetchHttpStatus(status) {
|
|
917
|
+
return status === 429 || status >= 500;
|
|
918
|
+
}
|
|
919
|
+
function isRetryableFetchError(err) {
|
|
920
|
+
if (err instanceof FetchHttpError) return isRetryableFetchHttpStatus(err.status);
|
|
921
|
+
return true;
|
|
922
|
+
}
|
|
407
923
|
function resolveHeaders(headers) {
|
|
408
924
|
if (!headers) return { "Content-Type": "application/json" };
|
|
409
925
|
const resolved = typeof headers === "function" ? headers() : headers;
|
|
410
926
|
return { "Content-Type": "application/json", ...resolved };
|
|
411
927
|
}
|
|
412
928
|
function createAbortSignal(timeoutMs) {
|
|
413
|
-
if (timeoutMs <= 0) return void 0
|
|
414
|
-
|
|
415
|
-
if (typeof timeout.timeout === "function") {
|
|
416
|
-
return timeout.timeout(timeoutMs);
|
|
417
|
-
}
|
|
929
|
+
if (timeoutMs <= 0) return { signal: void 0, abort: () => {
|
|
930
|
+
} };
|
|
418
931
|
const controller = new AbortController();
|
|
419
932
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
420
933
|
timer.unref?.();
|
|
421
|
-
return
|
|
934
|
+
return {
|
|
935
|
+
signal: controller.signal,
|
|
936
|
+
abort: () => {
|
|
937
|
+
clearTimeout(timer);
|
|
938
|
+
controller.abort();
|
|
939
|
+
}
|
|
940
|
+
};
|
|
422
941
|
}
|
|
423
942
|
function sleep(ms) {
|
|
424
943
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
425
944
|
}
|
|
426
945
|
function postStatement(url, statement, init) {
|
|
427
946
|
return fetch(url, {
|
|
947
|
+
...init,
|
|
428
948
|
method: "POST",
|
|
429
|
-
body: JSON.stringify(statement)
|
|
430
|
-
...init
|
|
949
|
+
body: JSON.stringify(statement)
|
|
431
950
|
}).then((res) => {
|
|
432
951
|
if (!res.ok) {
|
|
433
|
-
throw new
|
|
952
|
+
throw new FetchHttpError(res.status, res.statusText, "xapi");
|
|
434
953
|
}
|
|
435
954
|
});
|
|
436
955
|
}
|
|
956
|
+
async function postWithRetry(post, retries, initialBackoffMs, maxBackoffMs) {
|
|
957
|
+
let attempt = 0;
|
|
958
|
+
let backoff = initialBackoffMs;
|
|
959
|
+
for (; ; ) {
|
|
960
|
+
try {
|
|
961
|
+
await post();
|
|
962
|
+
return;
|
|
963
|
+
} catch (err) {
|
|
964
|
+
if (!isRetryableFetchError(err) || attempt >= retries) throw err;
|
|
965
|
+
await sleep(backoff);
|
|
966
|
+
backoff = Math.min(backoff * 2, maxBackoffMs);
|
|
967
|
+
attempt += 1;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
437
971
|
function createFetchTransport(opts) {
|
|
972
|
+
assertSafeLrsUrl(opts.url, { allowPrivateHosts: opts.allowPrivateHosts });
|
|
438
973
|
const timeoutMs = opts.timeoutMs ?? 3e4;
|
|
439
|
-
const
|
|
974
|
+
const rawRetries = opts.retries ?? 2;
|
|
975
|
+
const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
|
|
440
976
|
const initialBackoffMs = opts.backoffMs ?? 250;
|
|
441
977
|
const maxBackoffMs = opts.maxBackoffMs ?? 5e3;
|
|
978
|
+
const activeControllers = /* @__PURE__ */ new Map();
|
|
442
979
|
const transport = async (statement) => {
|
|
443
|
-
let
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
signal
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
980
|
+
let abortCleanup;
|
|
981
|
+
activeControllers.set(statement.id, {
|
|
982
|
+
abort: () => abortCleanup?.()
|
|
983
|
+
});
|
|
984
|
+
try {
|
|
985
|
+
await postWithRetry(
|
|
986
|
+
() => {
|
|
987
|
+
const { signal, abort } = createAbortSignal(timeoutMs);
|
|
988
|
+
abortCleanup = abort;
|
|
989
|
+
return postStatement(opts.url, statement, {
|
|
990
|
+
...opts.init,
|
|
991
|
+
headers: resolveHeaders(opts.headers),
|
|
992
|
+
signal
|
|
993
|
+
});
|
|
994
|
+
},
|
|
995
|
+
retries,
|
|
996
|
+
initialBackoffMs,
|
|
997
|
+
maxBackoffMs
|
|
998
|
+
);
|
|
999
|
+
} finally {
|
|
1000
|
+
activeControllers.delete(statement.id);
|
|
459
1001
|
}
|
|
460
1002
|
};
|
|
461
1003
|
const exitTransport = (statement) => {
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
}
|
|
469
|
-
}
|
|
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
|
+
});
|
|
470
1011
|
};
|
|
471
|
-
|
|
1012
|
+
const abortInFlight = (statementId) => {
|
|
1013
|
+
activeControllers.get(statementId)?.abort();
|
|
1014
|
+
activeControllers.delete(statementId);
|
|
1015
|
+
};
|
|
1016
|
+
return { transport, exitTransport, abortInFlight };
|
|
472
1017
|
}
|
|
473
1018
|
function createFetchBatchSink(opts) {
|
|
1019
|
+
assertSafeLrsUrl(opts.url, { allowPrivateHosts: opts.allowPrivateHosts });
|
|
474
1020
|
const timeoutMs = opts.timeoutMs ?? 3e4;
|
|
475
|
-
const
|
|
1021
|
+
const rawRetries = opts.retries ?? 2;
|
|
1022
|
+
const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
|
|
476
1023
|
const initialBackoffMs = opts.backoffMs ?? 250;
|
|
477
1024
|
const maxBackoffMs = opts.maxBackoffMs ?? 5e3;
|
|
478
1025
|
const postBatch = async (events, init) => {
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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;
|
|
1026
|
+
await postWithRetry(async () => {
|
|
1027
|
+
const { signal } = createAbortSignal(timeoutMs);
|
|
1028
|
+
const res = await fetch(opts.url, {
|
|
1029
|
+
...init,
|
|
1030
|
+
method: "POST",
|
|
1031
|
+
body: JSON.stringify(events),
|
|
1032
|
+
headers: resolveHeaders(opts.headers),
|
|
1033
|
+
signal
|
|
1034
|
+
});
|
|
1035
|
+
if (!res.ok) {
|
|
1036
|
+
throw new FetchHttpError(res.status, res.statusText, "batch");
|
|
499
1037
|
}
|
|
500
|
-
}
|
|
1038
|
+
}, retries, initialBackoffMs, maxBackoffMs);
|
|
501
1039
|
};
|
|
502
1040
|
return {
|
|
503
1041
|
batchSink: (events) => postBatch(events, opts.init ?? {}),
|
|
504
1042
|
exitBatchSink: (events) => {
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
+
});
|
|
515
1054
|
}
|
|
516
1055
|
};
|
|
517
1056
|
}
|
|
518
1057
|
export {
|
|
1058
|
+
FetchHttpError,
|
|
1059
|
+
assertSafeLrsUrl,
|
|
519
1060
|
createFetchBatchSink,
|
|
520
1061
|
createFetchTransport,
|
|
521
1062
|
createInMemoryXAPIQueue,
|
|
522
1063
|
createXAPIClient,
|
|
1064
|
+
isRetryableFetchError,
|
|
1065
|
+
isRetryableFetchHttpStatus,
|
|
1066
|
+
loadDeadLetterStatements,
|
|
1067
|
+
persistDeadLetterStatement,
|
|
1068
|
+
resetXAPIDeadLetterForTests,
|
|
523
1069
|
telemetryEventToXAPIStatement
|
|
524
1070
|
};
|