@lessonkit/xapi 1.4.0 → 1.6.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 +582 -64
- package/dist/index.d.cts +31 -2
- package/dist/index.d.ts +31 -2
- package/dist/index.js +577 -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,30 @@ 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) {
|
|
184
|
+
const evicted = buffer[1];
|
|
71
185
|
buffer.splice(1, 1);
|
|
186
|
+
opts?.onCap?.();
|
|
187
|
+
opts?.onOverflow?.(evicted);
|
|
188
|
+
} else {
|
|
189
|
+
opts?.onCap?.();
|
|
190
|
+
opts?.onOverflow?.(normalized);
|
|
191
|
+
return;
|
|
72
192
|
}
|
|
73
193
|
} else {
|
|
194
|
+
const evicted = buffer[0];
|
|
74
195
|
buffer.shift();
|
|
196
|
+
opts?.onCap?.();
|
|
197
|
+
opts?.onOverflow?.(evicted);
|
|
75
198
|
}
|
|
76
|
-
opts?.onCap?.();
|
|
77
199
|
}
|
|
78
200
|
buffer.push(normalized);
|
|
79
201
|
notifyDepth();
|
|
@@ -89,7 +211,9 @@ function createInMemoryXAPIQueue(opts) {
|
|
|
89
211
|
return flushInFlight;
|
|
90
212
|
},
|
|
91
213
|
flushOnExit: (exitTransport) => {
|
|
214
|
+
const skipId = headInFlightId;
|
|
92
215
|
for (const statement of buffer) {
|
|
216
|
+
if (statement.id === skipId) continue;
|
|
93
217
|
try {
|
|
94
218
|
exitTransport(statement);
|
|
95
219
|
} catch {
|
|
@@ -105,6 +229,64 @@ function createInMemoryXAPIQueue(opts) {
|
|
|
105
229
|
// src/client.ts
|
|
106
230
|
import { nowIso } from "@lessonkit/core";
|
|
107
231
|
|
|
232
|
+
// src/deadLetter.ts
|
|
233
|
+
var STORAGE_KEY = "lk-xapi-dead-letter";
|
|
234
|
+
var MAX_DEAD_LETTER = 200;
|
|
235
|
+
function readStorage() {
|
|
236
|
+
try {
|
|
237
|
+
const storage = globalThis.sessionStorage;
|
|
238
|
+
return storage ?? null;
|
|
239
|
+
} catch {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function loadDeadLetterStatements() {
|
|
244
|
+
const storage = readStorage();
|
|
245
|
+
if (!storage) return [];
|
|
246
|
+
try {
|
|
247
|
+
const raw = storage.getItem(STORAGE_KEY);
|
|
248
|
+
if (!raw) return [];
|
|
249
|
+
const parsed = JSON.parse(raw);
|
|
250
|
+
if (!Array.isArray(parsed)) return [];
|
|
251
|
+
return parsed.filter(
|
|
252
|
+
(item) => typeof item === "object" && item !== null && typeof item.id === "string"
|
|
253
|
+
);
|
|
254
|
+
} catch {
|
|
255
|
+
return [];
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
function persistDeadLetterStatement(statement, opts) {
|
|
259
|
+
const storage = readStorage();
|
|
260
|
+
if (!storage) return;
|
|
261
|
+
try {
|
|
262
|
+
const existing = loadDeadLetterStatements();
|
|
263
|
+
if (existing.some((s) => s.id === statement.id)) return;
|
|
264
|
+
const combined = [...existing, statement];
|
|
265
|
+
if (combined.length > MAX_DEAD_LETTER) {
|
|
266
|
+
opts?.onTruncated?.(combined.length - MAX_DEAD_LETTER);
|
|
267
|
+
}
|
|
268
|
+
const next = combined.slice(-MAX_DEAD_LETTER);
|
|
269
|
+
storage.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
270
|
+
} catch {
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function removeDeadLetterStatement(id) {
|
|
274
|
+
const storage = readStorage();
|
|
275
|
+
if (!storage) return;
|
|
276
|
+
try {
|
|
277
|
+
const next = loadDeadLetterStatements().filter((s) => s.id !== id);
|
|
278
|
+
if (next.length === 0) {
|
|
279
|
+
storage.removeItem(STORAGE_KEY);
|
|
280
|
+
} else {
|
|
281
|
+
storage.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
282
|
+
}
|
|
283
|
+
} catch {
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
function clearDeadLetterStorage() {
|
|
287
|
+
readStorage()?.removeItem(STORAGE_KEY);
|
|
288
|
+
}
|
|
289
|
+
|
|
108
290
|
// src/telemetryMap.ts
|
|
109
291
|
import { buildLessonkitUrn } from "@lessonkit/core";
|
|
110
292
|
|
|
@@ -141,9 +323,9 @@ function buildXapiScoreResult(opts) {
|
|
|
141
323
|
}
|
|
142
324
|
return result;
|
|
143
325
|
}
|
|
144
|
-
function statementFor(objectId, verb, timestamp, extra) {
|
|
326
|
+
function statementFor(event, objectId, verb, timestamp, extra) {
|
|
145
327
|
return {
|
|
146
|
-
id:
|
|
328
|
+
id: deriveStatementId(event, objectId, verb),
|
|
147
329
|
timestamp,
|
|
148
330
|
verb,
|
|
149
331
|
object: { id: objectId },
|
|
@@ -151,8 +333,21 @@ function statementFor(objectId, verb, timestamp, extra) {
|
|
|
151
333
|
context: extra?.context
|
|
152
334
|
};
|
|
153
335
|
}
|
|
154
|
-
function
|
|
336
|
+
function sanitizeTelemetryEmbedSrc(src) {
|
|
337
|
+
try {
|
|
338
|
+
const url = new URL(src);
|
|
339
|
+
url.username = "";
|
|
340
|
+
url.password = "";
|
|
341
|
+
url.search = "";
|
|
342
|
+
url.hash = "";
|
|
343
|
+
return `${url.origin}${url.pathname}`;
|
|
344
|
+
} catch {
|
|
345
|
+
return src;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
function experiencedBlockStatement(event, courseId, lessonId, blockId, timestamp) {
|
|
155
349
|
return statementFor(
|
|
350
|
+
event,
|
|
156
351
|
buildLessonkitUrn({ courseId, lessonId, blockId }),
|
|
157
352
|
XAPIVerbs.experienced,
|
|
158
353
|
timestamp
|
|
@@ -163,20 +358,39 @@ var experiencedBlockMapper = (event, ctx) => {
|
|
|
163
358
|
const lessonId2 = event.lessonId;
|
|
164
359
|
const blockId2 = event.data?.blockId;
|
|
165
360
|
if (!lessonId2 || !blockId2 || typeof blockId2 !== "string") return null;
|
|
166
|
-
|
|
361
|
+
const kind = event.data?.kind;
|
|
362
|
+
const extensions = {};
|
|
363
|
+
if (kind === "embed_viewed" || kind === "chart_viewed") {
|
|
364
|
+
extensions["https://lessonkit.dev/xapi/interactionKind"] = kind;
|
|
365
|
+
const data = event.data;
|
|
366
|
+
if (kind === "embed_viewed" && data && typeof data.src === "string") {
|
|
367
|
+
extensions["https://lessonkit.dev/xapi/embedSrc"] = sanitizeTelemetryEmbedSrc(data.src);
|
|
368
|
+
}
|
|
369
|
+
if (kind === "chart_viewed" && data && typeof data.chartType === "string") {
|
|
370
|
+
extensions["https://lessonkit.dev/xapi/chartType"] = data.chartType;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return statementFor(
|
|
374
|
+
event,
|
|
375
|
+
buildLessonkitUrn({ courseId: ctx.courseId, lessonId: lessonId2, blockId: blockId2 }),
|
|
376
|
+
XAPIVerbs.experienced,
|
|
377
|
+
ctx.timestamp,
|
|
378
|
+
Object.keys(extensions).length > 0 ? { context: { extensions } } : void 0
|
|
379
|
+
);
|
|
167
380
|
}
|
|
168
381
|
const lessonId = event.lessonId;
|
|
169
382
|
const blockId = "data" in event && event.data && "blockId" in event.data ? event.data.blockId : void 0;
|
|
170
383
|
if (!lessonId || !blockId || typeof blockId !== "string") return null;
|
|
171
|
-
return experiencedBlockStatement(ctx.courseId, lessonId, blockId, ctx.timestamp);
|
|
384
|
+
return experiencedBlockStatement(event, ctx.courseId, lessonId, blockId, ctx.timestamp);
|
|
172
385
|
};
|
|
173
386
|
var TELEMETRY_XAPI_MAPPERS = {
|
|
174
|
-
course_started: (
|
|
175
|
-
course_completed: (
|
|
387
|
+
course_started: (event, ctx) => statementFor(event, buildLessonkitUrn({ courseId: ctx.courseId }), XAPIVerbs.initialized, ctx.timestamp),
|
|
388
|
+
course_completed: (event, ctx) => statementFor(event, buildLessonkitUrn({ courseId: ctx.courseId }), XAPIVerbs.completed, ctx.timestamp),
|
|
176
389
|
lesson_started: (event, ctx) => {
|
|
177
390
|
const lessonId = event.name === "lesson_started" ? event.lessonId : void 0;
|
|
178
391
|
if (!lessonId) return null;
|
|
179
392
|
return statementFor(
|
|
393
|
+
event,
|
|
180
394
|
buildLessonkitUrn({ courseId: ctx.courseId, lessonId }),
|
|
181
395
|
XAPIVerbs.initialized,
|
|
182
396
|
ctx.timestamp
|
|
@@ -185,6 +399,7 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
185
399
|
lesson_completed: (event, ctx) => {
|
|
186
400
|
if (event.name !== "lesson_completed") return null;
|
|
187
401
|
const lessonId = event.lessonId;
|
|
402
|
+
if (!lessonId) return null;
|
|
188
403
|
const data = event.data;
|
|
189
404
|
const result = {};
|
|
190
405
|
if (typeof data?.durationMs === "number") {
|
|
@@ -194,9 +409,15 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
194
409
|
if (typeof data?.success === "boolean") result.success = data.success;
|
|
195
410
|
const score = buildXapiScoreResult({ score: data?.score, maxScore: data?.maxScore });
|
|
196
411
|
if (score) result.score = score;
|
|
197
|
-
return statementFor(
|
|
198
|
-
|
|
199
|
-
|
|
412
|
+
return statementFor(
|
|
413
|
+
event,
|
|
414
|
+
buildLessonkitUrn({ courseId: ctx.courseId, lessonId }),
|
|
415
|
+
XAPIVerbs.completed,
|
|
416
|
+
ctx.timestamp,
|
|
417
|
+
{
|
|
418
|
+
result: Object.keys(result).length ? result : void 0
|
|
419
|
+
}
|
|
420
|
+
);
|
|
200
421
|
},
|
|
201
422
|
lesson_time_on_task: () => null,
|
|
202
423
|
quiz_answered: (event, ctx) => {
|
|
@@ -204,6 +425,7 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
204
425
|
const result = {};
|
|
205
426
|
if (typeof event.data.correct === "boolean") result.success = event.data.correct;
|
|
206
427
|
return statementFor(
|
|
428
|
+
event,
|
|
207
429
|
buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
208
430
|
XAPIVerbs.answered,
|
|
209
431
|
ctx.timestamp,
|
|
@@ -214,6 +436,7 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
214
436
|
if (event.name !== "quiz_completed") return null;
|
|
215
437
|
const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
|
|
216
438
|
return statementFor(
|
|
439
|
+
event,
|
|
217
440
|
buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
218
441
|
XAPIVerbs.completed,
|
|
219
442
|
ctx.timestamp,
|
|
@@ -225,6 +448,7 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
225
448
|
const result = {};
|
|
226
449
|
if (typeof event.data.correct === "boolean") result.success = event.data.correct;
|
|
227
450
|
return statementFor(
|
|
451
|
+
event,
|
|
228
452
|
buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
229
453
|
XAPIVerbs.answered,
|
|
230
454
|
ctx.timestamp,
|
|
@@ -235,6 +459,7 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
235
459
|
if (event.name !== "assessment_completed") return null;
|
|
236
460
|
const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
|
|
237
461
|
return statementFor(
|
|
462
|
+
event,
|
|
238
463
|
buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
239
464
|
XAPIVerbs.completed,
|
|
240
465
|
ctx.timestamp,
|
|
@@ -256,6 +481,7 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
256
481
|
const blockId = event.data.blockId;
|
|
257
482
|
if (!lessonId || !blockId) return null;
|
|
258
483
|
return statementFor(
|
|
484
|
+
event,
|
|
259
485
|
buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId }),
|
|
260
486
|
XAPIVerbs.completed,
|
|
261
487
|
ctx.timestamp
|
|
@@ -270,20 +496,92 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
270
496
|
const blockId = event.data.blockId;
|
|
271
497
|
if (!lessonId || !blockId) return null;
|
|
272
498
|
return statementFor(
|
|
499
|
+
event,
|
|
500
|
+
buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId }),
|
|
501
|
+
XAPIVerbs.completed,
|
|
502
|
+
ctx.timestamp
|
|
503
|
+
);
|
|
504
|
+
},
|
|
505
|
+
branch_node_viewed: (event, ctx) => {
|
|
506
|
+
if (event.name !== "branch_node_viewed") return null;
|
|
507
|
+
const lessonId = event.lessonId;
|
|
508
|
+
const blockId = event.data.blockId;
|
|
509
|
+
const nodeId = event.data.nodeId;
|
|
510
|
+
if (!lessonId || !blockId || !nodeId) return null;
|
|
511
|
+
return statementFor(
|
|
512
|
+
event,
|
|
513
|
+
buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId, nodeId }),
|
|
514
|
+
XAPIVerbs.experienced,
|
|
515
|
+
ctx.timestamp
|
|
516
|
+
);
|
|
517
|
+
},
|
|
518
|
+
branch_selected: (event, ctx) => {
|
|
519
|
+
if (event.name !== "branch_selected") return null;
|
|
520
|
+
const lessonId = event.lessonId;
|
|
521
|
+
const blockId = event.data.blockId;
|
|
522
|
+
const toNodeId = event.data.toNodeId;
|
|
523
|
+
if (!lessonId || !blockId || !toNodeId) return null;
|
|
524
|
+
return statementFor(
|
|
525
|
+
event,
|
|
526
|
+
buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId, nodeId: toNodeId }),
|
|
527
|
+
XAPIVerbs.experienced,
|
|
528
|
+
ctx.timestamp
|
|
529
|
+
);
|
|
530
|
+
},
|
|
531
|
+
image_juxtaposition_changed: experiencedBlockMapper,
|
|
532
|
+
timeline_event_viewed: experiencedBlockMapper,
|
|
533
|
+
image_sequence_changed: experiencedBlockMapper,
|
|
534
|
+
audio_recording_started: experiencedBlockMapper,
|
|
535
|
+
audio_recording_completed: (event, ctx) => {
|
|
536
|
+
if (event.name !== "audio_recording_completed") return null;
|
|
537
|
+
const lessonId = event.lessonId;
|
|
538
|
+
const blockId = event.data.blockId;
|
|
539
|
+
if (!blockId) return null;
|
|
540
|
+
return statementFor(
|
|
541
|
+
event,
|
|
273
542
|
buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId }),
|
|
274
543
|
XAPIVerbs.completed,
|
|
275
544
|
ctx.timestamp
|
|
276
545
|
);
|
|
546
|
+
},
|
|
547
|
+
qr_content_revealed: experiencedBlockMapper,
|
|
548
|
+
advent_door_opened: experiencedBlockMapper,
|
|
549
|
+
map_stage_viewed: (event, ctx) => {
|
|
550
|
+
if (event.name !== "map_stage_viewed") return null;
|
|
551
|
+
const lessonId = event.lessonId;
|
|
552
|
+
const blockId = event.data.blockId;
|
|
553
|
+
const stageId = event.data.stageId;
|
|
554
|
+
if (!lessonId || !blockId || !stageId) return null;
|
|
555
|
+
return statementFor(
|
|
556
|
+
event,
|
|
557
|
+
buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId, nodeId: stageId }),
|
|
558
|
+
XAPIVerbs.experienced,
|
|
559
|
+
ctx.timestamp
|
|
560
|
+
);
|
|
561
|
+
},
|
|
562
|
+
map_exit_selected: (event, ctx) => {
|
|
563
|
+
if (event.name !== "map_exit_selected") return null;
|
|
564
|
+
const lessonId = event.lessonId;
|
|
565
|
+
const blockId = event.data.blockId;
|
|
566
|
+
const toStageId = event.data.toStageId;
|
|
567
|
+
if (!lessonId || !blockId || !toStageId) return null;
|
|
568
|
+
return statementFor(
|
|
569
|
+
event,
|
|
570
|
+
buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId, nodeId: toStageId }),
|
|
571
|
+
XAPIVerbs.experienced,
|
|
572
|
+
ctx.timestamp
|
|
573
|
+
);
|
|
277
574
|
}
|
|
278
575
|
};
|
|
279
576
|
function telemetryEventToXAPIStatement(event) {
|
|
280
|
-
const
|
|
577
|
+
const enriched = enrichTelemetryEventForXapi(event);
|
|
578
|
+
const mapper = TELEMETRY_XAPI_MAPPERS[enriched.name];
|
|
281
579
|
if (!mapper) {
|
|
282
|
-
throw new Error(`Unhandled telemetry event: ${
|
|
580
|
+
throw new Error(`Unhandled telemetry event: ${enriched.name}`);
|
|
283
581
|
}
|
|
284
|
-
return mapper(
|
|
285
|
-
courseId:
|
|
286
|
-
timestamp:
|
|
582
|
+
return mapper(enriched, {
|
|
583
|
+
courseId: enriched.courseId,
|
|
584
|
+
timestamp: enriched.timestamp
|
|
287
585
|
});
|
|
288
586
|
}
|
|
289
587
|
|
|
@@ -301,26 +599,90 @@ function isDevEnvironment() {
|
|
|
301
599
|
const g = globalThis;
|
|
302
600
|
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
303
601
|
}
|
|
602
|
+
function defaultQueueCapHandler() {
|
|
603
|
+
if (isDevEnvironment()) {
|
|
604
|
+
console.warn("[lessonkit] xAPI queue reached capacity; oldest statement(s) dropped.");
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
function defaultHeadSkippedHandler(_statement, err) {
|
|
608
|
+
if (isDevEnvironment()) {
|
|
609
|
+
console.warn(
|
|
610
|
+
"[lessonkit] xAPI queue skipped statement after repeated transport failures:",
|
|
611
|
+
err instanceof Error ? err.message : err
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
304
615
|
function createXAPIClient(opts) {
|
|
305
616
|
const transport = opts?.transport;
|
|
306
617
|
const exitTransport = opts?.exitTransport;
|
|
307
618
|
const courseId = opts?.courseId;
|
|
308
619
|
const queue = opts?.queue ?? createInMemoryXAPIQueue({
|
|
309
620
|
maxSize: opts?.maxQueueSize,
|
|
621
|
+
maxHeadFailures: opts?.maxHeadFailures,
|
|
310
622
|
onDepth: opts?.onQueueDepth,
|
|
311
|
-
onCap: opts?.onQueueCap
|
|
623
|
+
onCap: opts?.onQueueCap ?? defaultQueueCapHandler,
|
|
624
|
+
onOverflow: (statement) => {
|
|
625
|
+
persistDeadLetterStatement(statement, {
|
|
626
|
+
onTruncated: opts?.onDeadLetterTruncated
|
|
627
|
+
});
|
|
628
|
+
},
|
|
629
|
+
onHeadSkipped: (statement, err) => {
|
|
630
|
+
persistDeadLetterStatement(statement, {
|
|
631
|
+
onTruncated: opts?.onDeadLetterTruncated
|
|
632
|
+
});
|
|
633
|
+
(opts?.onHeadSkipped ?? defaultHeadSkippedHandler)(statement, err);
|
|
634
|
+
}
|
|
312
635
|
});
|
|
313
636
|
let warnedNoTransport = false;
|
|
314
637
|
let warnedTransportFailure = false;
|
|
315
638
|
const inflightById = /* @__PURE__ */ new Map();
|
|
316
639
|
const inflightStatements = /* @__PURE__ */ new Map();
|
|
640
|
+
const pendingReplacement = /* @__PURE__ */ new Map();
|
|
641
|
+
const inflightPayload = /* @__PURE__ */ new Map();
|
|
642
|
+
const replacementWatcher = /* @__PURE__ */ new Set();
|
|
317
643
|
const exitDeliveredIds = /* @__PURE__ */ new Set();
|
|
318
644
|
const exitNetworkSentIds = /* @__PURE__ */ new Set();
|
|
645
|
+
const exitHandoffIds = /* @__PURE__ */ new Set();
|
|
646
|
+
let activeFlush = null;
|
|
647
|
+
for (const statement of loadDeadLetterStatements()) {
|
|
648
|
+
queue.enqueue(statement);
|
|
649
|
+
}
|
|
650
|
+
const hadDeadLetters = queue.size() > 0;
|
|
319
651
|
const deliveryTransport = transport ? async (statement) => {
|
|
320
652
|
if (exitNetworkSentIds.has(statement.id)) return;
|
|
321
653
|
await transport(statement);
|
|
654
|
+
removeDeadLetterStatement(statement.id);
|
|
322
655
|
} : void 0;
|
|
323
|
-
const
|
|
656
|
+
const markExitDelivered = (statement) => {
|
|
657
|
+
exitHandoffIds.delete(statement.id);
|
|
658
|
+
exitDeliveredIds.add(statement.id);
|
|
659
|
+
exitNetworkSentIds.add(statement.id);
|
|
660
|
+
removeDeadLetterStatement(statement.id);
|
|
661
|
+
};
|
|
662
|
+
const dispatchExitStatement = (statement) => {
|
|
663
|
+
if (exitDeliveredIds.has(statement.id)) return;
|
|
664
|
+
exitHandoffIds.add(statement.id);
|
|
665
|
+
try {
|
|
666
|
+
const result = exitTransport(statement);
|
|
667
|
+
if (result != null && typeof result.then === "function") {
|
|
668
|
+
void result.then(
|
|
669
|
+
() => markExitDelivered(statement),
|
|
670
|
+
() => {
|
|
671
|
+
exitHandoffIds.delete(statement.id);
|
|
672
|
+
persistDeadLetterStatement(statement);
|
|
673
|
+
}
|
|
674
|
+
);
|
|
675
|
+
} else {
|
|
676
|
+
markExitDelivered(statement);
|
|
677
|
+
}
|
|
678
|
+
} catch {
|
|
679
|
+
exitHandoffIds.delete(statement.id);
|
|
680
|
+
persistDeadLetterStatement(statement);
|
|
681
|
+
}
|
|
682
|
+
};
|
|
683
|
+
const pendingDuringFlush = [];
|
|
684
|
+
let flushInProgress = false;
|
|
685
|
+
const sendOrQueueInternal = (statement) => {
|
|
324
686
|
const normalized = withStatementId2(statement);
|
|
325
687
|
if (exitDeliveredIds.has(normalized.id)) return;
|
|
326
688
|
if (!deliveryTransport) {
|
|
@@ -335,20 +697,39 @@ function createXAPIClient(opts) {
|
|
|
335
697
|
}
|
|
336
698
|
const existing = inflightById.get(normalized.id);
|
|
337
699
|
if (existing) {
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
700
|
+
pendingReplacement.set(normalized.id, normalized);
|
|
701
|
+
inflightStatements.set(normalized.id, normalized);
|
|
702
|
+
if (!replacementWatcher.has(normalized.id)) {
|
|
703
|
+
replacementWatcher.add(normalized.id);
|
|
704
|
+
void existing.then(
|
|
705
|
+
() => {
|
|
706
|
+
replacementWatcher.delete(normalized.id);
|
|
707
|
+
const replacement = pendingReplacement.get(normalized.id);
|
|
708
|
+
const transported = inflightPayload.get(normalized.id);
|
|
709
|
+
pendingReplacement.delete(normalized.id);
|
|
710
|
+
inflightPayload.delete(normalized.id);
|
|
711
|
+
if (replacement && replacement !== transported) {
|
|
712
|
+
sendOrQueueInternal(replacement);
|
|
713
|
+
}
|
|
714
|
+
},
|
|
715
|
+
() => {
|
|
716
|
+
replacementWatcher.delete(normalized.id);
|
|
717
|
+
const replacement = pendingReplacement.get(normalized.id) ?? normalized;
|
|
718
|
+
pendingReplacement.delete(normalized.id);
|
|
719
|
+
sendOrQueueInternal(replacement);
|
|
720
|
+
}
|
|
721
|
+
);
|
|
722
|
+
}
|
|
344
723
|
return;
|
|
345
724
|
}
|
|
725
|
+
queue.removeById(normalized.id);
|
|
346
726
|
inflightStatements.set(normalized.id, normalized);
|
|
727
|
+
inflightPayload.set(normalized.id, normalized);
|
|
347
728
|
const flight = Promise.resolve().then(async () => {
|
|
348
729
|
await deliveryTransport(normalized);
|
|
349
730
|
queue.removeById(normalized.id);
|
|
350
731
|
}).catch((err) => {
|
|
351
|
-
if (exitDeliveredIds.has(normalized.id)) return;
|
|
732
|
+
if (exitDeliveredIds.has(normalized.id) || exitHandoffIds.has(normalized.id)) return;
|
|
352
733
|
queue.enqueue(normalized);
|
|
353
734
|
opts?.onTransportError?.(err);
|
|
354
735
|
if (isDevEnvironment() && !warnedTransportFailure) {
|
|
@@ -361,17 +742,28 @@ function createXAPIClient(opts) {
|
|
|
361
742
|
}).finally(() => {
|
|
362
743
|
inflightById.delete(normalized.id);
|
|
363
744
|
inflightStatements.delete(normalized.id);
|
|
745
|
+
if (!replacementWatcher.has(normalized.id)) {
|
|
746
|
+
inflightPayload.delete(normalized.id);
|
|
747
|
+
}
|
|
364
748
|
});
|
|
365
749
|
inflightById.set(normalized.id, flight);
|
|
366
750
|
void flight.catch(() => {
|
|
367
751
|
});
|
|
368
752
|
};
|
|
753
|
+
const sendOrQueue = (statement) => {
|
|
754
|
+
if (flushInProgress) {
|
|
755
|
+
pendingDuringFlush.push(statement);
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
sendOrQueueInternal(statement);
|
|
759
|
+
};
|
|
369
760
|
const emit = (event) => {
|
|
370
761
|
try {
|
|
371
762
|
const statement = telemetryEventToXAPIStatement(event);
|
|
372
763
|
if (!statement) return;
|
|
373
764
|
sendOrQueue(statement);
|
|
374
765
|
} catch (err) {
|
|
766
|
+
opts?.onMappingError?.(err);
|
|
375
767
|
if (isDevEnvironment()) {
|
|
376
768
|
console.warn(
|
|
377
769
|
"[lessonkit] xAPI mapping skipped:",
|
|
@@ -380,42 +772,66 @@ function createXAPIClient(opts) {
|
|
|
380
772
|
}
|
|
381
773
|
}
|
|
382
774
|
};
|
|
383
|
-
|
|
775
|
+
const runFlushLoop = async () => {
|
|
776
|
+
if (!deliveryTransport) return;
|
|
777
|
+
for (; ; ) {
|
|
778
|
+
await queue.flush(deliveryTransport);
|
|
779
|
+
const flights = [...inflightById.values()];
|
|
780
|
+
if (flights.length > 0) {
|
|
781
|
+
await Promise.all(flights);
|
|
782
|
+
}
|
|
783
|
+
if (queue.size() === 0 && inflightById.size === 0) break;
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
const client = {
|
|
384
787
|
send: (statement) => {
|
|
385
788
|
sendOrQueue(statement);
|
|
386
789
|
},
|
|
387
790
|
queueSize: () => queue.size(),
|
|
388
791
|
flush: async () => {
|
|
389
792
|
if (!deliveryTransport) return;
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
793
|
+
for (; ; ) {
|
|
794
|
+
if (activeFlush) {
|
|
795
|
+
await activeFlush;
|
|
796
|
+
} else {
|
|
797
|
+
flushInProgress = true;
|
|
798
|
+
activeFlush = (async () => {
|
|
799
|
+
try {
|
|
800
|
+
await runFlushLoop();
|
|
801
|
+
while (pendingDuringFlush.length > 0) {
|
|
802
|
+
const batch = pendingDuringFlush.splice(0, pendingDuringFlush.length);
|
|
803
|
+
for (const pending of batch) {
|
|
804
|
+
sendOrQueueInternal(pending);
|
|
805
|
+
}
|
|
806
|
+
await runFlushLoop();
|
|
807
|
+
}
|
|
808
|
+
} finally {
|
|
809
|
+
flushInProgress = false;
|
|
810
|
+
}
|
|
811
|
+
})().finally(() => {
|
|
812
|
+
activeFlush = null;
|
|
813
|
+
});
|
|
814
|
+
await activeFlush;
|
|
815
|
+
}
|
|
816
|
+
if (queue.size() === 0 && inflightById.size === 0) break;
|
|
397
817
|
}
|
|
398
818
|
},
|
|
399
819
|
flushOnExit: exitTransport ? () => {
|
|
400
820
|
const headId = queue.getHeadInFlightId?.();
|
|
401
821
|
if (headId) {
|
|
402
|
-
exitNetworkSentIds.add(headId);
|
|
403
|
-
exitDeliveredIds.add(headId);
|
|
404
822
|
opts.abortInFlight?.(headId);
|
|
823
|
+
const headStatement = inflightStatements.get(headId);
|
|
824
|
+
if (headStatement) {
|
|
825
|
+
dispatchExitStatement(headStatement);
|
|
826
|
+
}
|
|
405
827
|
}
|
|
406
828
|
for (const statement of inflightStatements.values()) {
|
|
407
|
-
|
|
408
|
-
exitDeliveredIds.add(statement.id);
|
|
829
|
+
if (statement.id === headId) continue;
|
|
409
830
|
opts.abortInFlight?.(statement.id);
|
|
831
|
+
dispatchExitStatement(statement);
|
|
410
832
|
}
|
|
411
833
|
queue.flushOnExit((statement) => {
|
|
412
|
-
|
|
413
|
-
exitNetworkSentIds.add(statement.id);
|
|
414
|
-
exitDeliveredIds.add(statement.id);
|
|
415
|
-
try {
|
|
416
|
-
exitTransport(statement);
|
|
417
|
-
} catch {
|
|
418
|
-
}
|
|
834
|
+
dispatchExitStatement(statement);
|
|
419
835
|
});
|
|
420
836
|
} : void 0,
|
|
421
837
|
startedLesson: ({ lessonId }) => {
|
|
@@ -453,6 +869,97 @@ function createXAPIClient(opts) {
|
|
|
453
869
|
});
|
|
454
870
|
}
|
|
455
871
|
};
|
|
872
|
+
if (hadDeadLetters && deliveryTransport) {
|
|
873
|
+
queueMicrotask(() => {
|
|
874
|
+
void client.flush().catch(() => void 0);
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
return client;
|
|
878
|
+
}
|
|
879
|
+
function resetXAPIDeadLetterForTests() {
|
|
880
|
+
clearDeadLetterStorage();
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// src/safeLrsUrl.ts
|
|
884
|
+
function isProductionRuntime() {
|
|
885
|
+
try {
|
|
886
|
+
if (import.meta.env?.PROD === true) return true;
|
|
887
|
+
} catch {
|
|
888
|
+
}
|
|
889
|
+
const g = globalThis;
|
|
890
|
+
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production";
|
|
891
|
+
}
|
|
892
|
+
function parseHostname(url) {
|
|
893
|
+
return url.hostname.replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
|
|
894
|
+
}
|
|
895
|
+
function isIpv4MappedAddress(hostname) {
|
|
896
|
+
const match = hostname.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
|
897
|
+
return match?.[1] ?? null;
|
|
898
|
+
}
|
|
899
|
+
function isLoopbackHost(hostname) {
|
|
900
|
+
const ipv4Mapped = isIpv4MappedAddress(hostname);
|
|
901
|
+
if (ipv4Mapped) return isLoopbackHost(ipv4Mapped);
|
|
902
|
+
return hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "127.0.0.1" || hostname === "::1" || hostname === "0.0.0.0";
|
|
903
|
+
}
|
|
904
|
+
function isLinkLocalOrMetadataHost(hostname) {
|
|
905
|
+
if (hostname === "169.254.169.254") return true;
|
|
906
|
+
if (/^169\.254\./.test(hostname)) return true;
|
|
907
|
+
if (/^fe80:/i.test(hostname)) return true;
|
|
908
|
+
return false;
|
|
909
|
+
}
|
|
910
|
+
function isRfc1918Host(hostname) {
|
|
911
|
+
const ipv4Mapped = isIpv4MappedAddress(hostname);
|
|
912
|
+
if (ipv4Mapped) return isRfc1918Host(ipv4Mapped);
|
|
913
|
+
if (/^10\./.test(hostname)) return true;
|
|
914
|
+
if (/^192\.168\./.test(hostname)) return true;
|
|
915
|
+
const parts = hostname.split(".").map(Number);
|
|
916
|
+
if (parts.length === 4 && parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
|
|
917
|
+
return false;
|
|
918
|
+
}
|
|
919
|
+
function isPrivateOrMetadataHost(hostname) {
|
|
920
|
+
return isLoopbackHost(hostname) || isLinkLocalOrMetadataHost(hostname) || isRfc1918Host(hostname);
|
|
921
|
+
}
|
|
922
|
+
function containsPathTraversal(path) {
|
|
923
|
+
if (path.includes("..")) return true;
|
|
924
|
+
let decoded = path;
|
|
925
|
+
for (let i = 0; i < 2; i++) {
|
|
926
|
+
try {
|
|
927
|
+
const next = decodeURIComponent(decoded.replace(/\+/g, " "));
|
|
928
|
+
if (next.includes("..")) return true;
|
|
929
|
+
if (next === decoded) break;
|
|
930
|
+
decoded = next;
|
|
931
|
+
} catch {
|
|
932
|
+
break;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
return false;
|
|
936
|
+
}
|
|
937
|
+
function assertSafeLrsUrl(url, opts) {
|
|
938
|
+
if (url.startsWith("/")) {
|
|
939
|
+
if (containsPathTraversal(url)) {
|
|
940
|
+
throw new Error(`Unsafe LRS URL: path traversal in "${url}"`);
|
|
941
|
+
}
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
let parsed;
|
|
945
|
+
try {
|
|
946
|
+
parsed = new URL(url);
|
|
947
|
+
} catch {
|
|
948
|
+
throw new Error(`Unsafe LRS URL: invalid URL "${url}"`);
|
|
949
|
+
}
|
|
950
|
+
if (containsPathTraversal(parsed.pathname)) {
|
|
951
|
+
throw new Error(`Unsafe LRS URL: path traversal in "${url}"`);
|
|
952
|
+
}
|
|
953
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
954
|
+
throw new Error(`Unsafe LRS URL: unsupported scheme "${parsed.protocol}"`);
|
|
955
|
+
}
|
|
956
|
+
if (isProductionRuntime() && parsed.protocol !== "https:") {
|
|
957
|
+
throw new Error("Unsafe LRS URL: HTTPS is required in production builds");
|
|
958
|
+
}
|
|
959
|
+
const hostname = parseHostname(parsed);
|
|
960
|
+
if (!opts?.allowPrivateHosts && isPrivateOrMetadataHost(hostname)) {
|
|
961
|
+
throw new Error(`Unsafe LRS URL: private or metadata host "${hostname}" is not allowed`);
|
|
962
|
+
}
|
|
456
963
|
}
|
|
457
964
|
|
|
458
965
|
// src/fetchTransport.ts
|
|
@@ -522,6 +1029,7 @@ async function postWithRetry(post, retries, initialBackoffMs, maxBackoffMs) {
|
|
|
522
1029
|
}
|
|
523
1030
|
}
|
|
524
1031
|
function createFetchTransport(opts) {
|
|
1032
|
+
assertSafeLrsUrl(opts.url, { allowPrivateHosts: opts.allowPrivateHosts });
|
|
525
1033
|
const timeoutMs = opts.timeoutMs ?? 3e4;
|
|
526
1034
|
const rawRetries = opts.retries ?? 2;
|
|
527
1035
|
const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
|
|
@@ -553,14 +1061,13 @@ function createFetchTransport(opts) {
|
|
|
553
1061
|
}
|
|
554
1062
|
};
|
|
555
1063
|
const exitTransport = (statement) => {
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
}
|
|
563
|
-
}
|
|
1064
|
+
return postStatement(opts.url, statement, {
|
|
1065
|
+
...opts.init,
|
|
1066
|
+
headers: resolveHeaders(opts.headers),
|
|
1067
|
+
keepalive: true
|
|
1068
|
+
}).catch(() => {
|
|
1069
|
+
throw new Error("xAPI keepalive delivery failed");
|
|
1070
|
+
});
|
|
564
1071
|
};
|
|
565
1072
|
const abortInFlight = (statementId) => {
|
|
566
1073
|
activeControllers.get(statementId)?.abort();
|
|
@@ -569,6 +1076,7 @@ function createFetchTransport(opts) {
|
|
|
569
1076
|
return { transport, exitTransport, abortInFlight };
|
|
570
1077
|
}
|
|
571
1078
|
function createFetchBatchSink(opts) {
|
|
1079
|
+
assertSafeLrsUrl(opts.url, { allowPrivateHosts: opts.allowPrivateHosts });
|
|
572
1080
|
const timeoutMs = opts.timeoutMs ?? 3e4;
|
|
573
1081
|
const rawRetries = opts.retries ?? 2;
|
|
574
1082
|
const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
|
|
@@ -592,26 +1100,31 @@ function createFetchBatchSink(opts) {
|
|
|
592
1100
|
return {
|
|
593
1101
|
batchSink: (events) => postBatch(events, opts.init ?? {}),
|
|
594
1102
|
exitBatchSink: (events) => {
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
1103
|
+
return fetch(opts.url, {
|
|
1104
|
+
method: "POST",
|
|
1105
|
+
body: JSON.stringify(events),
|
|
1106
|
+
...opts.init,
|
|
1107
|
+
headers: resolveHeaders(opts.headers),
|
|
1108
|
+
keepalive: true
|
|
1109
|
+
}).then((res) => {
|
|
1110
|
+
if (!res.ok) {
|
|
1111
|
+
throw new FetchHttpError(res.status, res.statusText, "batch");
|
|
1112
|
+
}
|
|
1113
|
+
});
|
|
605
1114
|
}
|
|
606
1115
|
};
|
|
607
1116
|
}
|
|
608
1117
|
export {
|
|
609
1118
|
FetchHttpError,
|
|
1119
|
+
assertSafeLrsUrl,
|
|
610
1120
|
createFetchBatchSink,
|
|
611
1121
|
createFetchTransport,
|
|
612
1122
|
createInMemoryXAPIQueue,
|
|
613
1123
|
createXAPIClient,
|
|
614
1124
|
isRetryableFetchError,
|
|
615
1125
|
isRetryableFetchHttpStatus,
|
|
1126
|
+
loadDeadLetterStatements,
|
|
1127
|
+
persistDeadLetterStatement,
|
|
1128
|
+
resetXAPIDeadLetterForTests,
|
|
616
1129
|
telemetryEventToXAPIStatement
|
|
617
1130
|
};
|