@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.cjs
CHANGED
|
@@ -21,21 +21,133 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
23
|
FetchHttpError: () => FetchHttpError,
|
|
24
|
+
assertSafeLrsUrl: () => assertSafeLrsUrl,
|
|
24
25
|
createFetchBatchSink: () => createFetchBatchSink,
|
|
25
26
|
createFetchTransport: () => createFetchTransport,
|
|
26
27
|
createInMemoryXAPIQueue: () => createInMemoryXAPIQueue,
|
|
27
28
|
createXAPIClient: () => createXAPIClient,
|
|
28
29
|
isRetryableFetchError: () => isRetryableFetchError,
|
|
29
30
|
isRetryableFetchHttpStatus: () => isRetryableFetchHttpStatus,
|
|
31
|
+
loadDeadLetterStatements: () => loadDeadLetterStatements,
|
|
32
|
+
persistDeadLetterStatement: () => persistDeadLetterStatement,
|
|
33
|
+
resetXAPIDeadLetterForTests: () => resetXAPIDeadLetterForTests,
|
|
30
34
|
telemetryEventToXAPIStatement: () => telemetryEventToXAPIStatement
|
|
31
35
|
});
|
|
32
36
|
module.exports = __toCommonJS(index_exports);
|
|
33
37
|
|
|
34
38
|
// src/id.ts
|
|
39
|
+
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
40
|
+
function randomIdFallback() {
|
|
41
|
+
const g = globalThis;
|
|
42
|
+
if (g.crypto?.getRandomValues) {
|
|
43
|
+
const bytes = new Uint8Array(16);
|
|
44
|
+
g.crypto.getRandomValues(bytes);
|
|
45
|
+
return formatUuid(bytes);
|
|
46
|
+
}
|
|
47
|
+
throw new Error(
|
|
48
|
+
"[lessonkit] cryptoRandomId requires crypto.randomUUID or crypto.getRandomValues"
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
function formatUuid(bytes) {
|
|
52
|
+
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
53
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
54
|
+
}
|
|
55
|
+
function hashSeedToUuid(seed) {
|
|
56
|
+
const bytes = new Uint8Array(16);
|
|
57
|
+
let h0 = 2166136261;
|
|
58
|
+
let h1 = 2166136261 ^ 2654435769;
|
|
59
|
+
let h2 = 2166136261 ^ 2246822507;
|
|
60
|
+
let h3 = 2166136261 ^ 3266489909;
|
|
61
|
+
for (let i = 0; i < seed.length; i += 1) {
|
|
62
|
+
const c = seed.charCodeAt(i);
|
|
63
|
+
h0 = Math.imul(h0 ^ c, 16777619);
|
|
64
|
+
h1 = Math.imul(h1 ^ c + 1, 16777619);
|
|
65
|
+
h2 = Math.imul(h2 ^ c + 2, 16777619);
|
|
66
|
+
h3 = Math.imul(h3 ^ c + 3, 16777619);
|
|
67
|
+
}
|
|
68
|
+
bytes[0] = h0 >>> 24 & 255;
|
|
69
|
+
bytes[1] = h0 >>> 16 & 255;
|
|
70
|
+
bytes[2] = h0 >>> 8 & 255;
|
|
71
|
+
bytes[3] = h0 & 255;
|
|
72
|
+
bytes[4] = h1 >>> 24 & 255;
|
|
73
|
+
bytes[5] = h1 >>> 16 & 255;
|
|
74
|
+
bytes[6] = h1 >>> 8 & 15 | 80;
|
|
75
|
+
bytes[7] = h1 & 255;
|
|
76
|
+
bytes[8] = h2 >>> 24 & 63 | 128;
|
|
77
|
+
bytes[9] = h2 >>> 16 & 255;
|
|
78
|
+
bytes[10] = h2 >>> 8 & 255;
|
|
79
|
+
bytes[11] = h2 & 255;
|
|
80
|
+
bytes[12] = h3 >>> 24 & 255;
|
|
81
|
+
bytes[13] = h3 >>> 16 & 255;
|
|
82
|
+
bytes[14] = h3 >>> 8 & 255;
|
|
83
|
+
bytes[15] = h3 & 255;
|
|
84
|
+
return formatUuid(bytes);
|
|
85
|
+
}
|
|
35
86
|
function cryptoRandomId() {
|
|
36
87
|
const g = globalThis;
|
|
37
88
|
if (g.crypto?.randomUUID) return g.crypto.randomUUID();
|
|
38
|
-
return
|
|
89
|
+
return randomIdFallback();
|
|
90
|
+
}
|
|
91
|
+
var LIFECYCLE_EVENTS_FOR_STABLE_ID = /* @__PURE__ */ new Set([
|
|
92
|
+
"course_started",
|
|
93
|
+
"course_completed",
|
|
94
|
+
"lesson_started",
|
|
95
|
+
"lesson_completed"
|
|
96
|
+
]);
|
|
97
|
+
var ASSESSMENT_COMPLETION_EVENTS_FOR_STABLE_ID = /* @__PURE__ */ new Set([
|
|
98
|
+
"assessment_completed",
|
|
99
|
+
"quiz_completed"
|
|
100
|
+
]);
|
|
101
|
+
function stableTelemetryEventId(event) {
|
|
102
|
+
const seed = [
|
|
103
|
+
event.name,
|
|
104
|
+
event.courseId,
|
|
105
|
+
event.lessonId ?? "",
|
|
106
|
+
event.sessionId ?? "",
|
|
107
|
+
event.attemptId ?? ""
|
|
108
|
+
].join("|");
|
|
109
|
+
return hashSeedToUuid(seed);
|
|
110
|
+
}
|
|
111
|
+
function stableAssessmentCompletionEventId(event) {
|
|
112
|
+
const checkId = event.data && typeof event.data === "object" && "checkId" in event.data ? String(event.data.checkId ?? "") : "";
|
|
113
|
+
const seed = [
|
|
114
|
+
event.name,
|
|
115
|
+
event.courseId,
|
|
116
|
+
event.lessonId ?? "",
|
|
117
|
+
event.sessionId ?? "",
|
|
118
|
+
event.attemptId ?? "",
|
|
119
|
+
checkId
|
|
120
|
+
].join("|");
|
|
121
|
+
return hashSeedToUuid(seed);
|
|
122
|
+
}
|
|
123
|
+
function enrichTelemetryEventForXapi(event) {
|
|
124
|
+
if (event.id?.trim()) return event;
|
|
125
|
+
if (LIFECYCLE_EVENTS_FOR_STABLE_ID.has(event.name)) {
|
|
126
|
+
return { ...event, id: stableTelemetryEventId(event) };
|
|
127
|
+
}
|
|
128
|
+
if (ASSESSMENT_COMPLETION_EVENTS_FOR_STABLE_ID.has(event.name)) {
|
|
129
|
+
return {
|
|
130
|
+
...event,
|
|
131
|
+
id: stableAssessmentCompletionEventId(
|
|
132
|
+
event
|
|
133
|
+
)
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return event;
|
|
137
|
+
}
|
|
138
|
+
function deriveStatementId(event, objectId, verb) {
|
|
139
|
+
const eventId = event.id?.trim();
|
|
140
|
+
if (eventId && UUID_RE.test(eventId)) return eventId;
|
|
141
|
+
const seed = [
|
|
142
|
+
event.name,
|
|
143
|
+
event.courseId,
|
|
144
|
+
event.lessonId ?? "",
|
|
145
|
+
event.sessionId ?? "",
|
|
146
|
+
objectId,
|
|
147
|
+
verb,
|
|
148
|
+
event.timestamp
|
|
149
|
+
].join("|");
|
|
150
|
+
return hashSeedToUuid(seed);
|
|
39
151
|
}
|
|
40
152
|
|
|
41
153
|
// src/queue.ts
|
|
@@ -97,16 +209,30 @@ function createInMemoryXAPIQueue(opts) {
|
|
|
97
209
|
return {
|
|
98
210
|
enqueue: (statement) => {
|
|
99
211
|
const normalized = withStatementId(statement);
|
|
100
|
-
|
|
212
|
+
const existingIdx = buffer.findIndex((s) => s.id === normalized.id);
|
|
213
|
+
if (existingIdx >= 0) {
|
|
214
|
+
buffer[existingIdx] = normalized;
|
|
215
|
+
notifyDepth();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
101
218
|
if (buffer.length >= maxSize) {
|
|
102
219
|
if (headInFlight) {
|
|
103
220
|
if (buffer.length > 1) {
|
|
221
|
+
const evicted = buffer[1];
|
|
104
222
|
buffer.splice(1, 1);
|
|
223
|
+
opts?.onCap?.();
|
|
224
|
+
opts?.onOverflow?.(evicted);
|
|
225
|
+
} else {
|
|
226
|
+
opts?.onCap?.();
|
|
227
|
+
opts?.onOverflow?.(normalized);
|
|
228
|
+
return;
|
|
105
229
|
}
|
|
106
230
|
} else {
|
|
231
|
+
const evicted = buffer[0];
|
|
107
232
|
buffer.shift();
|
|
233
|
+
opts?.onCap?.();
|
|
234
|
+
opts?.onOverflow?.(evicted);
|
|
108
235
|
}
|
|
109
|
-
opts?.onCap?.();
|
|
110
236
|
}
|
|
111
237
|
buffer.push(normalized);
|
|
112
238
|
notifyDepth();
|
|
@@ -122,7 +248,9 @@ function createInMemoryXAPIQueue(opts) {
|
|
|
122
248
|
return flushInFlight;
|
|
123
249
|
},
|
|
124
250
|
flushOnExit: (exitTransport) => {
|
|
251
|
+
const skipId = headInFlightId;
|
|
125
252
|
for (const statement of buffer) {
|
|
253
|
+
if (statement.id === skipId) continue;
|
|
126
254
|
try {
|
|
127
255
|
exitTransport(statement);
|
|
128
256
|
} catch {
|
|
@@ -138,6 +266,64 @@ function createInMemoryXAPIQueue(opts) {
|
|
|
138
266
|
// src/client.ts
|
|
139
267
|
var import_core2 = require("@lessonkit/core");
|
|
140
268
|
|
|
269
|
+
// src/deadLetter.ts
|
|
270
|
+
var STORAGE_KEY = "lk-xapi-dead-letter";
|
|
271
|
+
var MAX_DEAD_LETTER = 200;
|
|
272
|
+
function readStorage() {
|
|
273
|
+
try {
|
|
274
|
+
const storage = globalThis.sessionStorage;
|
|
275
|
+
return storage ?? null;
|
|
276
|
+
} catch {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
function loadDeadLetterStatements() {
|
|
281
|
+
const storage = readStorage();
|
|
282
|
+
if (!storage) return [];
|
|
283
|
+
try {
|
|
284
|
+
const raw = storage.getItem(STORAGE_KEY);
|
|
285
|
+
if (!raw) return [];
|
|
286
|
+
const parsed = JSON.parse(raw);
|
|
287
|
+
if (!Array.isArray(parsed)) return [];
|
|
288
|
+
return parsed.filter(
|
|
289
|
+
(item) => typeof item === "object" && item !== null && typeof item.id === "string"
|
|
290
|
+
);
|
|
291
|
+
} catch {
|
|
292
|
+
return [];
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function persistDeadLetterStatement(statement, opts) {
|
|
296
|
+
const storage = readStorage();
|
|
297
|
+
if (!storage) return;
|
|
298
|
+
try {
|
|
299
|
+
const existing = loadDeadLetterStatements();
|
|
300
|
+
if (existing.some((s) => s.id === statement.id)) return;
|
|
301
|
+
const combined = [...existing, statement];
|
|
302
|
+
if (combined.length > MAX_DEAD_LETTER) {
|
|
303
|
+
opts?.onTruncated?.(combined.length - MAX_DEAD_LETTER);
|
|
304
|
+
}
|
|
305
|
+
const next = combined.slice(-MAX_DEAD_LETTER);
|
|
306
|
+
storage.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
307
|
+
} catch {
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
function removeDeadLetterStatement(id) {
|
|
311
|
+
const storage = readStorage();
|
|
312
|
+
if (!storage) return;
|
|
313
|
+
try {
|
|
314
|
+
const next = loadDeadLetterStatements().filter((s) => s.id !== id);
|
|
315
|
+
if (next.length === 0) {
|
|
316
|
+
storage.removeItem(STORAGE_KEY);
|
|
317
|
+
} else {
|
|
318
|
+
storage.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
319
|
+
}
|
|
320
|
+
} catch {
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
function clearDeadLetterStorage() {
|
|
324
|
+
readStorage()?.removeItem(STORAGE_KEY);
|
|
325
|
+
}
|
|
326
|
+
|
|
141
327
|
// src/telemetryMap.ts
|
|
142
328
|
var import_core = require("@lessonkit/core");
|
|
143
329
|
|
|
@@ -174,9 +360,9 @@ function buildXapiScoreResult(opts) {
|
|
|
174
360
|
}
|
|
175
361
|
return result;
|
|
176
362
|
}
|
|
177
|
-
function statementFor(objectId, verb, timestamp, extra) {
|
|
363
|
+
function statementFor(event, objectId, verb, timestamp, extra) {
|
|
178
364
|
return {
|
|
179
|
-
id:
|
|
365
|
+
id: deriveStatementId(event, objectId, verb),
|
|
180
366
|
timestamp,
|
|
181
367
|
verb,
|
|
182
368
|
object: { id: objectId },
|
|
@@ -184,8 +370,21 @@ function statementFor(objectId, verb, timestamp, extra) {
|
|
|
184
370
|
context: extra?.context
|
|
185
371
|
};
|
|
186
372
|
}
|
|
187
|
-
function
|
|
373
|
+
function sanitizeTelemetryEmbedSrc(src) {
|
|
374
|
+
try {
|
|
375
|
+
const url = new URL(src);
|
|
376
|
+
url.username = "";
|
|
377
|
+
url.password = "";
|
|
378
|
+
url.search = "";
|
|
379
|
+
url.hash = "";
|
|
380
|
+
return `${url.origin}${url.pathname}`;
|
|
381
|
+
} catch {
|
|
382
|
+
return src;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
function experiencedBlockStatement(event, courseId, lessonId, blockId, timestamp) {
|
|
188
386
|
return statementFor(
|
|
387
|
+
event,
|
|
189
388
|
(0, import_core.buildLessonkitUrn)({ courseId, lessonId, blockId }),
|
|
190
389
|
XAPIVerbs.experienced,
|
|
191
390
|
timestamp
|
|
@@ -196,20 +395,39 @@ var experiencedBlockMapper = (event, ctx) => {
|
|
|
196
395
|
const lessonId2 = event.lessonId;
|
|
197
396
|
const blockId2 = event.data?.blockId;
|
|
198
397
|
if (!lessonId2 || !blockId2 || typeof blockId2 !== "string") return null;
|
|
199
|
-
|
|
398
|
+
const kind = event.data?.kind;
|
|
399
|
+
const extensions = {};
|
|
400
|
+
if (kind === "embed_viewed" || kind === "chart_viewed") {
|
|
401
|
+
extensions["https://lessonkit.dev/xapi/interactionKind"] = kind;
|
|
402
|
+
const data = event.data;
|
|
403
|
+
if (kind === "embed_viewed" && data && typeof data.src === "string") {
|
|
404
|
+
extensions["https://lessonkit.dev/xapi/embedSrc"] = sanitizeTelemetryEmbedSrc(data.src);
|
|
405
|
+
}
|
|
406
|
+
if (kind === "chart_viewed" && data && typeof data.chartType === "string") {
|
|
407
|
+
extensions["https://lessonkit.dev/xapi/chartType"] = data.chartType;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return statementFor(
|
|
411
|
+
event,
|
|
412
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: lessonId2, blockId: blockId2 }),
|
|
413
|
+
XAPIVerbs.experienced,
|
|
414
|
+
ctx.timestamp,
|
|
415
|
+
Object.keys(extensions).length > 0 ? { context: { extensions } } : void 0
|
|
416
|
+
);
|
|
200
417
|
}
|
|
201
418
|
const lessonId = event.lessonId;
|
|
202
419
|
const blockId = "data" in event && event.data && "blockId" in event.data ? event.data.blockId : void 0;
|
|
203
420
|
if (!lessonId || !blockId || typeof blockId !== "string") return null;
|
|
204
|
-
return experiencedBlockStatement(ctx.courseId, lessonId, blockId, ctx.timestamp);
|
|
421
|
+
return experiencedBlockStatement(event, ctx.courseId, lessonId, blockId, ctx.timestamp);
|
|
205
422
|
};
|
|
206
423
|
var TELEMETRY_XAPI_MAPPERS = {
|
|
207
|
-
course_started: (
|
|
208
|
-
course_completed: (
|
|
424
|
+
course_started: (event, ctx) => statementFor(event, (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId }), XAPIVerbs.initialized, ctx.timestamp),
|
|
425
|
+
course_completed: (event, ctx) => statementFor(event, (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId }), XAPIVerbs.completed, ctx.timestamp),
|
|
209
426
|
lesson_started: (event, ctx) => {
|
|
210
427
|
const lessonId = event.name === "lesson_started" ? event.lessonId : void 0;
|
|
211
428
|
if (!lessonId) return null;
|
|
212
429
|
return statementFor(
|
|
430
|
+
event,
|
|
213
431
|
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId }),
|
|
214
432
|
XAPIVerbs.initialized,
|
|
215
433
|
ctx.timestamp
|
|
@@ -218,6 +436,7 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
218
436
|
lesson_completed: (event, ctx) => {
|
|
219
437
|
if (event.name !== "lesson_completed") return null;
|
|
220
438
|
const lessonId = event.lessonId;
|
|
439
|
+
if (!lessonId) return null;
|
|
221
440
|
const data = event.data;
|
|
222
441
|
const result = {};
|
|
223
442
|
if (typeof data?.durationMs === "number") {
|
|
@@ -227,9 +446,15 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
227
446
|
if (typeof data?.success === "boolean") result.success = data.success;
|
|
228
447
|
const score = buildXapiScoreResult({ score: data?.score, maxScore: data?.maxScore });
|
|
229
448
|
if (score) result.score = score;
|
|
230
|
-
return statementFor(
|
|
231
|
-
|
|
232
|
-
|
|
449
|
+
return statementFor(
|
|
450
|
+
event,
|
|
451
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId }),
|
|
452
|
+
XAPIVerbs.completed,
|
|
453
|
+
ctx.timestamp,
|
|
454
|
+
{
|
|
455
|
+
result: Object.keys(result).length ? result : void 0
|
|
456
|
+
}
|
|
457
|
+
);
|
|
233
458
|
},
|
|
234
459
|
lesson_time_on_task: () => null,
|
|
235
460
|
quiz_answered: (event, ctx) => {
|
|
@@ -237,6 +462,7 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
237
462
|
const result = {};
|
|
238
463
|
if (typeof event.data.correct === "boolean") result.success = event.data.correct;
|
|
239
464
|
return statementFor(
|
|
465
|
+
event,
|
|
240
466
|
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
241
467
|
XAPIVerbs.answered,
|
|
242
468
|
ctx.timestamp,
|
|
@@ -247,6 +473,7 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
247
473
|
if (event.name !== "quiz_completed") return null;
|
|
248
474
|
const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
|
|
249
475
|
return statementFor(
|
|
476
|
+
event,
|
|
250
477
|
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
251
478
|
XAPIVerbs.completed,
|
|
252
479
|
ctx.timestamp,
|
|
@@ -258,6 +485,7 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
258
485
|
const result = {};
|
|
259
486
|
if (typeof event.data.correct === "boolean") result.success = event.data.correct;
|
|
260
487
|
return statementFor(
|
|
488
|
+
event,
|
|
261
489
|
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
262
490
|
XAPIVerbs.answered,
|
|
263
491
|
ctx.timestamp,
|
|
@@ -268,6 +496,7 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
268
496
|
if (event.name !== "assessment_completed") return null;
|
|
269
497
|
const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
|
|
270
498
|
return statementFor(
|
|
499
|
+
event,
|
|
271
500
|
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
272
501
|
XAPIVerbs.completed,
|
|
273
502
|
ctx.timestamp,
|
|
@@ -289,6 +518,7 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
289
518
|
const blockId = event.data.blockId;
|
|
290
519
|
if (!lessonId || !blockId) return null;
|
|
291
520
|
return statementFor(
|
|
521
|
+
event,
|
|
292
522
|
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId }),
|
|
293
523
|
XAPIVerbs.completed,
|
|
294
524
|
ctx.timestamp
|
|
@@ -303,20 +533,92 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
303
533
|
const blockId = event.data.blockId;
|
|
304
534
|
if (!lessonId || !blockId) return null;
|
|
305
535
|
return statementFor(
|
|
536
|
+
event,
|
|
537
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId }),
|
|
538
|
+
XAPIVerbs.completed,
|
|
539
|
+
ctx.timestamp
|
|
540
|
+
);
|
|
541
|
+
},
|
|
542
|
+
branch_node_viewed: (event, ctx) => {
|
|
543
|
+
if (event.name !== "branch_node_viewed") return null;
|
|
544
|
+
const lessonId = event.lessonId;
|
|
545
|
+
const blockId = event.data.blockId;
|
|
546
|
+
const nodeId = event.data.nodeId;
|
|
547
|
+
if (!lessonId || !blockId || !nodeId) return null;
|
|
548
|
+
return statementFor(
|
|
549
|
+
event,
|
|
550
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId, nodeId }),
|
|
551
|
+
XAPIVerbs.experienced,
|
|
552
|
+
ctx.timestamp
|
|
553
|
+
);
|
|
554
|
+
},
|
|
555
|
+
branch_selected: (event, ctx) => {
|
|
556
|
+
if (event.name !== "branch_selected") return null;
|
|
557
|
+
const lessonId = event.lessonId;
|
|
558
|
+
const blockId = event.data.blockId;
|
|
559
|
+
const toNodeId = event.data.toNodeId;
|
|
560
|
+
if (!lessonId || !blockId || !toNodeId) return null;
|
|
561
|
+
return statementFor(
|
|
562
|
+
event,
|
|
563
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId, nodeId: toNodeId }),
|
|
564
|
+
XAPIVerbs.experienced,
|
|
565
|
+
ctx.timestamp
|
|
566
|
+
);
|
|
567
|
+
},
|
|
568
|
+
image_juxtaposition_changed: experiencedBlockMapper,
|
|
569
|
+
timeline_event_viewed: experiencedBlockMapper,
|
|
570
|
+
image_sequence_changed: experiencedBlockMapper,
|
|
571
|
+
audio_recording_started: experiencedBlockMapper,
|
|
572
|
+
audio_recording_completed: (event, ctx) => {
|
|
573
|
+
if (event.name !== "audio_recording_completed") return null;
|
|
574
|
+
const lessonId = event.lessonId;
|
|
575
|
+
const blockId = event.data.blockId;
|
|
576
|
+
if (!blockId) return null;
|
|
577
|
+
return statementFor(
|
|
578
|
+
event,
|
|
306
579
|
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId }),
|
|
307
580
|
XAPIVerbs.completed,
|
|
308
581
|
ctx.timestamp
|
|
309
582
|
);
|
|
583
|
+
},
|
|
584
|
+
qr_content_revealed: experiencedBlockMapper,
|
|
585
|
+
advent_door_opened: experiencedBlockMapper,
|
|
586
|
+
map_stage_viewed: (event, ctx) => {
|
|
587
|
+
if (event.name !== "map_stage_viewed") return null;
|
|
588
|
+
const lessonId = event.lessonId;
|
|
589
|
+
const blockId = event.data.blockId;
|
|
590
|
+
const stageId = event.data.stageId;
|
|
591
|
+
if (!lessonId || !blockId || !stageId) return null;
|
|
592
|
+
return statementFor(
|
|
593
|
+
event,
|
|
594
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId, nodeId: stageId }),
|
|
595
|
+
XAPIVerbs.experienced,
|
|
596
|
+
ctx.timestamp
|
|
597
|
+
);
|
|
598
|
+
},
|
|
599
|
+
map_exit_selected: (event, ctx) => {
|
|
600
|
+
if (event.name !== "map_exit_selected") return null;
|
|
601
|
+
const lessonId = event.lessonId;
|
|
602
|
+
const blockId = event.data.blockId;
|
|
603
|
+
const toStageId = event.data.toStageId;
|
|
604
|
+
if (!lessonId || !blockId || !toStageId) return null;
|
|
605
|
+
return statementFor(
|
|
606
|
+
event,
|
|
607
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId, nodeId: toStageId }),
|
|
608
|
+
XAPIVerbs.experienced,
|
|
609
|
+
ctx.timestamp
|
|
610
|
+
);
|
|
310
611
|
}
|
|
311
612
|
};
|
|
312
613
|
function telemetryEventToXAPIStatement(event) {
|
|
313
|
-
const
|
|
614
|
+
const enriched = enrichTelemetryEventForXapi(event);
|
|
615
|
+
const mapper = TELEMETRY_XAPI_MAPPERS[enriched.name];
|
|
314
616
|
if (!mapper) {
|
|
315
|
-
throw new Error(`Unhandled telemetry event: ${
|
|
617
|
+
throw new Error(`Unhandled telemetry event: ${enriched.name}`);
|
|
316
618
|
}
|
|
317
|
-
return mapper(
|
|
318
|
-
courseId:
|
|
319
|
-
timestamp:
|
|
619
|
+
return mapper(enriched, {
|
|
620
|
+
courseId: enriched.courseId,
|
|
621
|
+
timestamp: enriched.timestamp
|
|
320
622
|
});
|
|
321
623
|
}
|
|
322
624
|
|
|
@@ -334,26 +636,90 @@ function isDevEnvironment() {
|
|
|
334
636
|
const g = globalThis;
|
|
335
637
|
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
336
638
|
}
|
|
639
|
+
function defaultQueueCapHandler() {
|
|
640
|
+
if (isDevEnvironment()) {
|
|
641
|
+
console.warn("[lessonkit] xAPI queue reached capacity; oldest statement(s) dropped.");
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
function defaultHeadSkippedHandler(_statement, err) {
|
|
645
|
+
if (isDevEnvironment()) {
|
|
646
|
+
console.warn(
|
|
647
|
+
"[lessonkit] xAPI queue skipped statement after repeated transport failures:",
|
|
648
|
+
err instanceof Error ? err.message : err
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
337
652
|
function createXAPIClient(opts) {
|
|
338
653
|
const transport = opts?.transport;
|
|
339
654
|
const exitTransport = opts?.exitTransport;
|
|
340
655
|
const courseId = opts?.courseId;
|
|
341
656
|
const queue = opts?.queue ?? createInMemoryXAPIQueue({
|
|
342
657
|
maxSize: opts?.maxQueueSize,
|
|
658
|
+
maxHeadFailures: opts?.maxHeadFailures,
|
|
343
659
|
onDepth: opts?.onQueueDepth,
|
|
344
|
-
onCap: opts?.onQueueCap
|
|
660
|
+
onCap: opts?.onQueueCap ?? defaultQueueCapHandler,
|
|
661
|
+
onOverflow: (statement) => {
|
|
662
|
+
persistDeadLetterStatement(statement, {
|
|
663
|
+
onTruncated: opts?.onDeadLetterTruncated
|
|
664
|
+
});
|
|
665
|
+
},
|
|
666
|
+
onHeadSkipped: (statement, err) => {
|
|
667
|
+
persistDeadLetterStatement(statement, {
|
|
668
|
+
onTruncated: opts?.onDeadLetterTruncated
|
|
669
|
+
});
|
|
670
|
+
(opts?.onHeadSkipped ?? defaultHeadSkippedHandler)(statement, err);
|
|
671
|
+
}
|
|
345
672
|
});
|
|
346
673
|
let warnedNoTransport = false;
|
|
347
674
|
let warnedTransportFailure = false;
|
|
348
675
|
const inflightById = /* @__PURE__ */ new Map();
|
|
349
676
|
const inflightStatements = /* @__PURE__ */ new Map();
|
|
677
|
+
const pendingReplacement = /* @__PURE__ */ new Map();
|
|
678
|
+
const inflightPayload = /* @__PURE__ */ new Map();
|
|
679
|
+
const replacementWatcher = /* @__PURE__ */ new Set();
|
|
350
680
|
const exitDeliveredIds = /* @__PURE__ */ new Set();
|
|
351
681
|
const exitNetworkSentIds = /* @__PURE__ */ new Set();
|
|
682
|
+
const exitHandoffIds = /* @__PURE__ */ new Set();
|
|
683
|
+
let activeFlush = null;
|
|
684
|
+
for (const statement of loadDeadLetterStatements()) {
|
|
685
|
+
queue.enqueue(statement);
|
|
686
|
+
}
|
|
687
|
+
const hadDeadLetters = queue.size() > 0;
|
|
352
688
|
const deliveryTransport = transport ? async (statement) => {
|
|
353
689
|
if (exitNetworkSentIds.has(statement.id)) return;
|
|
354
690
|
await transport(statement);
|
|
691
|
+
removeDeadLetterStatement(statement.id);
|
|
355
692
|
} : void 0;
|
|
356
|
-
const
|
|
693
|
+
const markExitDelivered = (statement) => {
|
|
694
|
+
exitHandoffIds.delete(statement.id);
|
|
695
|
+
exitDeliveredIds.add(statement.id);
|
|
696
|
+
exitNetworkSentIds.add(statement.id);
|
|
697
|
+
removeDeadLetterStatement(statement.id);
|
|
698
|
+
};
|
|
699
|
+
const dispatchExitStatement = (statement) => {
|
|
700
|
+
if (exitDeliveredIds.has(statement.id)) return;
|
|
701
|
+
exitHandoffIds.add(statement.id);
|
|
702
|
+
try {
|
|
703
|
+
const result = exitTransport(statement);
|
|
704
|
+
if (result != null && typeof result.then === "function") {
|
|
705
|
+
void result.then(
|
|
706
|
+
() => markExitDelivered(statement),
|
|
707
|
+
() => {
|
|
708
|
+
exitHandoffIds.delete(statement.id);
|
|
709
|
+
persistDeadLetterStatement(statement);
|
|
710
|
+
}
|
|
711
|
+
);
|
|
712
|
+
} else {
|
|
713
|
+
markExitDelivered(statement);
|
|
714
|
+
}
|
|
715
|
+
} catch {
|
|
716
|
+
exitHandoffIds.delete(statement.id);
|
|
717
|
+
persistDeadLetterStatement(statement);
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
const pendingDuringFlush = [];
|
|
721
|
+
let flushInProgress = false;
|
|
722
|
+
const sendOrQueueInternal = (statement) => {
|
|
357
723
|
const normalized = withStatementId2(statement);
|
|
358
724
|
if (exitDeliveredIds.has(normalized.id)) return;
|
|
359
725
|
if (!deliveryTransport) {
|
|
@@ -368,20 +734,39 @@ function createXAPIClient(opts) {
|
|
|
368
734
|
}
|
|
369
735
|
const existing = inflightById.get(normalized.id);
|
|
370
736
|
if (existing) {
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
737
|
+
pendingReplacement.set(normalized.id, normalized);
|
|
738
|
+
inflightStatements.set(normalized.id, normalized);
|
|
739
|
+
if (!replacementWatcher.has(normalized.id)) {
|
|
740
|
+
replacementWatcher.add(normalized.id);
|
|
741
|
+
void existing.then(
|
|
742
|
+
() => {
|
|
743
|
+
replacementWatcher.delete(normalized.id);
|
|
744
|
+
const replacement = pendingReplacement.get(normalized.id);
|
|
745
|
+
const transported = inflightPayload.get(normalized.id);
|
|
746
|
+
pendingReplacement.delete(normalized.id);
|
|
747
|
+
inflightPayload.delete(normalized.id);
|
|
748
|
+
if (replacement && replacement !== transported) {
|
|
749
|
+
sendOrQueueInternal(replacement);
|
|
750
|
+
}
|
|
751
|
+
},
|
|
752
|
+
() => {
|
|
753
|
+
replacementWatcher.delete(normalized.id);
|
|
754
|
+
const replacement = pendingReplacement.get(normalized.id) ?? normalized;
|
|
755
|
+
pendingReplacement.delete(normalized.id);
|
|
756
|
+
sendOrQueueInternal(replacement);
|
|
757
|
+
}
|
|
758
|
+
);
|
|
759
|
+
}
|
|
377
760
|
return;
|
|
378
761
|
}
|
|
762
|
+
queue.removeById(normalized.id);
|
|
379
763
|
inflightStatements.set(normalized.id, normalized);
|
|
764
|
+
inflightPayload.set(normalized.id, normalized);
|
|
380
765
|
const flight = Promise.resolve().then(async () => {
|
|
381
766
|
await deliveryTransport(normalized);
|
|
382
767
|
queue.removeById(normalized.id);
|
|
383
768
|
}).catch((err) => {
|
|
384
|
-
if (exitDeliveredIds.has(normalized.id)) return;
|
|
769
|
+
if (exitDeliveredIds.has(normalized.id) || exitHandoffIds.has(normalized.id)) return;
|
|
385
770
|
queue.enqueue(normalized);
|
|
386
771
|
opts?.onTransportError?.(err);
|
|
387
772
|
if (isDevEnvironment() && !warnedTransportFailure) {
|
|
@@ -394,17 +779,28 @@ function createXAPIClient(opts) {
|
|
|
394
779
|
}).finally(() => {
|
|
395
780
|
inflightById.delete(normalized.id);
|
|
396
781
|
inflightStatements.delete(normalized.id);
|
|
782
|
+
if (!replacementWatcher.has(normalized.id)) {
|
|
783
|
+
inflightPayload.delete(normalized.id);
|
|
784
|
+
}
|
|
397
785
|
});
|
|
398
786
|
inflightById.set(normalized.id, flight);
|
|
399
787
|
void flight.catch(() => {
|
|
400
788
|
});
|
|
401
789
|
};
|
|
790
|
+
const sendOrQueue = (statement) => {
|
|
791
|
+
if (flushInProgress) {
|
|
792
|
+
pendingDuringFlush.push(statement);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
sendOrQueueInternal(statement);
|
|
796
|
+
};
|
|
402
797
|
const emit = (event) => {
|
|
403
798
|
try {
|
|
404
799
|
const statement = telemetryEventToXAPIStatement(event);
|
|
405
800
|
if (!statement) return;
|
|
406
801
|
sendOrQueue(statement);
|
|
407
802
|
} catch (err) {
|
|
803
|
+
opts?.onMappingError?.(err);
|
|
408
804
|
if (isDevEnvironment()) {
|
|
409
805
|
console.warn(
|
|
410
806
|
"[lessonkit] xAPI mapping skipped:",
|
|
@@ -413,42 +809,66 @@ function createXAPIClient(opts) {
|
|
|
413
809
|
}
|
|
414
810
|
}
|
|
415
811
|
};
|
|
416
|
-
|
|
812
|
+
const runFlushLoop = async () => {
|
|
813
|
+
if (!deliveryTransport) return;
|
|
814
|
+
for (; ; ) {
|
|
815
|
+
await queue.flush(deliveryTransport);
|
|
816
|
+
const flights = [...inflightById.values()];
|
|
817
|
+
if (flights.length > 0) {
|
|
818
|
+
await Promise.all(flights);
|
|
819
|
+
}
|
|
820
|
+
if (queue.size() === 0 && inflightById.size === 0) break;
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
const client = {
|
|
417
824
|
send: (statement) => {
|
|
418
825
|
sendOrQueue(statement);
|
|
419
826
|
},
|
|
420
827
|
queueSize: () => queue.size(),
|
|
421
828
|
flush: async () => {
|
|
422
829
|
if (!deliveryTransport) return;
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
830
|
+
for (; ; ) {
|
|
831
|
+
if (activeFlush) {
|
|
832
|
+
await activeFlush;
|
|
833
|
+
} else {
|
|
834
|
+
flushInProgress = true;
|
|
835
|
+
activeFlush = (async () => {
|
|
836
|
+
try {
|
|
837
|
+
await runFlushLoop();
|
|
838
|
+
while (pendingDuringFlush.length > 0) {
|
|
839
|
+
const batch = pendingDuringFlush.splice(0, pendingDuringFlush.length);
|
|
840
|
+
for (const pending of batch) {
|
|
841
|
+
sendOrQueueInternal(pending);
|
|
842
|
+
}
|
|
843
|
+
await runFlushLoop();
|
|
844
|
+
}
|
|
845
|
+
} finally {
|
|
846
|
+
flushInProgress = false;
|
|
847
|
+
}
|
|
848
|
+
})().finally(() => {
|
|
849
|
+
activeFlush = null;
|
|
850
|
+
});
|
|
851
|
+
await activeFlush;
|
|
852
|
+
}
|
|
853
|
+
if (queue.size() === 0 && inflightById.size === 0) break;
|
|
430
854
|
}
|
|
431
855
|
},
|
|
432
856
|
flushOnExit: exitTransport ? () => {
|
|
433
857
|
const headId = queue.getHeadInFlightId?.();
|
|
434
858
|
if (headId) {
|
|
435
|
-
exitNetworkSentIds.add(headId);
|
|
436
|
-
exitDeliveredIds.add(headId);
|
|
437
859
|
opts.abortInFlight?.(headId);
|
|
860
|
+
const headStatement = inflightStatements.get(headId);
|
|
861
|
+
if (headStatement) {
|
|
862
|
+
dispatchExitStatement(headStatement);
|
|
863
|
+
}
|
|
438
864
|
}
|
|
439
865
|
for (const statement of inflightStatements.values()) {
|
|
440
|
-
|
|
441
|
-
exitDeliveredIds.add(statement.id);
|
|
866
|
+
if (statement.id === headId) continue;
|
|
442
867
|
opts.abortInFlight?.(statement.id);
|
|
868
|
+
dispatchExitStatement(statement);
|
|
443
869
|
}
|
|
444
870
|
queue.flushOnExit((statement) => {
|
|
445
|
-
|
|
446
|
-
exitNetworkSentIds.add(statement.id);
|
|
447
|
-
exitDeliveredIds.add(statement.id);
|
|
448
|
-
try {
|
|
449
|
-
exitTransport(statement);
|
|
450
|
-
} catch {
|
|
451
|
-
}
|
|
871
|
+
dispatchExitStatement(statement);
|
|
452
872
|
});
|
|
453
873
|
} : void 0,
|
|
454
874
|
startedLesson: ({ lessonId }) => {
|
|
@@ -486,6 +906,98 @@ function createXAPIClient(opts) {
|
|
|
486
906
|
});
|
|
487
907
|
}
|
|
488
908
|
};
|
|
909
|
+
if (hadDeadLetters && deliveryTransport) {
|
|
910
|
+
queueMicrotask(() => {
|
|
911
|
+
void client.flush().catch(() => void 0);
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
return client;
|
|
915
|
+
}
|
|
916
|
+
function resetXAPIDeadLetterForTests() {
|
|
917
|
+
clearDeadLetterStorage();
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// src/safeLrsUrl.ts
|
|
921
|
+
var import_meta = {};
|
|
922
|
+
function isProductionRuntime() {
|
|
923
|
+
try {
|
|
924
|
+
if (import_meta.env?.PROD === true) return true;
|
|
925
|
+
} catch {
|
|
926
|
+
}
|
|
927
|
+
const g = globalThis;
|
|
928
|
+
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production";
|
|
929
|
+
}
|
|
930
|
+
function parseHostname(url) {
|
|
931
|
+
return url.hostname.replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
|
|
932
|
+
}
|
|
933
|
+
function isIpv4MappedAddress(hostname) {
|
|
934
|
+
const match = hostname.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
|
935
|
+
return match?.[1] ?? null;
|
|
936
|
+
}
|
|
937
|
+
function isLoopbackHost(hostname) {
|
|
938
|
+
const ipv4Mapped = isIpv4MappedAddress(hostname);
|
|
939
|
+
if (ipv4Mapped) return isLoopbackHost(ipv4Mapped);
|
|
940
|
+
return hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "127.0.0.1" || hostname === "::1" || hostname === "0.0.0.0";
|
|
941
|
+
}
|
|
942
|
+
function isLinkLocalOrMetadataHost(hostname) {
|
|
943
|
+
if (hostname === "169.254.169.254") return true;
|
|
944
|
+
if (/^169\.254\./.test(hostname)) return true;
|
|
945
|
+
if (/^fe80:/i.test(hostname)) return true;
|
|
946
|
+
return false;
|
|
947
|
+
}
|
|
948
|
+
function isRfc1918Host(hostname) {
|
|
949
|
+
const ipv4Mapped = isIpv4MappedAddress(hostname);
|
|
950
|
+
if (ipv4Mapped) return isRfc1918Host(ipv4Mapped);
|
|
951
|
+
if (/^10\./.test(hostname)) return true;
|
|
952
|
+
if (/^192\.168\./.test(hostname)) return true;
|
|
953
|
+
const parts = hostname.split(".").map(Number);
|
|
954
|
+
if (parts.length === 4 && parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
|
|
955
|
+
return false;
|
|
956
|
+
}
|
|
957
|
+
function isPrivateOrMetadataHost(hostname) {
|
|
958
|
+
return isLoopbackHost(hostname) || isLinkLocalOrMetadataHost(hostname) || isRfc1918Host(hostname);
|
|
959
|
+
}
|
|
960
|
+
function containsPathTraversal(path) {
|
|
961
|
+
if (path.includes("..")) return true;
|
|
962
|
+
let decoded = path;
|
|
963
|
+
for (let i = 0; i < 2; i++) {
|
|
964
|
+
try {
|
|
965
|
+
const next = decodeURIComponent(decoded.replace(/\+/g, " "));
|
|
966
|
+
if (next.includes("..")) return true;
|
|
967
|
+
if (next === decoded) break;
|
|
968
|
+
decoded = next;
|
|
969
|
+
} catch {
|
|
970
|
+
break;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
return false;
|
|
974
|
+
}
|
|
975
|
+
function assertSafeLrsUrl(url, opts) {
|
|
976
|
+
if (url.startsWith("/")) {
|
|
977
|
+
if (containsPathTraversal(url)) {
|
|
978
|
+
throw new Error(`Unsafe LRS URL: path traversal in "${url}"`);
|
|
979
|
+
}
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
let parsed;
|
|
983
|
+
try {
|
|
984
|
+
parsed = new URL(url);
|
|
985
|
+
} catch {
|
|
986
|
+
throw new Error(`Unsafe LRS URL: invalid URL "${url}"`);
|
|
987
|
+
}
|
|
988
|
+
if (containsPathTraversal(parsed.pathname)) {
|
|
989
|
+
throw new Error(`Unsafe LRS URL: path traversal in "${url}"`);
|
|
990
|
+
}
|
|
991
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
992
|
+
throw new Error(`Unsafe LRS URL: unsupported scheme "${parsed.protocol}"`);
|
|
993
|
+
}
|
|
994
|
+
if (isProductionRuntime() && parsed.protocol !== "https:") {
|
|
995
|
+
throw new Error("Unsafe LRS URL: HTTPS is required in production builds");
|
|
996
|
+
}
|
|
997
|
+
const hostname = parseHostname(parsed);
|
|
998
|
+
if (!opts?.allowPrivateHosts && isPrivateOrMetadataHost(hostname)) {
|
|
999
|
+
throw new Error(`Unsafe LRS URL: private or metadata host "${hostname}" is not allowed`);
|
|
1000
|
+
}
|
|
489
1001
|
}
|
|
490
1002
|
|
|
491
1003
|
// src/fetchTransport.ts
|
|
@@ -555,6 +1067,7 @@ async function postWithRetry(post, retries, initialBackoffMs, maxBackoffMs) {
|
|
|
555
1067
|
}
|
|
556
1068
|
}
|
|
557
1069
|
function createFetchTransport(opts) {
|
|
1070
|
+
assertSafeLrsUrl(opts.url, { allowPrivateHosts: opts.allowPrivateHosts });
|
|
558
1071
|
const timeoutMs = opts.timeoutMs ?? 3e4;
|
|
559
1072
|
const rawRetries = opts.retries ?? 2;
|
|
560
1073
|
const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
|
|
@@ -586,14 +1099,13 @@ function createFetchTransport(opts) {
|
|
|
586
1099
|
}
|
|
587
1100
|
};
|
|
588
1101
|
const exitTransport = (statement) => {
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
}
|
|
596
|
-
}
|
|
1102
|
+
return postStatement(opts.url, statement, {
|
|
1103
|
+
...opts.init,
|
|
1104
|
+
headers: resolveHeaders(opts.headers),
|
|
1105
|
+
keepalive: true
|
|
1106
|
+
}).catch(() => {
|
|
1107
|
+
throw new Error("xAPI keepalive delivery failed");
|
|
1108
|
+
});
|
|
597
1109
|
};
|
|
598
1110
|
const abortInFlight = (statementId) => {
|
|
599
1111
|
activeControllers.get(statementId)?.abort();
|
|
@@ -602,6 +1114,7 @@ function createFetchTransport(opts) {
|
|
|
602
1114
|
return { transport, exitTransport, abortInFlight };
|
|
603
1115
|
}
|
|
604
1116
|
function createFetchBatchSink(opts) {
|
|
1117
|
+
assertSafeLrsUrl(opts.url, { allowPrivateHosts: opts.allowPrivateHosts });
|
|
605
1118
|
const timeoutMs = opts.timeoutMs ?? 3e4;
|
|
606
1119
|
const rawRetries = opts.retries ?? 2;
|
|
607
1120
|
const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
|
|
@@ -625,27 +1138,32 @@ function createFetchBatchSink(opts) {
|
|
|
625
1138
|
return {
|
|
626
1139
|
batchSink: (events) => postBatch(events, opts.init ?? {}),
|
|
627
1140
|
exitBatchSink: (events) => {
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
1141
|
+
return fetch(opts.url, {
|
|
1142
|
+
method: "POST",
|
|
1143
|
+
body: JSON.stringify(events),
|
|
1144
|
+
...opts.init,
|
|
1145
|
+
headers: resolveHeaders(opts.headers),
|
|
1146
|
+
keepalive: true
|
|
1147
|
+
}).then((res) => {
|
|
1148
|
+
if (!res.ok) {
|
|
1149
|
+
throw new FetchHttpError(res.status, res.statusText, "batch");
|
|
1150
|
+
}
|
|
1151
|
+
});
|
|
638
1152
|
}
|
|
639
1153
|
};
|
|
640
1154
|
}
|
|
641
1155
|
// Annotate the CommonJS export names for ESM import in node:
|
|
642
1156
|
0 && (module.exports = {
|
|
643
1157
|
FetchHttpError,
|
|
1158
|
+
assertSafeLrsUrl,
|
|
644
1159
|
createFetchBatchSink,
|
|
645
1160
|
createFetchTransport,
|
|
646
1161
|
createInMemoryXAPIQueue,
|
|
647
1162
|
createXAPIClient,
|
|
648
1163
|
isRetryableFetchError,
|
|
649
1164
|
isRetryableFetchHttpStatus,
|
|
1165
|
+
loadDeadLetterStatements,
|
|
1166
|
+
persistDeadLetterStatement,
|
|
1167
|
+
resetXAPIDeadLetterForTests,
|
|
650
1168
|
telemetryEventToXAPIStatement
|
|
651
1169
|
});
|