@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.cjs
CHANGED
|
@@ -20,19 +20,134 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/index.ts
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
|
+
FetchHttpError: () => FetchHttpError,
|
|
24
|
+
assertSafeLrsUrl: () => assertSafeLrsUrl,
|
|
23
25
|
createFetchBatchSink: () => createFetchBatchSink,
|
|
24
26
|
createFetchTransport: () => createFetchTransport,
|
|
25
27
|
createInMemoryXAPIQueue: () => createInMemoryXAPIQueue,
|
|
26
28
|
createXAPIClient: () => createXAPIClient,
|
|
29
|
+
isRetryableFetchError: () => isRetryableFetchError,
|
|
30
|
+
isRetryableFetchHttpStatus: () => isRetryableFetchHttpStatus,
|
|
31
|
+
loadDeadLetterStatements: () => loadDeadLetterStatements,
|
|
32
|
+
persistDeadLetterStatement: () => persistDeadLetterStatement,
|
|
33
|
+
resetXAPIDeadLetterForTests: () => resetXAPIDeadLetterForTests,
|
|
27
34
|
telemetryEventToXAPIStatement: () => telemetryEventToXAPIStatement
|
|
28
35
|
});
|
|
29
36
|
module.exports = __toCommonJS(index_exports);
|
|
30
37
|
|
|
31
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
|
+
}
|
|
32
86
|
function cryptoRandomId() {
|
|
33
87
|
const g = globalThis;
|
|
34
88
|
if (g.crypto?.randomUUID) return g.crypto.randomUUID();
|
|
35
|
-
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);
|
|
36
151
|
}
|
|
37
152
|
|
|
38
153
|
// src/queue.ts
|
|
@@ -46,11 +161,15 @@ function withStatementId(statement) {
|
|
|
46
161
|
return statement;
|
|
47
162
|
}
|
|
48
163
|
var DEFAULT_MAX_QUEUE_SIZE = 1e3;
|
|
164
|
+
var DEFAULT_MAX_HEAD_FAILURES = 10;
|
|
49
165
|
function createInMemoryXAPIQueue(opts) {
|
|
50
166
|
const maxSize = opts?.maxSize ?? DEFAULT_MAX_QUEUE_SIZE;
|
|
167
|
+
const maxHeadFailures = opts?.maxHeadFailures ?? DEFAULT_MAX_HEAD_FAILURES;
|
|
51
168
|
const buffer = [];
|
|
52
169
|
let flushInFlight = null;
|
|
53
170
|
let headInFlight = false;
|
|
171
|
+
let headInFlightId;
|
|
172
|
+
let headFailureCount = 0;
|
|
54
173
|
const notifyDepth = () => {
|
|
55
174
|
opts?.onDepth?.(buffer.length);
|
|
56
175
|
};
|
|
@@ -65,32 +184,51 @@ function createInMemoryXAPIQueue(opts) {
|
|
|
65
184
|
while (buffer.length) {
|
|
66
185
|
const statement = buffer[0];
|
|
67
186
|
headInFlight = true;
|
|
187
|
+
headInFlightId = statement.id;
|
|
68
188
|
try {
|
|
69
189
|
await transport(statement);
|
|
70
190
|
buffer.shift();
|
|
191
|
+
headFailureCount = 0;
|
|
71
192
|
notifyDepth();
|
|
72
|
-
} catch {
|
|
73
|
-
|
|
193
|
+
} catch (err) {
|
|
194
|
+
headFailureCount += 1;
|
|
195
|
+
if (headFailureCount >= maxHeadFailures) {
|
|
196
|
+
buffer.shift();
|
|
197
|
+
headFailureCount = 0;
|
|
198
|
+
notifyDepth();
|
|
199
|
+
opts?.onHeadSkipped?.(statement, err);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
throw err;
|
|
74
203
|
} finally {
|
|
75
204
|
headInFlight = false;
|
|
205
|
+
headInFlightId = void 0;
|
|
76
206
|
}
|
|
77
207
|
}
|
|
78
208
|
};
|
|
79
209
|
return {
|
|
80
210
|
enqueue: (statement) => {
|
|
81
211
|
const normalized = withStatementId(statement);
|
|
82
|
-
|
|
212
|
+
const existingIdx = buffer.findIndex((s) => s.id === normalized.id);
|
|
213
|
+
if (existingIdx >= 0) {
|
|
214
|
+
buffer[existingIdx] = normalized;
|
|
215
|
+
notifyDepth();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
83
218
|
if (buffer.length >= maxSize) {
|
|
84
|
-
if (headInFlight && buffer.length <= 1) {
|
|
85
|
-
opts?.onCap?.();
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
219
|
if (headInFlight) {
|
|
89
|
-
buffer.
|
|
220
|
+
if (buffer.length > 1) {
|
|
221
|
+
buffer.splice(1, 1);
|
|
222
|
+
opts?.onCap?.();
|
|
223
|
+
} else {
|
|
224
|
+
opts?.onCap?.();
|
|
225
|
+
opts?.onOverflow?.(normalized);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
90
228
|
} else {
|
|
91
229
|
buffer.shift();
|
|
230
|
+
opts?.onCap?.();
|
|
92
231
|
}
|
|
93
|
-
opts?.onCap?.();
|
|
94
232
|
}
|
|
95
233
|
buffer.push(normalized);
|
|
96
234
|
notifyDepth();
|
|
@@ -106,27 +244,76 @@ function createInMemoryXAPIQueue(opts) {
|
|
|
106
244
|
return flushInFlight;
|
|
107
245
|
},
|
|
108
246
|
flushOnExit: (exitTransport) => {
|
|
109
|
-
const
|
|
110
|
-
for (let i = startIdx; i < buffer.length; i++) {
|
|
111
|
-
const statement = buffer[i];
|
|
247
|
+
for (const statement of buffer) {
|
|
112
248
|
try {
|
|
113
249
|
exitTransport(statement);
|
|
114
250
|
} catch {
|
|
115
251
|
}
|
|
116
252
|
}
|
|
117
|
-
|
|
118
|
-
buffer.length = 0;
|
|
119
|
-
} else if (buffer.length > 1) {
|
|
120
|
-
buffer.splice(1);
|
|
121
|
-
}
|
|
253
|
+
buffer.length = 0;
|
|
122
254
|
notifyDepth();
|
|
123
|
-
}
|
|
255
|
+
},
|
|
256
|
+
getHeadInFlightId: () => headInFlightId
|
|
124
257
|
};
|
|
125
258
|
}
|
|
126
259
|
|
|
127
260
|
// src/client.ts
|
|
128
261
|
var import_core2 = require("@lessonkit/core");
|
|
129
262
|
|
|
263
|
+
// src/deadLetter.ts
|
|
264
|
+
var STORAGE_KEY = "lk-xapi-dead-letter";
|
|
265
|
+
var MAX_DEAD_LETTER = 200;
|
|
266
|
+
function readStorage() {
|
|
267
|
+
try {
|
|
268
|
+
const storage = globalThis.sessionStorage;
|
|
269
|
+
return storage ?? null;
|
|
270
|
+
} catch {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
function loadDeadLetterStatements() {
|
|
275
|
+
const storage = readStorage();
|
|
276
|
+
if (!storage) return [];
|
|
277
|
+
try {
|
|
278
|
+
const raw = storage.getItem(STORAGE_KEY);
|
|
279
|
+
if (!raw) return [];
|
|
280
|
+
const parsed = JSON.parse(raw);
|
|
281
|
+
if (!Array.isArray(parsed)) return [];
|
|
282
|
+
return parsed.filter(
|
|
283
|
+
(item) => typeof item === "object" && item !== null && typeof item.id === "string"
|
|
284
|
+
);
|
|
285
|
+
} catch {
|
|
286
|
+
return [];
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
function persistDeadLetterStatement(statement) {
|
|
290
|
+
const storage = readStorage();
|
|
291
|
+
if (!storage) return;
|
|
292
|
+
try {
|
|
293
|
+
const existing = loadDeadLetterStatements();
|
|
294
|
+
if (existing.some((s) => s.id === statement.id)) return;
|
|
295
|
+
const next = [...existing, statement].slice(-MAX_DEAD_LETTER);
|
|
296
|
+
storage.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
297
|
+
} catch {
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function removeDeadLetterStatement(id) {
|
|
301
|
+
const storage = readStorage();
|
|
302
|
+
if (!storage) return;
|
|
303
|
+
try {
|
|
304
|
+
const next = loadDeadLetterStatements().filter((s) => s.id !== id);
|
|
305
|
+
if (next.length === 0) {
|
|
306
|
+
storage.removeItem(STORAGE_KEY);
|
|
307
|
+
} else {
|
|
308
|
+
storage.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
309
|
+
}
|
|
310
|
+
} catch {
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
function clearDeadLetterStorage() {
|
|
314
|
+
readStorage()?.removeItem(STORAGE_KEY);
|
|
315
|
+
}
|
|
316
|
+
|
|
130
317
|
// src/telemetryMap.ts
|
|
131
318
|
var import_core = require("@lessonkit/core");
|
|
132
319
|
|
|
@@ -163,9 +350,9 @@ function buildXapiScoreResult(opts) {
|
|
|
163
350
|
}
|
|
164
351
|
return result;
|
|
165
352
|
}
|
|
166
|
-
function statementFor(objectId, verb, timestamp, extra) {
|
|
353
|
+
function statementFor(event, objectId, verb, timestamp, extra) {
|
|
167
354
|
return {
|
|
168
|
-
id:
|
|
355
|
+
id: deriveStatementId(event, objectId, verb),
|
|
169
356
|
timestamp,
|
|
170
357
|
verb,
|
|
171
358
|
object: { id: objectId },
|
|
@@ -173,8 +360,21 @@ function statementFor(objectId, verb, timestamp, extra) {
|
|
|
173
360
|
context: extra?.context
|
|
174
361
|
};
|
|
175
362
|
}
|
|
176
|
-
function
|
|
363
|
+
function sanitizeTelemetryEmbedSrc(src) {
|
|
364
|
+
try {
|
|
365
|
+
const url = new URL(src);
|
|
366
|
+
url.username = "";
|
|
367
|
+
url.password = "";
|
|
368
|
+
url.search = "";
|
|
369
|
+
url.hash = "";
|
|
370
|
+
return `${url.origin}${url.pathname}`;
|
|
371
|
+
} catch {
|
|
372
|
+
return src;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function experiencedBlockStatement(event, courseId, lessonId, blockId, timestamp) {
|
|
177
376
|
return statementFor(
|
|
377
|
+
event,
|
|
178
378
|
(0, import_core.buildLessonkitUrn)({ courseId, lessonId, blockId }),
|
|
179
379
|
XAPIVerbs.experienced,
|
|
180
380
|
timestamp
|
|
@@ -185,20 +385,39 @@ var experiencedBlockMapper = (event, ctx) => {
|
|
|
185
385
|
const lessonId2 = event.lessonId;
|
|
186
386
|
const blockId2 = event.data?.blockId;
|
|
187
387
|
if (!lessonId2 || !blockId2 || typeof blockId2 !== "string") return null;
|
|
188
|
-
|
|
388
|
+
const kind = event.data?.kind;
|
|
389
|
+
const extensions = {};
|
|
390
|
+
if (kind === "embed_viewed" || kind === "chart_viewed") {
|
|
391
|
+
extensions["https://lessonkit.dev/xapi/interactionKind"] = kind;
|
|
392
|
+
const data = event.data;
|
|
393
|
+
if (kind === "embed_viewed" && data && typeof data.src === "string") {
|
|
394
|
+
extensions["https://lessonkit.dev/xapi/embedSrc"] = sanitizeTelemetryEmbedSrc(data.src);
|
|
395
|
+
}
|
|
396
|
+
if (kind === "chart_viewed" && data && typeof data.chartType === "string") {
|
|
397
|
+
extensions["https://lessonkit.dev/xapi/chartType"] = data.chartType;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return statementFor(
|
|
401
|
+
event,
|
|
402
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: lessonId2, blockId: blockId2 }),
|
|
403
|
+
XAPIVerbs.experienced,
|
|
404
|
+
ctx.timestamp,
|
|
405
|
+
Object.keys(extensions).length > 0 ? { context: { extensions } } : void 0
|
|
406
|
+
);
|
|
189
407
|
}
|
|
190
408
|
const lessonId = event.lessonId;
|
|
191
409
|
const blockId = "data" in event && event.data && "blockId" in event.data ? event.data.blockId : void 0;
|
|
192
410
|
if (!lessonId || !blockId || typeof blockId !== "string") return null;
|
|
193
|
-
return experiencedBlockStatement(ctx.courseId, lessonId, blockId, ctx.timestamp);
|
|
411
|
+
return experiencedBlockStatement(event, ctx.courseId, lessonId, blockId, ctx.timestamp);
|
|
194
412
|
};
|
|
195
413
|
var TELEMETRY_XAPI_MAPPERS = {
|
|
196
|
-
course_started: (
|
|
197
|
-
course_completed: (
|
|
414
|
+
course_started: (event, ctx) => statementFor(event, (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId }), XAPIVerbs.initialized, ctx.timestamp),
|
|
415
|
+
course_completed: (event, ctx) => statementFor(event, (0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId }), XAPIVerbs.completed, ctx.timestamp),
|
|
198
416
|
lesson_started: (event, ctx) => {
|
|
199
417
|
const lessonId = event.name === "lesson_started" ? event.lessonId : void 0;
|
|
200
418
|
if (!lessonId) return null;
|
|
201
419
|
return statementFor(
|
|
420
|
+
event,
|
|
202
421
|
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId }),
|
|
203
422
|
XAPIVerbs.initialized,
|
|
204
423
|
ctx.timestamp
|
|
@@ -216,9 +435,15 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
216
435
|
if (typeof data?.success === "boolean") result.success = data.success;
|
|
217
436
|
const score = buildXapiScoreResult({ score: data?.score, maxScore: data?.maxScore });
|
|
218
437
|
if (score) result.score = score;
|
|
219
|
-
return statementFor(
|
|
220
|
-
|
|
221
|
-
|
|
438
|
+
return statementFor(
|
|
439
|
+
event,
|
|
440
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId }),
|
|
441
|
+
XAPIVerbs.completed,
|
|
442
|
+
ctx.timestamp,
|
|
443
|
+
{
|
|
444
|
+
result: Object.keys(result).length ? result : void 0
|
|
445
|
+
}
|
|
446
|
+
);
|
|
222
447
|
},
|
|
223
448
|
lesson_time_on_task: () => null,
|
|
224
449
|
quiz_answered: (event, ctx) => {
|
|
@@ -226,6 +451,7 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
226
451
|
const result = {};
|
|
227
452
|
if (typeof event.data.correct === "boolean") result.success = event.data.correct;
|
|
228
453
|
return statementFor(
|
|
454
|
+
event,
|
|
229
455
|
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
230
456
|
XAPIVerbs.answered,
|
|
231
457
|
ctx.timestamp,
|
|
@@ -236,6 +462,7 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
236
462
|
if (event.name !== "quiz_completed") return null;
|
|
237
463
|
const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
|
|
238
464
|
return statementFor(
|
|
465
|
+
event,
|
|
239
466
|
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
240
467
|
XAPIVerbs.completed,
|
|
241
468
|
ctx.timestamp,
|
|
@@ -247,6 +474,7 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
247
474
|
const result = {};
|
|
248
475
|
if (typeof event.data.correct === "boolean") result.success = event.data.correct;
|
|
249
476
|
return statementFor(
|
|
477
|
+
event,
|
|
250
478
|
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
251
479
|
XAPIVerbs.answered,
|
|
252
480
|
ctx.timestamp,
|
|
@@ -257,6 +485,7 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
257
485
|
if (event.name !== "assessment_completed") return null;
|
|
258
486
|
const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
|
|
259
487
|
return statementFor(
|
|
488
|
+
event,
|
|
260
489
|
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
261
490
|
XAPIVerbs.completed,
|
|
262
491
|
ctx.timestamp,
|
|
@@ -270,16 +499,71 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
270
499
|
hotspot_opened: experiencedBlockMapper,
|
|
271
500
|
accordion_section_toggled: experiencedBlockMapper,
|
|
272
501
|
flashcard_flipped: experiencedBlockMapper,
|
|
273
|
-
image_slider_changed: experiencedBlockMapper
|
|
502
|
+
image_slider_changed: experiencedBlockMapper,
|
|
503
|
+
video_cue_reached: experiencedBlockMapper,
|
|
504
|
+
video_segment_completed: (event, ctx) => {
|
|
505
|
+
if (event.name !== "video_segment_completed") return null;
|
|
506
|
+
const lessonId = event.lessonId;
|
|
507
|
+
const blockId = event.data.blockId;
|
|
508
|
+
if (!lessonId || !blockId) return null;
|
|
509
|
+
return statementFor(
|
|
510
|
+
event,
|
|
511
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId }),
|
|
512
|
+
XAPIVerbs.completed,
|
|
513
|
+
ctx.timestamp
|
|
514
|
+
);
|
|
515
|
+
},
|
|
516
|
+
memory_card_flipped: experiencedBlockMapper,
|
|
517
|
+
information_wall_search: experiencedBlockMapper,
|
|
518
|
+
parallax_slide_viewed: experiencedBlockMapper,
|
|
519
|
+
questionnaire_submitted: (event, ctx) => {
|
|
520
|
+
if (event.name !== "questionnaire_submitted") return null;
|
|
521
|
+
const lessonId = event.lessonId;
|
|
522
|
+
const blockId = event.data.blockId;
|
|
523
|
+
if (!lessonId || !blockId) return null;
|
|
524
|
+
return statementFor(
|
|
525
|
+
event,
|
|
526
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId }),
|
|
527
|
+
XAPIVerbs.completed,
|
|
528
|
+
ctx.timestamp
|
|
529
|
+
);
|
|
530
|
+
},
|
|
531
|
+
branch_node_viewed: (event, ctx) => {
|
|
532
|
+
if (event.name !== "branch_node_viewed") return null;
|
|
533
|
+
const lessonId = event.lessonId;
|
|
534
|
+
const blockId = event.data.blockId;
|
|
535
|
+
const nodeId = event.data.nodeId;
|
|
536
|
+
if (!lessonId || !blockId || !nodeId) return null;
|
|
537
|
+
return statementFor(
|
|
538
|
+
event,
|
|
539
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId, nodeId }),
|
|
540
|
+
XAPIVerbs.experienced,
|
|
541
|
+
ctx.timestamp
|
|
542
|
+
);
|
|
543
|
+
},
|
|
544
|
+
branch_selected: (event, ctx) => {
|
|
545
|
+
if (event.name !== "branch_selected") return null;
|
|
546
|
+
const lessonId = event.lessonId;
|
|
547
|
+
const blockId = event.data.blockId;
|
|
548
|
+
const toNodeId = event.data.toNodeId;
|
|
549
|
+
if (!lessonId || !blockId || !toNodeId) return null;
|
|
550
|
+
return statementFor(
|
|
551
|
+
event,
|
|
552
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId, nodeId: toNodeId }),
|
|
553
|
+
XAPIVerbs.experienced,
|
|
554
|
+
ctx.timestamp
|
|
555
|
+
);
|
|
556
|
+
}
|
|
274
557
|
};
|
|
275
558
|
function telemetryEventToXAPIStatement(event) {
|
|
276
|
-
const
|
|
559
|
+
const enriched = enrichTelemetryEventForXapi(event);
|
|
560
|
+
const mapper = TELEMETRY_XAPI_MAPPERS[enriched.name];
|
|
277
561
|
if (!mapper) {
|
|
278
|
-
throw new Error(`Unhandled telemetry event: ${
|
|
562
|
+
throw new Error(`Unhandled telemetry event: ${enriched.name}`);
|
|
279
563
|
}
|
|
280
|
-
return mapper(
|
|
281
|
-
courseId:
|
|
282
|
-
timestamp:
|
|
564
|
+
return mapper(enriched, {
|
|
565
|
+
courseId: enriched.courseId,
|
|
566
|
+
timestamp: enriched.timestamp
|
|
283
567
|
});
|
|
284
568
|
}
|
|
285
569
|
|
|
@@ -297,22 +581,89 @@ function isDevEnvironment() {
|
|
|
297
581
|
const g = globalThis;
|
|
298
582
|
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
299
583
|
}
|
|
584
|
+
function defaultQueueCapHandler() {
|
|
585
|
+
if (isDevEnvironment()) {
|
|
586
|
+
console.warn("[lessonkit] xAPI queue reached capacity; oldest statement(s) dropped.");
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
function defaultHeadSkippedHandler(_statement, err) {
|
|
590
|
+
if (isDevEnvironment()) {
|
|
591
|
+
console.warn(
|
|
592
|
+
"[lessonkit] xAPI queue skipped statement after repeated transport failures:",
|
|
593
|
+
err instanceof Error ? err.message : err
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
300
597
|
function createXAPIClient(opts) {
|
|
301
598
|
const transport = opts?.transport;
|
|
302
599
|
const exitTransport = opts?.exitTransport;
|
|
303
600
|
const courseId = opts?.courseId;
|
|
304
601
|
const queue = opts?.queue ?? createInMemoryXAPIQueue({
|
|
305
602
|
maxSize: opts?.maxQueueSize,
|
|
603
|
+
maxHeadFailures: opts?.maxHeadFailures,
|
|
306
604
|
onDepth: opts?.onQueueDepth,
|
|
307
|
-
onCap: opts?.onQueueCap
|
|
605
|
+
onCap: opts?.onQueueCap ?? defaultQueueCapHandler,
|
|
606
|
+
onOverflow: (statement) => {
|
|
607
|
+
persistDeadLetterStatement(statement);
|
|
608
|
+
},
|
|
609
|
+
onHeadSkipped: (statement, err) => {
|
|
610
|
+
persistDeadLetterStatement(statement);
|
|
611
|
+
(opts?.onHeadSkipped ?? defaultHeadSkippedHandler)(statement, err);
|
|
612
|
+
}
|
|
308
613
|
});
|
|
309
614
|
let warnedNoTransport = false;
|
|
310
615
|
let warnedTransportFailure = false;
|
|
311
616
|
const inflightById = /* @__PURE__ */ new Map();
|
|
312
617
|
const inflightStatements = /* @__PURE__ */ new Map();
|
|
313
|
-
const
|
|
618
|
+
const pendingReplacement = /* @__PURE__ */ new Map();
|
|
619
|
+
const inflightPayload = /* @__PURE__ */ new Map();
|
|
620
|
+
const replacementWatcher = /* @__PURE__ */ new Set();
|
|
621
|
+
const exitDeliveredIds = /* @__PURE__ */ new Set();
|
|
622
|
+
const exitNetworkSentIds = /* @__PURE__ */ new Set();
|
|
623
|
+
const exitHandoffIds = /* @__PURE__ */ new Set();
|
|
624
|
+
let activeFlush = null;
|
|
625
|
+
for (const statement of loadDeadLetterStatements()) {
|
|
626
|
+
queue.enqueue(statement);
|
|
627
|
+
}
|
|
628
|
+
const hadDeadLetters = queue.size() > 0;
|
|
629
|
+
const deliveryTransport = transport ? async (statement) => {
|
|
630
|
+
if (exitNetworkSentIds.has(statement.id)) return;
|
|
631
|
+
await transport(statement);
|
|
632
|
+
removeDeadLetterStatement(statement.id);
|
|
633
|
+
} : void 0;
|
|
634
|
+
const markExitDelivered = (statement) => {
|
|
635
|
+
exitHandoffIds.delete(statement.id);
|
|
636
|
+
exitDeliveredIds.add(statement.id);
|
|
637
|
+
exitNetworkSentIds.add(statement.id);
|
|
638
|
+
removeDeadLetterStatement(statement.id);
|
|
639
|
+
};
|
|
640
|
+
const dispatchExitStatement = (statement) => {
|
|
641
|
+
if (exitDeliveredIds.has(statement.id)) return;
|
|
642
|
+
exitHandoffIds.add(statement.id);
|
|
643
|
+
try {
|
|
644
|
+
const result = exitTransport(statement);
|
|
645
|
+
if (result != null && typeof result.then === "function") {
|
|
646
|
+
void result.then(
|
|
647
|
+
() => markExitDelivered(statement),
|
|
648
|
+
() => {
|
|
649
|
+
exitHandoffIds.delete(statement.id);
|
|
650
|
+
persistDeadLetterStatement(statement);
|
|
651
|
+
}
|
|
652
|
+
);
|
|
653
|
+
} else {
|
|
654
|
+
markExitDelivered(statement);
|
|
655
|
+
}
|
|
656
|
+
} catch {
|
|
657
|
+
exitHandoffIds.delete(statement.id);
|
|
658
|
+
persistDeadLetterStatement(statement);
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
const pendingDuringFlush = [];
|
|
662
|
+
let flushInProgress = false;
|
|
663
|
+
const sendOrQueueInternal = (statement) => {
|
|
314
664
|
const normalized = withStatementId2(statement);
|
|
315
|
-
if (
|
|
665
|
+
if (exitDeliveredIds.has(normalized.id)) return;
|
|
666
|
+
if (!deliveryTransport) {
|
|
316
667
|
queue.enqueue(normalized);
|
|
317
668
|
if (isDevEnvironment() && !warnedNoTransport) {
|
|
318
669
|
warnedNoTransport = true;
|
|
@@ -324,41 +675,72 @@ function createXAPIClient(opts) {
|
|
|
324
675
|
}
|
|
325
676
|
const existing = inflightById.get(normalized.id);
|
|
326
677
|
if (existing) {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
678
|
+
pendingReplacement.set(normalized.id, normalized);
|
|
679
|
+
inflightStatements.set(normalized.id, normalized);
|
|
680
|
+
if (!replacementWatcher.has(normalized.id)) {
|
|
681
|
+
replacementWatcher.add(normalized.id);
|
|
682
|
+
void existing.then(
|
|
683
|
+
() => {
|
|
684
|
+
replacementWatcher.delete(normalized.id);
|
|
685
|
+
const replacement = pendingReplacement.get(normalized.id);
|
|
686
|
+
const transported = inflightPayload.get(normalized.id);
|
|
687
|
+
pendingReplacement.delete(normalized.id);
|
|
688
|
+
inflightPayload.delete(normalized.id);
|
|
689
|
+
if (replacement && replacement !== transported) {
|
|
690
|
+
sendOrQueueInternal(replacement);
|
|
691
|
+
}
|
|
692
|
+
},
|
|
693
|
+
() => {
|
|
694
|
+
replacementWatcher.delete(normalized.id);
|
|
695
|
+
const replacement = pendingReplacement.get(normalized.id) ?? normalized;
|
|
696
|
+
pendingReplacement.delete(normalized.id);
|
|
697
|
+
sendOrQueueInternal(replacement);
|
|
698
|
+
}
|
|
699
|
+
);
|
|
700
|
+
}
|
|
333
701
|
return;
|
|
334
702
|
}
|
|
335
703
|
inflightStatements.set(normalized.id, normalized);
|
|
704
|
+
inflightPayload.set(normalized.id, normalized);
|
|
336
705
|
const flight = Promise.resolve().then(async () => {
|
|
337
|
-
await
|
|
706
|
+
await deliveryTransport(normalized);
|
|
338
707
|
queue.removeById(normalized.id);
|
|
339
|
-
}).catch(() => {
|
|
708
|
+
}).catch((err) => {
|
|
709
|
+
if (exitDeliveredIds.has(normalized.id) || exitHandoffIds.has(normalized.id)) return;
|
|
340
710
|
queue.enqueue(normalized);
|
|
711
|
+
opts?.onTransportError?.(err);
|
|
341
712
|
if (isDevEnvironment() && !warnedTransportFailure) {
|
|
342
713
|
warnedTransportFailure = true;
|
|
343
714
|
console.warn(
|
|
344
715
|
"[lessonkit] xAPI transport failed; statement re-queued. Check your LRS endpoint or transport implementation."
|
|
345
716
|
);
|
|
346
717
|
}
|
|
347
|
-
throw new Error("xAPI transport failed");
|
|
718
|
+
throw err instanceof Error ? err : new Error("xAPI transport failed", { cause: err });
|
|
348
719
|
}).finally(() => {
|
|
349
720
|
inflightById.delete(normalized.id);
|
|
350
721
|
inflightStatements.delete(normalized.id);
|
|
722
|
+
if (!replacementWatcher.has(normalized.id)) {
|
|
723
|
+
inflightPayload.delete(normalized.id);
|
|
724
|
+
}
|
|
351
725
|
});
|
|
352
726
|
inflightById.set(normalized.id, flight);
|
|
353
727
|
void flight.catch(() => {
|
|
354
728
|
});
|
|
355
729
|
};
|
|
730
|
+
const sendOrQueue = (statement) => {
|
|
731
|
+
if (flushInProgress) {
|
|
732
|
+
pendingDuringFlush.push(statement);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
sendOrQueueInternal(statement);
|
|
736
|
+
};
|
|
356
737
|
const emit = (event) => {
|
|
357
738
|
try {
|
|
358
739
|
const statement = telemetryEventToXAPIStatement(event);
|
|
359
740
|
if (!statement) return;
|
|
360
741
|
sendOrQueue(statement);
|
|
361
742
|
} catch (err) {
|
|
743
|
+
opts?.onMappingError?.(err);
|
|
362
744
|
if (isDevEnvironment()) {
|
|
363
745
|
console.warn(
|
|
364
746
|
"[lessonkit] xAPI mapping skipped:",
|
|
@@ -367,33 +749,66 @@ function createXAPIClient(opts) {
|
|
|
367
749
|
}
|
|
368
750
|
}
|
|
369
751
|
};
|
|
370
|
-
|
|
752
|
+
const runFlushLoop = async () => {
|
|
753
|
+
if (!deliveryTransport) return;
|
|
754
|
+
for (; ; ) {
|
|
755
|
+
await queue.flush(deliveryTransport);
|
|
756
|
+
const flights = [...inflightById.values()];
|
|
757
|
+
if (flights.length > 0) {
|
|
758
|
+
await Promise.all(flights);
|
|
759
|
+
}
|
|
760
|
+
if (queue.size() === 0 && inflightById.size === 0) break;
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
const client = {
|
|
371
764
|
send: (statement) => {
|
|
372
765
|
sendOrQueue(statement);
|
|
373
766
|
},
|
|
374
767
|
queueSize: () => queue.size(),
|
|
375
768
|
flush: async () => {
|
|
376
|
-
if (!
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
769
|
+
if (!deliveryTransport) return;
|
|
770
|
+
for (; ; ) {
|
|
771
|
+
if (activeFlush) {
|
|
772
|
+
await activeFlush;
|
|
773
|
+
} else {
|
|
774
|
+
flushInProgress = true;
|
|
775
|
+
activeFlush = (async () => {
|
|
776
|
+
try {
|
|
777
|
+
await runFlushLoop();
|
|
778
|
+
while (pendingDuringFlush.length > 0) {
|
|
779
|
+
const batch = pendingDuringFlush.splice(0, pendingDuringFlush.length);
|
|
780
|
+
for (const pending of batch) {
|
|
781
|
+
sendOrQueueInternal(pending);
|
|
782
|
+
}
|
|
783
|
+
await runFlushLoop();
|
|
784
|
+
}
|
|
785
|
+
} finally {
|
|
786
|
+
flushInProgress = false;
|
|
787
|
+
}
|
|
788
|
+
})().finally(() => {
|
|
789
|
+
activeFlush = null;
|
|
790
|
+
});
|
|
791
|
+
await activeFlush;
|
|
792
|
+
}
|
|
793
|
+
if (queue.size() === 0 && inflightById.size === 0) break;
|
|
381
794
|
}
|
|
382
795
|
},
|
|
383
796
|
flushOnExit: exitTransport ? () => {
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
} catch {
|
|
797
|
+
const headId = queue.getHeadInFlightId?.();
|
|
798
|
+
if (headId) {
|
|
799
|
+
opts.abortInFlight?.(headId);
|
|
800
|
+
const headStatement = inflightStatements.get(headId);
|
|
801
|
+
if (headStatement) {
|
|
802
|
+
dispatchExitStatement(headStatement);
|
|
391
803
|
}
|
|
392
804
|
}
|
|
805
|
+
for (const statement of inflightStatements.values()) {
|
|
806
|
+
if (statement.id === headId) continue;
|
|
807
|
+
opts.abortInFlight?.(statement.id);
|
|
808
|
+
dispatchExitStatement(statement);
|
|
809
|
+
}
|
|
393
810
|
queue.flushOnExit((statement) => {
|
|
394
|
-
|
|
395
|
-
exitTransport(statement);
|
|
396
|
-
exitSentIds.add(statement.id);
|
|
811
|
+
dispatchExitStatement(statement);
|
|
397
812
|
});
|
|
398
813
|
} : void 0,
|
|
399
814
|
startedLesson: ({ lessonId }) => {
|
|
@@ -431,125 +846,264 @@ function createXAPIClient(opts) {
|
|
|
431
846
|
});
|
|
432
847
|
}
|
|
433
848
|
};
|
|
849
|
+
if (hadDeadLetters && deliveryTransport) {
|
|
850
|
+
queueMicrotask(() => {
|
|
851
|
+
void client.flush().catch(() => void 0);
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
return client;
|
|
855
|
+
}
|
|
856
|
+
function resetXAPIDeadLetterForTests() {
|
|
857
|
+
clearDeadLetterStorage();
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// src/safeLrsUrl.ts
|
|
861
|
+
var import_meta = {};
|
|
862
|
+
function isProductionRuntime() {
|
|
863
|
+
try {
|
|
864
|
+
if (import_meta.env?.PROD === true) return true;
|
|
865
|
+
} catch {
|
|
866
|
+
}
|
|
867
|
+
const g = globalThis;
|
|
868
|
+
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production";
|
|
869
|
+
}
|
|
870
|
+
function parseHostname(url) {
|
|
871
|
+
return url.hostname.replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
|
|
872
|
+
}
|
|
873
|
+
function isIpv4MappedAddress(hostname) {
|
|
874
|
+
const match = hostname.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
|
875
|
+
return match?.[1] ?? null;
|
|
876
|
+
}
|
|
877
|
+
function isLoopbackHost(hostname) {
|
|
878
|
+
const ipv4Mapped = isIpv4MappedAddress(hostname);
|
|
879
|
+
if (ipv4Mapped) return isLoopbackHost(ipv4Mapped);
|
|
880
|
+
return hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "127.0.0.1" || hostname === "::1" || hostname === "0.0.0.0";
|
|
881
|
+
}
|
|
882
|
+
function isLinkLocalOrMetadataHost(hostname) {
|
|
883
|
+
if (hostname === "169.254.169.254") return true;
|
|
884
|
+
if (/^169\.254\./.test(hostname)) return true;
|
|
885
|
+
if (/^fe80:/i.test(hostname)) return true;
|
|
886
|
+
return false;
|
|
887
|
+
}
|
|
888
|
+
function isRfc1918Host(hostname) {
|
|
889
|
+
const ipv4Mapped = isIpv4MappedAddress(hostname);
|
|
890
|
+
if (ipv4Mapped) return isRfc1918Host(ipv4Mapped);
|
|
891
|
+
if (/^10\./.test(hostname)) return true;
|
|
892
|
+
if (/^192\.168\./.test(hostname)) return true;
|
|
893
|
+
const parts = hostname.split(".").map(Number);
|
|
894
|
+
if (parts.length === 4 && parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
|
|
895
|
+
return false;
|
|
896
|
+
}
|
|
897
|
+
function isPrivateOrMetadataHost(hostname) {
|
|
898
|
+
return isLoopbackHost(hostname) || isLinkLocalOrMetadataHost(hostname) || isRfc1918Host(hostname);
|
|
899
|
+
}
|
|
900
|
+
function containsPathTraversal(path) {
|
|
901
|
+
if (path.includes("..")) return true;
|
|
902
|
+
let decoded = path;
|
|
903
|
+
for (let i = 0; i < 2; i++) {
|
|
904
|
+
try {
|
|
905
|
+
const next = decodeURIComponent(decoded.replace(/\+/g, " "));
|
|
906
|
+
if (next.includes("..")) return true;
|
|
907
|
+
if (next === decoded) break;
|
|
908
|
+
decoded = next;
|
|
909
|
+
} catch {
|
|
910
|
+
break;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
return false;
|
|
914
|
+
}
|
|
915
|
+
function assertSafeLrsUrl(url, opts) {
|
|
916
|
+
if (url.startsWith("/")) {
|
|
917
|
+
if (containsPathTraversal(url)) {
|
|
918
|
+
throw new Error(`Unsafe LRS URL: path traversal in "${url}"`);
|
|
919
|
+
}
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
let parsed;
|
|
923
|
+
try {
|
|
924
|
+
parsed = new URL(url);
|
|
925
|
+
} catch {
|
|
926
|
+
throw new Error(`Unsafe LRS URL: invalid URL "${url}"`);
|
|
927
|
+
}
|
|
928
|
+
if (containsPathTraversal(parsed.pathname)) {
|
|
929
|
+
throw new Error(`Unsafe LRS URL: path traversal in "${url}"`);
|
|
930
|
+
}
|
|
931
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
932
|
+
throw new Error(`Unsafe LRS URL: unsupported scheme "${parsed.protocol}"`);
|
|
933
|
+
}
|
|
934
|
+
if (isProductionRuntime() && parsed.protocol !== "https:") {
|
|
935
|
+
throw new Error("Unsafe LRS URL: HTTPS is required in production builds");
|
|
936
|
+
}
|
|
937
|
+
const hostname = parseHostname(parsed);
|
|
938
|
+
if (!opts?.allowPrivateHosts && isPrivateOrMetadataHost(hostname)) {
|
|
939
|
+
throw new Error(`Unsafe LRS URL: private or metadata host "${hostname}" is not allowed`);
|
|
940
|
+
}
|
|
434
941
|
}
|
|
435
942
|
|
|
436
943
|
// src/fetchTransport.ts
|
|
944
|
+
var FetchHttpError = class extends Error {
|
|
945
|
+
status;
|
|
946
|
+
constructor(status, statusText, kind = "xapi") {
|
|
947
|
+
super(
|
|
948
|
+
kind === "xapi" ? `xAPI fetch failed: ${status} ${statusText}` : `telemetry batch fetch failed: ${status} ${statusText}`
|
|
949
|
+
);
|
|
950
|
+
this.name = "FetchHttpError";
|
|
951
|
+
this.status = status;
|
|
952
|
+
}
|
|
953
|
+
};
|
|
954
|
+
function isRetryableFetchHttpStatus(status) {
|
|
955
|
+
return status === 429 || status >= 500;
|
|
956
|
+
}
|
|
957
|
+
function isRetryableFetchError(err) {
|
|
958
|
+
if (err instanceof FetchHttpError) return isRetryableFetchHttpStatus(err.status);
|
|
959
|
+
return true;
|
|
960
|
+
}
|
|
437
961
|
function resolveHeaders(headers) {
|
|
438
962
|
if (!headers) return { "Content-Type": "application/json" };
|
|
439
963
|
const resolved = typeof headers === "function" ? headers() : headers;
|
|
440
964
|
return { "Content-Type": "application/json", ...resolved };
|
|
441
965
|
}
|
|
442
966
|
function createAbortSignal(timeoutMs) {
|
|
443
|
-
if (timeoutMs <= 0) return void 0
|
|
444
|
-
|
|
445
|
-
if (typeof timeout.timeout === "function") {
|
|
446
|
-
return timeout.timeout(timeoutMs);
|
|
447
|
-
}
|
|
967
|
+
if (timeoutMs <= 0) return { signal: void 0, abort: () => {
|
|
968
|
+
} };
|
|
448
969
|
const controller = new AbortController();
|
|
449
970
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
450
971
|
timer.unref?.();
|
|
451
|
-
return
|
|
972
|
+
return {
|
|
973
|
+
signal: controller.signal,
|
|
974
|
+
abort: () => {
|
|
975
|
+
clearTimeout(timer);
|
|
976
|
+
controller.abort();
|
|
977
|
+
}
|
|
978
|
+
};
|
|
452
979
|
}
|
|
453
980
|
function sleep(ms) {
|
|
454
981
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
455
982
|
}
|
|
456
983
|
function postStatement(url, statement, init) {
|
|
457
984
|
return fetch(url, {
|
|
985
|
+
...init,
|
|
458
986
|
method: "POST",
|
|
459
|
-
body: JSON.stringify(statement)
|
|
460
|
-
...init
|
|
987
|
+
body: JSON.stringify(statement)
|
|
461
988
|
}).then((res) => {
|
|
462
989
|
if (!res.ok) {
|
|
463
|
-
throw new
|
|
990
|
+
throw new FetchHttpError(res.status, res.statusText, "xapi");
|
|
464
991
|
}
|
|
465
992
|
});
|
|
466
993
|
}
|
|
994
|
+
async function postWithRetry(post, retries, initialBackoffMs, maxBackoffMs) {
|
|
995
|
+
let attempt = 0;
|
|
996
|
+
let backoff = initialBackoffMs;
|
|
997
|
+
for (; ; ) {
|
|
998
|
+
try {
|
|
999
|
+
await post();
|
|
1000
|
+
return;
|
|
1001
|
+
} catch (err) {
|
|
1002
|
+
if (!isRetryableFetchError(err) || attempt >= retries) throw err;
|
|
1003
|
+
await sleep(backoff);
|
|
1004
|
+
backoff = Math.min(backoff * 2, maxBackoffMs);
|
|
1005
|
+
attempt += 1;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
467
1009
|
function createFetchTransport(opts) {
|
|
1010
|
+
assertSafeLrsUrl(opts.url, { allowPrivateHosts: opts.allowPrivateHosts });
|
|
468
1011
|
const timeoutMs = opts.timeoutMs ?? 3e4;
|
|
469
|
-
const
|
|
1012
|
+
const rawRetries = opts.retries ?? 2;
|
|
1013
|
+
const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
|
|
470
1014
|
const initialBackoffMs = opts.backoffMs ?? 250;
|
|
471
1015
|
const maxBackoffMs = opts.maxBackoffMs ?? 5e3;
|
|
1016
|
+
const activeControllers = /* @__PURE__ */ new Map();
|
|
472
1017
|
const transport = async (statement) => {
|
|
473
|
-
let
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
signal
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
1018
|
+
let abortCleanup;
|
|
1019
|
+
activeControllers.set(statement.id, {
|
|
1020
|
+
abort: () => abortCleanup?.()
|
|
1021
|
+
});
|
|
1022
|
+
try {
|
|
1023
|
+
await postWithRetry(
|
|
1024
|
+
() => {
|
|
1025
|
+
const { signal, abort } = createAbortSignal(timeoutMs);
|
|
1026
|
+
abortCleanup = abort;
|
|
1027
|
+
return postStatement(opts.url, statement, {
|
|
1028
|
+
...opts.init,
|
|
1029
|
+
headers: resolveHeaders(opts.headers),
|
|
1030
|
+
signal
|
|
1031
|
+
});
|
|
1032
|
+
},
|
|
1033
|
+
retries,
|
|
1034
|
+
initialBackoffMs,
|
|
1035
|
+
maxBackoffMs
|
|
1036
|
+
);
|
|
1037
|
+
} finally {
|
|
1038
|
+
activeControllers.delete(statement.id);
|
|
489
1039
|
}
|
|
490
1040
|
};
|
|
491
1041
|
const exitTransport = (statement) => {
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
}
|
|
499
|
-
}
|
|
1042
|
+
return postStatement(opts.url, statement, {
|
|
1043
|
+
...opts.init,
|
|
1044
|
+
headers: resolveHeaders(opts.headers),
|
|
1045
|
+
keepalive: true
|
|
1046
|
+
}).catch(() => {
|
|
1047
|
+
throw new Error("xAPI keepalive delivery failed");
|
|
1048
|
+
});
|
|
500
1049
|
};
|
|
501
|
-
|
|
1050
|
+
const abortInFlight = (statementId) => {
|
|
1051
|
+
activeControllers.get(statementId)?.abort();
|
|
1052
|
+
activeControllers.delete(statementId);
|
|
1053
|
+
};
|
|
1054
|
+
return { transport, exitTransport, abortInFlight };
|
|
502
1055
|
}
|
|
503
1056
|
function createFetchBatchSink(opts) {
|
|
1057
|
+
assertSafeLrsUrl(opts.url, { allowPrivateHosts: opts.allowPrivateHosts });
|
|
504
1058
|
const timeoutMs = opts.timeoutMs ?? 3e4;
|
|
505
|
-
const
|
|
1059
|
+
const rawRetries = opts.retries ?? 2;
|
|
1060
|
+
const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
|
|
506
1061
|
const initialBackoffMs = opts.backoffMs ?? 250;
|
|
507
1062
|
const maxBackoffMs = opts.maxBackoffMs ?? 5e3;
|
|
508
1063
|
const postBatch = async (events, init) => {
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
if (!res.ok) {
|
|
521
|
-
throw new Error(`telemetry batch fetch failed: ${res.status} ${res.statusText}`);
|
|
522
|
-
}
|
|
523
|
-
return;
|
|
524
|
-
} catch (err) {
|
|
525
|
-
if (attempt >= retries) throw err;
|
|
526
|
-
await sleep(backoff);
|
|
527
|
-
backoff = Math.min(backoff * 2, maxBackoffMs);
|
|
528
|
-
attempt += 1;
|
|
1064
|
+
await postWithRetry(async () => {
|
|
1065
|
+
const { signal } = createAbortSignal(timeoutMs);
|
|
1066
|
+
const res = await fetch(opts.url, {
|
|
1067
|
+
...init,
|
|
1068
|
+
method: "POST",
|
|
1069
|
+
body: JSON.stringify(events),
|
|
1070
|
+
headers: resolveHeaders(opts.headers),
|
|
1071
|
+
signal
|
|
1072
|
+
});
|
|
1073
|
+
if (!res.ok) {
|
|
1074
|
+
throw new FetchHttpError(res.status, res.statusText, "batch");
|
|
529
1075
|
}
|
|
530
|
-
}
|
|
1076
|
+
}, retries, initialBackoffMs, maxBackoffMs);
|
|
531
1077
|
};
|
|
532
1078
|
return {
|
|
533
1079
|
batchSink: (events) => postBatch(events, opts.init ?? {}),
|
|
534
1080
|
exitBatchSink: (events) => {
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
1081
|
+
return fetch(opts.url, {
|
|
1082
|
+
method: "POST",
|
|
1083
|
+
body: JSON.stringify(events),
|
|
1084
|
+
...opts.init,
|
|
1085
|
+
headers: resolveHeaders(opts.headers),
|
|
1086
|
+
keepalive: true
|
|
1087
|
+
}).then((res) => {
|
|
1088
|
+
if (!res.ok) {
|
|
1089
|
+
throw new FetchHttpError(res.status, res.statusText, "batch");
|
|
1090
|
+
}
|
|
1091
|
+
});
|
|
545
1092
|
}
|
|
546
1093
|
};
|
|
547
1094
|
}
|
|
548
1095
|
// Annotate the CommonJS export names for ESM import in node:
|
|
549
1096
|
0 && (module.exports = {
|
|
1097
|
+
FetchHttpError,
|
|
1098
|
+
assertSafeLrsUrl,
|
|
550
1099
|
createFetchBatchSink,
|
|
551
1100
|
createFetchTransport,
|
|
552
1101
|
createInMemoryXAPIQueue,
|
|
553
1102
|
createXAPIClient,
|
|
1103
|
+
isRetryableFetchError,
|
|
1104
|
+
isRetryableFetchHttpStatus,
|
|
1105
|
+
loadDeadLetterStatements,
|
|
1106
|
+
persistDeadLetterStatement,
|
|
1107
|
+
resetXAPIDeadLetterForTests,
|
|
554
1108
|
telemetryEventToXAPIStatement
|
|
555
1109
|
});
|