@lessonkit/xapi 1.1.0 → 1.3.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 +1 -0
- package/dist/index.cjs +210 -152
- package/dist/index.d.cts +14 -2
- package/dist/index.d.ts +14 -2
- package/dist/index.js +206 -148
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -36,6 +36,7 @@ Map from telemetry events: `telemetryEventToXAPIStatement(event)` — uses canon
|
|
|
36
36
|
|
|
37
37
|
- No transport → statements queue in memory (dev warns once).
|
|
38
38
|
- Transport failure → re-queue; call `flush()` to retry.
|
|
39
|
+
- Queue capped at **1000** statements by default; oldest dropped when full (`onCap` / `createInMemoryXAPIQueue({ onCap })`).
|
|
39
40
|
- Concurrent `flush()` calls are coalesced.
|
|
40
41
|
|
|
41
42
|
## Docs
|
package/dist/index.cjs
CHANGED
|
@@ -26,25 +26,65 @@ __export(index_exports, {
|
|
|
26
26
|
});
|
|
27
27
|
module.exports = __toCommonJS(index_exports);
|
|
28
28
|
|
|
29
|
+
// src/id.ts
|
|
30
|
+
function cryptoRandomId() {
|
|
31
|
+
const g = globalThis;
|
|
32
|
+
if (g.crypto?.randomUUID) return g.crypto.randomUUID();
|
|
33
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 11)}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
29
36
|
// src/queue.ts
|
|
30
|
-
function
|
|
37
|
+
function withStatementId(statement) {
|
|
38
|
+
const trimmed = statement.id?.trim();
|
|
39
|
+
if (trimmed) {
|
|
40
|
+
if (trimmed !== statement.id) statement.id = trimmed;
|
|
41
|
+
return statement;
|
|
42
|
+
}
|
|
43
|
+
statement.id = cryptoRandomId();
|
|
44
|
+
return statement;
|
|
45
|
+
}
|
|
46
|
+
var DEFAULT_MAX_QUEUE_SIZE = 1e3;
|
|
47
|
+
function createInMemoryXAPIQueue(opts) {
|
|
48
|
+
const maxSize = opts?.maxSize ?? DEFAULT_MAX_QUEUE_SIZE;
|
|
31
49
|
const buffer = [];
|
|
32
50
|
let flushInFlight = null;
|
|
51
|
+
let headInFlight = false;
|
|
52
|
+
const notifyDepth = () => {
|
|
53
|
+
opts?.onDepth?.(buffer.length);
|
|
54
|
+
};
|
|
33
55
|
const runFlush = async (transport) => {
|
|
34
56
|
while (buffer.length) {
|
|
35
57
|
const statement = buffer[0];
|
|
58
|
+
headInFlight = true;
|
|
36
59
|
try {
|
|
37
60
|
await transport(statement);
|
|
38
61
|
buffer.shift();
|
|
62
|
+
notifyDepth();
|
|
39
63
|
} catch {
|
|
40
64
|
return;
|
|
65
|
+
} finally {
|
|
66
|
+
headInFlight = false;
|
|
41
67
|
}
|
|
42
68
|
}
|
|
43
69
|
};
|
|
44
70
|
return {
|
|
45
71
|
enqueue: (statement) => {
|
|
46
|
-
|
|
47
|
-
buffer.
|
|
72
|
+
const normalized = withStatementId(statement);
|
|
73
|
+
if (buffer.some((s) => s.id === normalized.id)) return;
|
|
74
|
+
if (buffer.length >= maxSize) {
|
|
75
|
+
if (headInFlight && buffer.length <= 1) {
|
|
76
|
+
opts?.onCap?.();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (headInFlight) {
|
|
80
|
+
buffer.splice(1, 1);
|
|
81
|
+
} else {
|
|
82
|
+
buffer.shift();
|
|
83
|
+
}
|
|
84
|
+
opts?.onCap?.();
|
|
85
|
+
}
|
|
86
|
+
buffer.push(normalized);
|
|
87
|
+
notifyDepth();
|
|
48
88
|
},
|
|
49
89
|
size: () => buffer.length,
|
|
50
90
|
flush: async (transport) => {
|
|
@@ -59,22 +99,15 @@ function createInMemoryXAPIQueue() {
|
|
|
59
99
|
}
|
|
60
100
|
|
|
61
101
|
// src/client.ts
|
|
62
|
-
var
|
|
102
|
+
var import_core2 = require("@lessonkit/core");
|
|
63
103
|
|
|
64
104
|
// src/telemetryMap.ts
|
|
65
105
|
var import_core = require("@lessonkit/core");
|
|
66
|
-
var import_core2 = require("@lessonkit/core");
|
|
67
|
-
|
|
68
|
-
// src/id.ts
|
|
69
|
-
function cryptoRandomId() {
|
|
70
|
-
const g = globalThis;
|
|
71
|
-
if (g.crypto?.randomUUID) return g.crypto.randomUUID();
|
|
72
|
-
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 11)}`;
|
|
73
|
-
}
|
|
74
106
|
|
|
75
107
|
// src/duration.ts
|
|
76
108
|
function formatDurationMs(ms) {
|
|
77
|
-
|
|
109
|
+
if (!Number.isFinite(ms) || ms < 0) return void 0;
|
|
110
|
+
const safe = ms;
|
|
78
111
|
const seconds = safe / 1e3;
|
|
79
112
|
const fixed = Number.isInteger(seconds) ? String(seconds) : seconds.toFixed(3).replace(/0+$/, "").replace(/\.$/, "");
|
|
80
113
|
return `PT${fixed}S`;
|
|
@@ -87,130 +120,22 @@ var XAPIVerbs = {
|
|
|
87
120
|
answered: "http://adlnet.gov/expapi/verbs/answered",
|
|
88
121
|
experienced: "http://adlnet.gov/expapi/verbs/experienced"
|
|
89
122
|
};
|
|
90
|
-
function
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
return statementFor((0, import_core2.buildLessonkitUrn)({ courseId }), XAPIVerbs.completed, event.timestamp);
|
|
97
|
-
case "lesson_started": {
|
|
98
|
-
const lessonId = event.lessonId;
|
|
99
|
-
return statementFor(
|
|
100
|
-
(0, import_core2.buildLessonkitUrn)({ courseId, lessonId }),
|
|
101
|
-
XAPIVerbs.initialized,
|
|
102
|
-
event.timestamp
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
case "lesson_completed": {
|
|
106
|
-
const lessonId = event.lessonId;
|
|
107
|
-
const data = event.data;
|
|
108
|
-
const result = {};
|
|
109
|
-
if (typeof data?.durationMs === "number") {
|
|
110
|
-
result.duration = formatDurationMs(data.durationMs);
|
|
111
|
-
}
|
|
112
|
-
if (typeof data?.success === "boolean") result.success = data.success;
|
|
113
|
-
if (typeof data?.score === "number" || typeof data?.maxScore === "number") {
|
|
114
|
-
const max = typeof data.maxScore === "number" ? data.maxScore : void 0;
|
|
115
|
-
const raw = typeof data.score === "number" ? data.score : void 0;
|
|
116
|
-
result.score = {
|
|
117
|
-
raw,
|
|
118
|
-
max,
|
|
119
|
-
min: 0,
|
|
120
|
-
scaled: typeof raw === "number" && typeof max === "number" && max > 0 ? raw / max : void 0
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
return statementFor((0, import_core2.buildLessonkitUrn)({ courseId, lessonId }), XAPIVerbs.completed, event.timestamp, {
|
|
124
|
-
result: Object.keys(result).length ? result : void 0
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
case "lesson_time_on_task":
|
|
128
|
-
return null;
|
|
129
|
-
case "quiz_answered": {
|
|
130
|
-
const lessonId = event.lessonId;
|
|
131
|
-
const checkId = event.data.checkId;
|
|
132
|
-
const result = {};
|
|
133
|
-
if (typeof event.data.correct === "boolean") {
|
|
134
|
-
result.success = event.data.correct;
|
|
135
|
-
}
|
|
136
|
-
return statementFor(
|
|
137
|
-
(0, import_core2.buildLessonkitUrn)({ courseId, lessonId, checkId }),
|
|
138
|
-
XAPIVerbs.answered,
|
|
139
|
-
event.timestamp,
|
|
140
|
-
{ result: Object.keys(result).length ? result : void 0 }
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
case "quiz_completed": {
|
|
144
|
-
const lessonId = event.lessonId;
|
|
145
|
-
const checkId = event.data.checkId;
|
|
146
|
-
const { score, maxScore } = event.data;
|
|
147
|
-
const result = {};
|
|
148
|
-
if (typeof score === "number" || typeof maxScore === "number") {
|
|
149
|
-
const max = typeof maxScore === "number" ? maxScore : void 0;
|
|
150
|
-
const raw = typeof score === "number" ? score : void 0;
|
|
151
|
-
result.score = {
|
|
152
|
-
raw,
|
|
153
|
-
max,
|
|
154
|
-
min: 0,
|
|
155
|
-
scaled: typeof raw === "number" && typeof max === "number" && max > 0 ? raw / max : void 0
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
return statementFor(
|
|
159
|
-
(0, import_core2.buildLessonkitUrn)({ courseId, lessonId, checkId }),
|
|
160
|
-
XAPIVerbs.completed,
|
|
161
|
-
event.timestamp,
|
|
162
|
-
{ result: Object.keys(result).length ? result : void 0 }
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
case "assessment_answered": {
|
|
166
|
-
const lessonId = event.lessonId;
|
|
167
|
-
const checkId = event.data.checkId;
|
|
168
|
-
const result = {};
|
|
169
|
-
if (typeof event.data.correct === "boolean") {
|
|
170
|
-
result.success = event.data.correct;
|
|
171
|
-
}
|
|
172
|
-
return statementFor(
|
|
173
|
-
(0, import_core2.buildLessonkitUrn)({ courseId, lessonId, checkId }),
|
|
174
|
-
XAPIVerbs.answered,
|
|
175
|
-
event.timestamp,
|
|
176
|
-
{ result: Object.keys(result).length ? result : void 0 }
|
|
177
|
-
);
|
|
178
|
-
}
|
|
179
|
-
case "assessment_completed": {
|
|
180
|
-
const lessonId = event.lessonId;
|
|
181
|
-
const checkId = event.data.checkId;
|
|
182
|
-
const { score, maxScore } = event.data;
|
|
183
|
-
const result = {};
|
|
184
|
-
if (typeof score === "number" || typeof maxScore === "number") {
|
|
185
|
-
const max = typeof maxScore === "number" ? maxScore : void 0;
|
|
186
|
-
const raw = typeof score === "number" ? score : void 0;
|
|
187
|
-
result.score = {
|
|
188
|
-
raw,
|
|
189
|
-
max,
|
|
190
|
-
min: 0,
|
|
191
|
-
scaled: typeof raw === "number" && typeof max === "number" && max > 0 ? raw / max : void 0
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
return statementFor(
|
|
195
|
-
(0, import_core2.buildLessonkitUrn)({ courseId, lessonId, checkId }),
|
|
196
|
-
XAPIVerbs.completed,
|
|
197
|
-
event.timestamp,
|
|
198
|
-
{ result: Object.keys(result).length ? result : void 0 }
|
|
199
|
-
);
|
|
200
|
-
}
|
|
201
|
-
case "interaction": {
|
|
202
|
-
const lessonId = event.lessonId;
|
|
203
|
-
const blockId = event.data?.blockId;
|
|
204
|
-
if (!lessonId || !blockId) return null;
|
|
205
|
-
return statementFor(
|
|
206
|
-
(0, import_core2.buildLessonkitUrn)({ courseId, lessonId, blockId }),
|
|
207
|
-
XAPIVerbs.experienced,
|
|
208
|
-
event.timestamp
|
|
209
|
-
);
|
|
210
|
-
}
|
|
211
|
-
default:
|
|
212
|
-
return (0, import_core.assertNever)(event, "Unhandled telemetry event");
|
|
123
|
+
function buildXapiScoreResult(opts) {
|
|
124
|
+
const max = typeof opts.maxScore === "number" ? opts.maxScore : void 0;
|
|
125
|
+
const raw = typeof opts.score === "number" ? opts.score : void 0;
|
|
126
|
+
if (typeof raw !== "number" && typeof max !== "number") return void 0;
|
|
127
|
+
if (typeof raw === "number" && !Number.isFinite(raw) || typeof max === "number" && !Number.isFinite(max)) {
|
|
128
|
+
return void 0;
|
|
213
129
|
}
|
|
130
|
+
if (typeof max === "number" && max <= 0) return void 0;
|
|
131
|
+
if (typeof raw === "number" && raw < 0) return void 0;
|
|
132
|
+
const result = { min: 0 };
|
|
133
|
+
if (typeof raw === "number") result.raw = raw;
|
|
134
|
+
if (typeof max === "number") result.max = max;
|
|
135
|
+
if (typeof raw === "number" && typeof max === "number" && max > 0 && raw <= max) {
|
|
136
|
+
result.scaled = raw / max;
|
|
137
|
+
}
|
|
138
|
+
return result;
|
|
214
139
|
}
|
|
215
140
|
function statementFor(objectId, verb, timestamp, extra) {
|
|
216
141
|
return {
|
|
@@ -222,8 +147,126 @@ function statementFor(objectId, verb, timestamp, extra) {
|
|
|
222
147
|
context: extra?.context
|
|
223
148
|
};
|
|
224
149
|
}
|
|
150
|
+
function experiencedBlockStatement(courseId, lessonId, blockId, timestamp) {
|
|
151
|
+
return statementFor(
|
|
152
|
+
(0, import_core.buildLessonkitUrn)({ courseId, lessonId, blockId }),
|
|
153
|
+
XAPIVerbs.experienced,
|
|
154
|
+
timestamp
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
var experiencedBlockMapper = (event, ctx) => {
|
|
158
|
+
if (event.name === "interaction") {
|
|
159
|
+
const lessonId2 = event.lessonId;
|
|
160
|
+
const blockId2 = event.data?.blockId;
|
|
161
|
+
if (!lessonId2 || !blockId2 || typeof blockId2 !== "string") return null;
|
|
162
|
+
return experiencedBlockStatement(ctx.courseId, lessonId2, blockId2, ctx.timestamp);
|
|
163
|
+
}
|
|
164
|
+
const lessonId = event.lessonId;
|
|
165
|
+
const blockId = "data" in event && event.data && "blockId" in event.data ? event.data.blockId : void 0;
|
|
166
|
+
if (!lessonId || !blockId || typeof blockId !== "string") return null;
|
|
167
|
+
return experiencedBlockStatement(ctx.courseId, lessonId, blockId, ctx.timestamp);
|
|
168
|
+
};
|
|
169
|
+
var TELEMETRY_XAPI_MAPPERS = {
|
|
170
|
+
course_started: (_event, ctx) => statementFor((0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId }), XAPIVerbs.initialized, ctx.timestamp),
|
|
171
|
+
course_completed: (_event, ctx) => statementFor((0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId }), XAPIVerbs.completed, ctx.timestamp),
|
|
172
|
+
lesson_started: (event, ctx) => {
|
|
173
|
+
const lessonId = event.name === "lesson_started" ? event.lessonId : void 0;
|
|
174
|
+
if (!lessonId) return null;
|
|
175
|
+
return statementFor(
|
|
176
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId }),
|
|
177
|
+
XAPIVerbs.initialized,
|
|
178
|
+
ctx.timestamp
|
|
179
|
+
);
|
|
180
|
+
},
|
|
181
|
+
lesson_completed: (event, ctx) => {
|
|
182
|
+
if (event.name !== "lesson_completed") return null;
|
|
183
|
+
const lessonId = event.lessonId;
|
|
184
|
+
const data = event.data;
|
|
185
|
+
const result = {};
|
|
186
|
+
if (typeof data?.durationMs === "number") {
|
|
187
|
+
const duration = formatDurationMs(data.durationMs);
|
|
188
|
+
if (duration !== void 0) result.duration = duration;
|
|
189
|
+
}
|
|
190
|
+
if (typeof data?.success === "boolean") result.success = data.success;
|
|
191
|
+
const score = buildXapiScoreResult({ score: data?.score, maxScore: data?.maxScore });
|
|
192
|
+
if (score) result.score = score;
|
|
193
|
+
return statementFor((0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId }), XAPIVerbs.completed, ctx.timestamp, {
|
|
194
|
+
result: Object.keys(result).length ? result : void 0
|
|
195
|
+
});
|
|
196
|
+
},
|
|
197
|
+
lesson_time_on_task: () => null,
|
|
198
|
+
quiz_answered: (event, ctx) => {
|
|
199
|
+
if (event.name !== "quiz_answered") return null;
|
|
200
|
+
const result = {};
|
|
201
|
+
if (typeof event.data.correct === "boolean") result.success = event.data.correct;
|
|
202
|
+
return statementFor(
|
|
203
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
204
|
+
XAPIVerbs.answered,
|
|
205
|
+
ctx.timestamp,
|
|
206
|
+
{ result: Object.keys(result).length ? result : void 0 }
|
|
207
|
+
);
|
|
208
|
+
},
|
|
209
|
+
quiz_completed: (event, ctx) => {
|
|
210
|
+
if (event.name !== "quiz_completed") return null;
|
|
211
|
+
const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
|
|
212
|
+
return statementFor(
|
|
213
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
214
|
+
XAPIVerbs.completed,
|
|
215
|
+
ctx.timestamp,
|
|
216
|
+
{ result: score ? { score } : void 0 }
|
|
217
|
+
);
|
|
218
|
+
},
|
|
219
|
+
assessment_answered: (event, ctx) => {
|
|
220
|
+
if (event.name !== "assessment_answered") return null;
|
|
221
|
+
const result = {};
|
|
222
|
+
if (typeof event.data.correct === "boolean") result.success = event.data.correct;
|
|
223
|
+
return statementFor(
|
|
224
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
225
|
+
XAPIVerbs.answered,
|
|
226
|
+
ctx.timestamp,
|
|
227
|
+
{ result: Object.keys(result).length ? result : void 0 }
|
|
228
|
+
);
|
|
229
|
+
},
|
|
230
|
+
assessment_completed: (event, ctx) => {
|
|
231
|
+
if (event.name !== "assessment_completed") return null;
|
|
232
|
+
const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
|
|
233
|
+
return statementFor(
|
|
234
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
235
|
+
XAPIVerbs.completed,
|
|
236
|
+
ctx.timestamp,
|
|
237
|
+
{ result: score ? { score } : void 0 }
|
|
238
|
+
);
|
|
239
|
+
},
|
|
240
|
+
interaction: experiencedBlockMapper,
|
|
241
|
+
book_page_viewed: experiencedBlockMapper,
|
|
242
|
+
slide_viewed: experiencedBlockMapper,
|
|
243
|
+
compound_page_viewed: experiencedBlockMapper,
|
|
244
|
+
hotspot_opened: experiencedBlockMapper,
|
|
245
|
+
accordion_section_toggled: experiencedBlockMapper,
|
|
246
|
+
flashcard_flipped: experiencedBlockMapper,
|
|
247
|
+
image_slider_changed: experiencedBlockMapper
|
|
248
|
+
};
|
|
249
|
+
function telemetryEventToXAPIStatement(event) {
|
|
250
|
+
const mapper = TELEMETRY_XAPI_MAPPERS[event.name];
|
|
251
|
+
if (!mapper) {
|
|
252
|
+
throw new Error(`Unhandled telemetry event: ${event.name}`);
|
|
253
|
+
}
|
|
254
|
+
return mapper(event, {
|
|
255
|
+
courseId: event.courseId,
|
|
256
|
+
timestamp: event.timestamp
|
|
257
|
+
});
|
|
258
|
+
}
|
|
225
259
|
|
|
226
260
|
// src/client.ts
|
|
261
|
+
function withStatementId2(statement) {
|
|
262
|
+
const trimmed = statement.id?.trim();
|
|
263
|
+
if (trimmed) {
|
|
264
|
+
if (trimmed !== statement.id) statement.id = trimmed;
|
|
265
|
+
return statement;
|
|
266
|
+
}
|
|
267
|
+
statement.id = cryptoRandomId();
|
|
268
|
+
return statement;
|
|
269
|
+
}
|
|
227
270
|
function isDevEnvironment() {
|
|
228
271
|
const g = globalThis;
|
|
229
272
|
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
@@ -231,13 +274,18 @@ function isDevEnvironment() {
|
|
|
231
274
|
function createXAPIClient(opts) {
|
|
232
275
|
const transport = opts?.transport;
|
|
233
276
|
const courseId = opts?.courseId;
|
|
234
|
-
const queue = opts?.queue ?? createInMemoryXAPIQueue(
|
|
277
|
+
const queue = opts?.queue ?? createInMemoryXAPIQueue({
|
|
278
|
+
maxSize: opts?.maxQueueSize,
|
|
279
|
+
onDepth: opts?.onQueueDepth,
|
|
280
|
+
onCap: opts?.onQueueCap
|
|
281
|
+
});
|
|
235
282
|
let warnedNoTransport = false;
|
|
236
283
|
let warnedTransportFailure = false;
|
|
237
284
|
const inflightById = /* @__PURE__ */ new Map();
|
|
238
285
|
const sendOrQueue = (statement) => {
|
|
286
|
+
const normalized = withStatementId2(statement);
|
|
239
287
|
if (!transport) {
|
|
240
|
-
queue.enqueue(
|
|
288
|
+
queue.enqueue(normalized);
|
|
241
289
|
if (isDevEnvironment() && !warnedNoTransport) {
|
|
242
290
|
warnedNoTransport = true;
|
|
243
291
|
console.warn(
|
|
@@ -246,35 +294,45 @@ function createXAPIClient(opts) {
|
|
|
246
294
|
}
|
|
247
295
|
return;
|
|
248
296
|
}
|
|
249
|
-
const existing = inflightById.get(
|
|
297
|
+
const existing = inflightById.get(normalized.id);
|
|
250
298
|
if (existing) {
|
|
251
299
|
void existing.then(
|
|
252
300
|
() => void 0,
|
|
253
301
|
() => {
|
|
254
|
-
sendOrQueue(
|
|
302
|
+
sendOrQueue(normalized);
|
|
255
303
|
}
|
|
256
304
|
);
|
|
257
305
|
return;
|
|
258
306
|
}
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
queue.enqueue(statement);
|
|
307
|
+
const flight = Promise.resolve().then(() => transport(normalized)).catch(() => {
|
|
308
|
+
queue.enqueue(normalized);
|
|
262
309
|
if (isDevEnvironment() && !warnedTransportFailure) {
|
|
263
310
|
warnedTransportFailure = true;
|
|
264
311
|
console.warn(
|
|
265
312
|
"[lessonkit] xAPI transport failed; statement re-queued. Check your LRS endpoint or transport implementation."
|
|
266
313
|
);
|
|
267
314
|
}
|
|
315
|
+
throw new Error("xAPI transport failed");
|
|
268
316
|
}).finally(() => {
|
|
269
|
-
inflightById.delete(
|
|
317
|
+
inflightById.delete(normalized.id);
|
|
318
|
+
});
|
|
319
|
+
inflightById.set(normalized.id, flight);
|
|
320
|
+
void flight.catch(() => {
|
|
270
321
|
});
|
|
271
|
-
inflightById.set(statement.id, transportFlight);
|
|
272
|
-
void flight;
|
|
273
322
|
};
|
|
274
323
|
const emit = (event) => {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
324
|
+
try {
|
|
325
|
+
const statement = telemetryEventToXAPIStatement(event);
|
|
326
|
+
if (!statement) return;
|
|
327
|
+
sendOrQueue(statement);
|
|
328
|
+
} catch (err) {
|
|
329
|
+
if (isDevEnvironment()) {
|
|
330
|
+
console.warn(
|
|
331
|
+
"[lessonkit] xAPI mapping skipped:",
|
|
332
|
+
err instanceof Error ? err.message : err
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
278
336
|
};
|
|
279
337
|
return {
|
|
280
338
|
send: (statement) => {
|
|
@@ -293,7 +351,7 @@ function createXAPIClient(opts) {
|
|
|
293
351
|
if (!courseId) return;
|
|
294
352
|
emit({
|
|
295
353
|
name: "lesson_started",
|
|
296
|
-
timestamp: (0,
|
|
354
|
+
timestamp: (0, import_core2.nowIso)(),
|
|
297
355
|
courseId,
|
|
298
356
|
lessonId,
|
|
299
357
|
data: { lessonId }
|
|
@@ -309,7 +367,7 @@ function createXAPIClient(opts) {
|
|
|
309
367
|
if (!courseId) return;
|
|
310
368
|
emit({
|
|
311
369
|
name: "lesson_completed",
|
|
312
|
-
timestamp: (0,
|
|
370
|
+
timestamp: (0, import_core2.nowIso)(),
|
|
313
371
|
courseId,
|
|
314
372
|
lessonId,
|
|
315
373
|
data: { lessonId, durationMs, score, maxScore, success }
|
|
@@ -319,7 +377,7 @@ function createXAPIClient(opts) {
|
|
|
319
377
|
if (!courseId) return;
|
|
320
378
|
emit({
|
|
321
379
|
name: "course_completed",
|
|
322
|
-
timestamp: (0,
|
|
380
|
+
timestamp: (0, import_core2.nowIso)(),
|
|
323
381
|
courseId
|
|
324
382
|
});
|
|
325
383
|
}
|
package/dist/index.d.cts
CHANGED
|
@@ -52,12 +52,24 @@ type XAPIClient = {
|
|
|
52
52
|
completeCourse: () => void;
|
|
53
53
|
};
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
type InMemoryXAPIQueueOptions = {
|
|
56
|
+
/** Maximum queued statements (default 1000). Oldest entries are dropped when full. */
|
|
57
|
+
maxSize?: number;
|
|
58
|
+
/** Called after enqueue with the current queue size. */
|
|
59
|
+
onDepth?: (size: number) => void;
|
|
60
|
+
/** Called when an oldest statement is dropped because the queue is at maxSize. */
|
|
61
|
+
onCap?: () => void;
|
|
62
|
+
};
|
|
63
|
+
declare function createInMemoryXAPIQueue(opts?: InMemoryXAPIQueueOptions): XAPIQueue;
|
|
56
64
|
|
|
57
65
|
declare function createXAPIClient(opts?: {
|
|
58
66
|
transport?: XAPITransport;
|
|
59
67
|
courseId?: CourseId;
|
|
60
68
|
queue?: XAPIQueue;
|
|
69
|
+
/** When creating the default in-memory queue (max size 1000 unless overridden). */
|
|
70
|
+
maxQueueSize?: number;
|
|
71
|
+
onQueueDepth?: (size: number) => void;
|
|
72
|
+
onQueueCap?: () => void;
|
|
61
73
|
}): XAPIClient;
|
|
62
74
|
|
|
63
75
|
/**
|
|
@@ -66,4 +78,4 @@ declare function createXAPIClient(opts?: {
|
|
|
66
78
|
*/
|
|
67
79
|
declare function telemetryEventToXAPIStatement(event: TelemetryEvent): XAPIStatement | null;
|
|
68
80
|
|
|
69
|
-
export { type XAPIClient, type XAPIObjectDefinition, type XAPIQueue, type XAPIResult, type XAPIScore, type XAPIStatement, type XAPITransport, type XAPIVerbIri, createInMemoryXAPIQueue, createXAPIClient, telemetryEventToXAPIStatement };
|
|
81
|
+
export { type InMemoryXAPIQueueOptions, type XAPIClient, type XAPIObjectDefinition, type XAPIQueue, type XAPIResult, type XAPIScore, type XAPIStatement, type XAPITransport, type XAPIVerbIri, createInMemoryXAPIQueue, createXAPIClient, telemetryEventToXAPIStatement };
|
package/dist/index.d.ts
CHANGED
|
@@ -52,12 +52,24 @@ type XAPIClient = {
|
|
|
52
52
|
completeCourse: () => void;
|
|
53
53
|
};
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
type InMemoryXAPIQueueOptions = {
|
|
56
|
+
/** Maximum queued statements (default 1000). Oldest entries are dropped when full. */
|
|
57
|
+
maxSize?: number;
|
|
58
|
+
/** Called after enqueue with the current queue size. */
|
|
59
|
+
onDepth?: (size: number) => void;
|
|
60
|
+
/** Called when an oldest statement is dropped because the queue is at maxSize. */
|
|
61
|
+
onCap?: () => void;
|
|
62
|
+
};
|
|
63
|
+
declare function createInMemoryXAPIQueue(opts?: InMemoryXAPIQueueOptions): XAPIQueue;
|
|
56
64
|
|
|
57
65
|
declare function createXAPIClient(opts?: {
|
|
58
66
|
transport?: XAPITransport;
|
|
59
67
|
courseId?: CourseId;
|
|
60
68
|
queue?: XAPIQueue;
|
|
69
|
+
/** When creating the default in-memory queue (max size 1000 unless overridden). */
|
|
70
|
+
maxQueueSize?: number;
|
|
71
|
+
onQueueDepth?: (size: number) => void;
|
|
72
|
+
onQueueCap?: () => void;
|
|
61
73
|
}): XAPIClient;
|
|
62
74
|
|
|
63
75
|
/**
|
|
@@ -66,4 +78,4 @@ declare function createXAPIClient(opts?: {
|
|
|
66
78
|
*/
|
|
67
79
|
declare function telemetryEventToXAPIStatement(event: TelemetryEvent): XAPIStatement | null;
|
|
68
80
|
|
|
69
|
-
export { type XAPIClient, type XAPIObjectDefinition, type XAPIQueue, type XAPIResult, type XAPIScore, type XAPIStatement, type XAPITransport, type XAPIVerbIri, createInMemoryXAPIQueue, createXAPIClient, telemetryEventToXAPIStatement };
|
|
81
|
+
export { type InMemoryXAPIQueueOptions, type XAPIClient, type XAPIObjectDefinition, type XAPIQueue, type XAPIResult, type XAPIScore, type XAPIStatement, type XAPITransport, type XAPIVerbIri, createInMemoryXAPIQueue, createXAPIClient, telemetryEventToXAPIStatement };
|
package/dist/index.js
CHANGED
|
@@ -1,22 +1,62 @@
|
|
|
1
|
+
// src/id.ts
|
|
2
|
+
function cryptoRandomId() {
|
|
3
|
+
const g = globalThis;
|
|
4
|
+
if (g.crypto?.randomUUID) return g.crypto.randomUUID();
|
|
5
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 11)}`;
|
|
6
|
+
}
|
|
7
|
+
|
|
1
8
|
// src/queue.ts
|
|
2
|
-
function
|
|
9
|
+
function withStatementId(statement) {
|
|
10
|
+
const trimmed = statement.id?.trim();
|
|
11
|
+
if (trimmed) {
|
|
12
|
+
if (trimmed !== statement.id) statement.id = trimmed;
|
|
13
|
+
return statement;
|
|
14
|
+
}
|
|
15
|
+
statement.id = cryptoRandomId();
|
|
16
|
+
return statement;
|
|
17
|
+
}
|
|
18
|
+
var DEFAULT_MAX_QUEUE_SIZE = 1e3;
|
|
19
|
+
function createInMemoryXAPIQueue(opts) {
|
|
20
|
+
const maxSize = opts?.maxSize ?? DEFAULT_MAX_QUEUE_SIZE;
|
|
3
21
|
const buffer = [];
|
|
4
22
|
let flushInFlight = null;
|
|
23
|
+
let headInFlight = false;
|
|
24
|
+
const notifyDepth = () => {
|
|
25
|
+
opts?.onDepth?.(buffer.length);
|
|
26
|
+
};
|
|
5
27
|
const runFlush = async (transport) => {
|
|
6
28
|
while (buffer.length) {
|
|
7
29
|
const statement = buffer[0];
|
|
30
|
+
headInFlight = true;
|
|
8
31
|
try {
|
|
9
32
|
await transport(statement);
|
|
10
33
|
buffer.shift();
|
|
34
|
+
notifyDepth();
|
|
11
35
|
} catch {
|
|
12
36
|
return;
|
|
37
|
+
} finally {
|
|
38
|
+
headInFlight = false;
|
|
13
39
|
}
|
|
14
40
|
}
|
|
15
41
|
};
|
|
16
42
|
return {
|
|
17
43
|
enqueue: (statement) => {
|
|
18
|
-
|
|
19
|
-
buffer.
|
|
44
|
+
const normalized = withStatementId(statement);
|
|
45
|
+
if (buffer.some((s) => s.id === normalized.id)) return;
|
|
46
|
+
if (buffer.length >= maxSize) {
|
|
47
|
+
if (headInFlight && buffer.length <= 1) {
|
|
48
|
+
opts?.onCap?.();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (headInFlight) {
|
|
52
|
+
buffer.splice(1, 1);
|
|
53
|
+
} else {
|
|
54
|
+
buffer.shift();
|
|
55
|
+
}
|
|
56
|
+
opts?.onCap?.();
|
|
57
|
+
}
|
|
58
|
+
buffer.push(normalized);
|
|
59
|
+
notifyDepth();
|
|
20
60
|
},
|
|
21
61
|
size: () => buffer.length,
|
|
22
62
|
flush: async (transport) => {
|
|
@@ -34,19 +74,12 @@ function createInMemoryXAPIQueue() {
|
|
|
34
74
|
import { nowIso } from "@lessonkit/core";
|
|
35
75
|
|
|
36
76
|
// src/telemetryMap.ts
|
|
37
|
-
import { assertNever } from "@lessonkit/core";
|
|
38
77
|
import { buildLessonkitUrn } from "@lessonkit/core";
|
|
39
78
|
|
|
40
|
-
// src/id.ts
|
|
41
|
-
function cryptoRandomId() {
|
|
42
|
-
const g = globalThis;
|
|
43
|
-
if (g.crypto?.randomUUID) return g.crypto.randomUUID();
|
|
44
|
-
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 11)}`;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
79
|
// src/duration.ts
|
|
48
80
|
function formatDurationMs(ms) {
|
|
49
|
-
|
|
81
|
+
if (!Number.isFinite(ms) || ms < 0) return void 0;
|
|
82
|
+
const safe = ms;
|
|
50
83
|
const seconds = safe / 1e3;
|
|
51
84
|
const fixed = Number.isInteger(seconds) ? String(seconds) : seconds.toFixed(3).replace(/0+$/, "").replace(/\.$/, "");
|
|
52
85
|
return `PT${fixed}S`;
|
|
@@ -59,130 +92,22 @@ var XAPIVerbs = {
|
|
|
59
92
|
answered: "http://adlnet.gov/expapi/verbs/answered",
|
|
60
93
|
experienced: "http://adlnet.gov/expapi/verbs/experienced"
|
|
61
94
|
};
|
|
62
|
-
function
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
case "lesson_completed": {
|
|
78
|
-
const lessonId = event.lessonId;
|
|
79
|
-
const data = event.data;
|
|
80
|
-
const result = {};
|
|
81
|
-
if (typeof data?.durationMs === "number") {
|
|
82
|
-
result.duration = formatDurationMs(data.durationMs);
|
|
83
|
-
}
|
|
84
|
-
if (typeof data?.success === "boolean") result.success = data.success;
|
|
85
|
-
if (typeof data?.score === "number" || typeof data?.maxScore === "number") {
|
|
86
|
-
const max = typeof data.maxScore === "number" ? data.maxScore : void 0;
|
|
87
|
-
const raw = typeof data.score === "number" ? data.score : void 0;
|
|
88
|
-
result.score = {
|
|
89
|
-
raw,
|
|
90
|
-
max,
|
|
91
|
-
min: 0,
|
|
92
|
-
scaled: typeof raw === "number" && typeof max === "number" && max > 0 ? raw / max : void 0
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
return statementFor(buildLessonkitUrn({ courseId, lessonId }), XAPIVerbs.completed, event.timestamp, {
|
|
96
|
-
result: Object.keys(result).length ? result : void 0
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
case "lesson_time_on_task":
|
|
100
|
-
return null;
|
|
101
|
-
case "quiz_answered": {
|
|
102
|
-
const lessonId = event.lessonId;
|
|
103
|
-
const checkId = event.data.checkId;
|
|
104
|
-
const result = {};
|
|
105
|
-
if (typeof event.data.correct === "boolean") {
|
|
106
|
-
result.success = event.data.correct;
|
|
107
|
-
}
|
|
108
|
-
return statementFor(
|
|
109
|
-
buildLessonkitUrn({ courseId, lessonId, checkId }),
|
|
110
|
-
XAPIVerbs.answered,
|
|
111
|
-
event.timestamp,
|
|
112
|
-
{ result: Object.keys(result).length ? result : void 0 }
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
case "quiz_completed": {
|
|
116
|
-
const lessonId = event.lessonId;
|
|
117
|
-
const checkId = event.data.checkId;
|
|
118
|
-
const { score, maxScore } = event.data;
|
|
119
|
-
const result = {};
|
|
120
|
-
if (typeof score === "number" || typeof maxScore === "number") {
|
|
121
|
-
const max = typeof maxScore === "number" ? maxScore : void 0;
|
|
122
|
-
const raw = typeof score === "number" ? score : void 0;
|
|
123
|
-
result.score = {
|
|
124
|
-
raw,
|
|
125
|
-
max,
|
|
126
|
-
min: 0,
|
|
127
|
-
scaled: typeof raw === "number" && typeof max === "number" && max > 0 ? raw / max : void 0
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
return statementFor(
|
|
131
|
-
buildLessonkitUrn({ courseId, lessonId, checkId }),
|
|
132
|
-
XAPIVerbs.completed,
|
|
133
|
-
event.timestamp,
|
|
134
|
-
{ result: Object.keys(result).length ? result : void 0 }
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
case "assessment_answered": {
|
|
138
|
-
const lessonId = event.lessonId;
|
|
139
|
-
const checkId = event.data.checkId;
|
|
140
|
-
const result = {};
|
|
141
|
-
if (typeof event.data.correct === "boolean") {
|
|
142
|
-
result.success = event.data.correct;
|
|
143
|
-
}
|
|
144
|
-
return statementFor(
|
|
145
|
-
buildLessonkitUrn({ courseId, lessonId, checkId }),
|
|
146
|
-
XAPIVerbs.answered,
|
|
147
|
-
event.timestamp,
|
|
148
|
-
{ result: Object.keys(result).length ? result : void 0 }
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
case "assessment_completed": {
|
|
152
|
-
const lessonId = event.lessonId;
|
|
153
|
-
const checkId = event.data.checkId;
|
|
154
|
-
const { score, maxScore } = event.data;
|
|
155
|
-
const result = {};
|
|
156
|
-
if (typeof score === "number" || typeof maxScore === "number") {
|
|
157
|
-
const max = typeof maxScore === "number" ? maxScore : void 0;
|
|
158
|
-
const raw = typeof score === "number" ? score : void 0;
|
|
159
|
-
result.score = {
|
|
160
|
-
raw,
|
|
161
|
-
max,
|
|
162
|
-
min: 0,
|
|
163
|
-
scaled: typeof raw === "number" && typeof max === "number" && max > 0 ? raw / max : void 0
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
return statementFor(
|
|
167
|
-
buildLessonkitUrn({ courseId, lessonId, checkId }),
|
|
168
|
-
XAPIVerbs.completed,
|
|
169
|
-
event.timestamp,
|
|
170
|
-
{ result: Object.keys(result).length ? result : void 0 }
|
|
171
|
-
);
|
|
172
|
-
}
|
|
173
|
-
case "interaction": {
|
|
174
|
-
const lessonId = event.lessonId;
|
|
175
|
-
const blockId = event.data?.blockId;
|
|
176
|
-
if (!lessonId || !blockId) return null;
|
|
177
|
-
return statementFor(
|
|
178
|
-
buildLessonkitUrn({ courseId, lessonId, blockId }),
|
|
179
|
-
XAPIVerbs.experienced,
|
|
180
|
-
event.timestamp
|
|
181
|
-
);
|
|
182
|
-
}
|
|
183
|
-
default:
|
|
184
|
-
return assertNever(event, "Unhandled telemetry event");
|
|
95
|
+
function buildXapiScoreResult(opts) {
|
|
96
|
+
const max = typeof opts.maxScore === "number" ? opts.maxScore : void 0;
|
|
97
|
+
const raw = typeof opts.score === "number" ? opts.score : void 0;
|
|
98
|
+
if (typeof raw !== "number" && typeof max !== "number") return void 0;
|
|
99
|
+
if (typeof raw === "number" && !Number.isFinite(raw) || typeof max === "number" && !Number.isFinite(max)) {
|
|
100
|
+
return void 0;
|
|
101
|
+
}
|
|
102
|
+
if (typeof max === "number" && max <= 0) return void 0;
|
|
103
|
+
if (typeof raw === "number" && raw < 0) return void 0;
|
|
104
|
+
const result = { min: 0 };
|
|
105
|
+
if (typeof raw === "number") result.raw = raw;
|
|
106
|
+
if (typeof max === "number") result.max = max;
|
|
107
|
+
if (typeof raw === "number" && typeof max === "number" && max > 0 && raw <= max) {
|
|
108
|
+
result.scaled = raw / max;
|
|
185
109
|
}
|
|
110
|
+
return result;
|
|
186
111
|
}
|
|
187
112
|
function statementFor(objectId, verb, timestamp, extra) {
|
|
188
113
|
return {
|
|
@@ -194,8 +119,126 @@ function statementFor(objectId, verb, timestamp, extra) {
|
|
|
194
119
|
context: extra?.context
|
|
195
120
|
};
|
|
196
121
|
}
|
|
122
|
+
function experiencedBlockStatement(courseId, lessonId, blockId, timestamp) {
|
|
123
|
+
return statementFor(
|
|
124
|
+
buildLessonkitUrn({ courseId, lessonId, blockId }),
|
|
125
|
+
XAPIVerbs.experienced,
|
|
126
|
+
timestamp
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
var experiencedBlockMapper = (event, ctx) => {
|
|
130
|
+
if (event.name === "interaction") {
|
|
131
|
+
const lessonId2 = event.lessonId;
|
|
132
|
+
const blockId2 = event.data?.blockId;
|
|
133
|
+
if (!lessonId2 || !blockId2 || typeof blockId2 !== "string") return null;
|
|
134
|
+
return experiencedBlockStatement(ctx.courseId, lessonId2, blockId2, ctx.timestamp);
|
|
135
|
+
}
|
|
136
|
+
const lessonId = event.lessonId;
|
|
137
|
+
const blockId = "data" in event && event.data && "blockId" in event.data ? event.data.blockId : void 0;
|
|
138
|
+
if (!lessonId || !blockId || typeof blockId !== "string") return null;
|
|
139
|
+
return experiencedBlockStatement(ctx.courseId, lessonId, blockId, ctx.timestamp);
|
|
140
|
+
};
|
|
141
|
+
var TELEMETRY_XAPI_MAPPERS = {
|
|
142
|
+
course_started: (_event, ctx) => statementFor(buildLessonkitUrn({ courseId: ctx.courseId }), XAPIVerbs.initialized, ctx.timestamp),
|
|
143
|
+
course_completed: (_event, ctx) => statementFor(buildLessonkitUrn({ courseId: ctx.courseId }), XAPIVerbs.completed, ctx.timestamp),
|
|
144
|
+
lesson_started: (event, ctx) => {
|
|
145
|
+
const lessonId = event.name === "lesson_started" ? event.lessonId : void 0;
|
|
146
|
+
if (!lessonId) return null;
|
|
147
|
+
return statementFor(
|
|
148
|
+
buildLessonkitUrn({ courseId: ctx.courseId, lessonId }),
|
|
149
|
+
XAPIVerbs.initialized,
|
|
150
|
+
ctx.timestamp
|
|
151
|
+
);
|
|
152
|
+
},
|
|
153
|
+
lesson_completed: (event, ctx) => {
|
|
154
|
+
if (event.name !== "lesson_completed") return null;
|
|
155
|
+
const lessonId = event.lessonId;
|
|
156
|
+
const data = event.data;
|
|
157
|
+
const result = {};
|
|
158
|
+
if (typeof data?.durationMs === "number") {
|
|
159
|
+
const duration = formatDurationMs(data.durationMs);
|
|
160
|
+
if (duration !== void 0) result.duration = duration;
|
|
161
|
+
}
|
|
162
|
+
if (typeof data?.success === "boolean") result.success = data.success;
|
|
163
|
+
const score = buildXapiScoreResult({ score: data?.score, maxScore: data?.maxScore });
|
|
164
|
+
if (score) result.score = score;
|
|
165
|
+
return statementFor(buildLessonkitUrn({ courseId: ctx.courseId, lessonId }), XAPIVerbs.completed, ctx.timestamp, {
|
|
166
|
+
result: Object.keys(result).length ? result : void 0
|
|
167
|
+
});
|
|
168
|
+
},
|
|
169
|
+
lesson_time_on_task: () => null,
|
|
170
|
+
quiz_answered: (event, ctx) => {
|
|
171
|
+
if (event.name !== "quiz_answered") return null;
|
|
172
|
+
const result = {};
|
|
173
|
+
if (typeof event.data.correct === "boolean") result.success = event.data.correct;
|
|
174
|
+
return statementFor(
|
|
175
|
+
buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
176
|
+
XAPIVerbs.answered,
|
|
177
|
+
ctx.timestamp,
|
|
178
|
+
{ result: Object.keys(result).length ? result : void 0 }
|
|
179
|
+
);
|
|
180
|
+
},
|
|
181
|
+
quiz_completed: (event, ctx) => {
|
|
182
|
+
if (event.name !== "quiz_completed") return null;
|
|
183
|
+
const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
|
|
184
|
+
return statementFor(
|
|
185
|
+
buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
186
|
+
XAPIVerbs.completed,
|
|
187
|
+
ctx.timestamp,
|
|
188
|
+
{ result: score ? { score } : void 0 }
|
|
189
|
+
);
|
|
190
|
+
},
|
|
191
|
+
assessment_answered: (event, ctx) => {
|
|
192
|
+
if (event.name !== "assessment_answered") return null;
|
|
193
|
+
const result = {};
|
|
194
|
+
if (typeof event.data.correct === "boolean") result.success = event.data.correct;
|
|
195
|
+
return statementFor(
|
|
196
|
+
buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
197
|
+
XAPIVerbs.answered,
|
|
198
|
+
ctx.timestamp,
|
|
199
|
+
{ result: Object.keys(result).length ? result : void 0 }
|
|
200
|
+
);
|
|
201
|
+
},
|
|
202
|
+
assessment_completed: (event, ctx) => {
|
|
203
|
+
if (event.name !== "assessment_completed") return null;
|
|
204
|
+
const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
|
|
205
|
+
return statementFor(
|
|
206
|
+
buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
207
|
+
XAPIVerbs.completed,
|
|
208
|
+
ctx.timestamp,
|
|
209
|
+
{ result: score ? { score } : void 0 }
|
|
210
|
+
);
|
|
211
|
+
},
|
|
212
|
+
interaction: experiencedBlockMapper,
|
|
213
|
+
book_page_viewed: experiencedBlockMapper,
|
|
214
|
+
slide_viewed: experiencedBlockMapper,
|
|
215
|
+
compound_page_viewed: experiencedBlockMapper,
|
|
216
|
+
hotspot_opened: experiencedBlockMapper,
|
|
217
|
+
accordion_section_toggled: experiencedBlockMapper,
|
|
218
|
+
flashcard_flipped: experiencedBlockMapper,
|
|
219
|
+
image_slider_changed: experiencedBlockMapper
|
|
220
|
+
};
|
|
221
|
+
function telemetryEventToXAPIStatement(event) {
|
|
222
|
+
const mapper = TELEMETRY_XAPI_MAPPERS[event.name];
|
|
223
|
+
if (!mapper) {
|
|
224
|
+
throw new Error(`Unhandled telemetry event: ${event.name}`);
|
|
225
|
+
}
|
|
226
|
+
return mapper(event, {
|
|
227
|
+
courseId: event.courseId,
|
|
228
|
+
timestamp: event.timestamp
|
|
229
|
+
});
|
|
230
|
+
}
|
|
197
231
|
|
|
198
232
|
// src/client.ts
|
|
233
|
+
function withStatementId2(statement) {
|
|
234
|
+
const trimmed = statement.id?.trim();
|
|
235
|
+
if (trimmed) {
|
|
236
|
+
if (trimmed !== statement.id) statement.id = trimmed;
|
|
237
|
+
return statement;
|
|
238
|
+
}
|
|
239
|
+
statement.id = cryptoRandomId();
|
|
240
|
+
return statement;
|
|
241
|
+
}
|
|
199
242
|
function isDevEnvironment() {
|
|
200
243
|
const g = globalThis;
|
|
201
244
|
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
@@ -203,13 +246,18 @@ function isDevEnvironment() {
|
|
|
203
246
|
function createXAPIClient(opts) {
|
|
204
247
|
const transport = opts?.transport;
|
|
205
248
|
const courseId = opts?.courseId;
|
|
206
|
-
const queue = opts?.queue ?? createInMemoryXAPIQueue(
|
|
249
|
+
const queue = opts?.queue ?? createInMemoryXAPIQueue({
|
|
250
|
+
maxSize: opts?.maxQueueSize,
|
|
251
|
+
onDepth: opts?.onQueueDepth,
|
|
252
|
+
onCap: opts?.onQueueCap
|
|
253
|
+
});
|
|
207
254
|
let warnedNoTransport = false;
|
|
208
255
|
let warnedTransportFailure = false;
|
|
209
256
|
const inflightById = /* @__PURE__ */ new Map();
|
|
210
257
|
const sendOrQueue = (statement) => {
|
|
258
|
+
const normalized = withStatementId2(statement);
|
|
211
259
|
if (!transport) {
|
|
212
|
-
queue.enqueue(
|
|
260
|
+
queue.enqueue(normalized);
|
|
213
261
|
if (isDevEnvironment() && !warnedNoTransport) {
|
|
214
262
|
warnedNoTransport = true;
|
|
215
263
|
console.warn(
|
|
@@ -218,35 +266,45 @@ function createXAPIClient(opts) {
|
|
|
218
266
|
}
|
|
219
267
|
return;
|
|
220
268
|
}
|
|
221
|
-
const existing = inflightById.get(
|
|
269
|
+
const existing = inflightById.get(normalized.id);
|
|
222
270
|
if (existing) {
|
|
223
271
|
void existing.then(
|
|
224
272
|
() => void 0,
|
|
225
273
|
() => {
|
|
226
|
-
sendOrQueue(
|
|
274
|
+
sendOrQueue(normalized);
|
|
227
275
|
}
|
|
228
276
|
);
|
|
229
277
|
return;
|
|
230
278
|
}
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
queue.enqueue(statement);
|
|
279
|
+
const flight = Promise.resolve().then(() => transport(normalized)).catch(() => {
|
|
280
|
+
queue.enqueue(normalized);
|
|
234
281
|
if (isDevEnvironment() && !warnedTransportFailure) {
|
|
235
282
|
warnedTransportFailure = true;
|
|
236
283
|
console.warn(
|
|
237
284
|
"[lessonkit] xAPI transport failed; statement re-queued. Check your LRS endpoint or transport implementation."
|
|
238
285
|
);
|
|
239
286
|
}
|
|
287
|
+
throw new Error("xAPI transport failed");
|
|
240
288
|
}).finally(() => {
|
|
241
|
-
inflightById.delete(
|
|
289
|
+
inflightById.delete(normalized.id);
|
|
290
|
+
});
|
|
291
|
+
inflightById.set(normalized.id, flight);
|
|
292
|
+
void flight.catch(() => {
|
|
242
293
|
});
|
|
243
|
-
inflightById.set(statement.id, transportFlight);
|
|
244
|
-
void flight;
|
|
245
294
|
};
|
|
246
295
|
const emit = (event) => {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
296
|
+
try {
|
|
297
|
+
const statement = telemetryEventToXAPIStatement(event);
|
|
298
|
+
if (!statement) return;
|
|
299
|
+
sendOrQueue(statement);
|
|
300
|
+
} catch (err) {
|
|
301
|
+
if (isDevEnvironment()) {
|
|
302
|
+
console.warn(
|
|
303
|
+
"[lessonkit] xAPI mapping skipped:",
|
|
304
|
+
err instanceof Error ? err.message : err
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
250
308
|
};
|
|
251
309
|
return {
|
|
252
310
|
send: (statement) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lessonkit/xapi",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "xAPI statement generation primitives for LessonKit.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"url": "git+https://github.com/eddiethedean/lessonkit.git",
|
|
10
10
|
"directory": "packages/xapi"
|
|
11
11
|
},
|
|
12
|
-
"homepage": "https://
|
|
12
|
+
"homepage": "https://lessonkit.readthedocs.io/en/latest/reference/xapi.html",
|
|
13
13
|
"bugs": {
|
|
14
14
|
"url": "https://github.com/eddiethedean/lessonkit/issues"
|
|
15
15
|
},
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"lint": "echo \"(no lint configured yet)\""
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@lessonkit/core": "1.
|
|
51
|
+
"@lessonkit/core": "1.3.0"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"tsup": "^8.5.0",
|