@lessonkit/xapi 1.1.0 → 1.2.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 +138 -131
- package/dist/index.d.cts +14 -2
- package/dist/index.d.ts +14 -2
- package/dist/index.js +134 -127
- 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
|
@@ -27,15 +27,21 @@ __export(index_exports, {
|
|
|
27
27
|
module.exports = __toCommonJS(index_exports);
|
|
28
28
|
|
|
29
29
|
// src/queue.ts
|
|
30
|
-
|
|
30
|
+
var DEFAULT_MAX_QUEUE_SIZE = 1e3;
|
|
31
|
+
function createInMemoryXAPIQueue(opts) {
|
|
32
|
+
const maxSize = opts?.maxSize ?? DEFAULT_MAX_QUEUE_SIZE;
|
|
31
33
|
const buffer = [];
|
|
32
34
|
let flushInFlight = null;
|
|
35
|
+
const notifyDepth = () => {
|
|
36
|
+
opts?.onDepth?.(buffer.length);
|
|
37
|
+
};
|
|
33
38
|
const runFlush = async (transport) => {
|
|
34
39
|
while (buffer.length) {
|
|
35
40
|
const statement = buffer[0];
|
|
36
41
|
try {
|
|
37
42
|
await transport(statement);
|
|
38
43
|
buffer.shift();
|
|
44
|
+
notifyDepth();
|
|
39
45
|
} catch {
|
|
40
46
|
return;
|
|
41
47
|
}
|
|
@@ -44,7 +50,12 @@ function createInMemoryXAPIQueue() {
|
|
|
44
50
|
return {
|
|
45
51
|
enqueue: (statement) => {
|
|
46
52
|
if (statement.id && buffer.some((s) => s.id === statement.id)) return;
|
|
53
|
+
if (buffer.length >= maxSize) {
|
|
54
|
+
buffer.shift();
|
|
55
|
+
opts?.onCap?.();
|
|
56
|
+
}
|
|
47
57
|
buffer.push(statement);
|
|
58
|
+
notifyDepth();
|
|
48
59
|
},
|
|
49
60
|
size: () => buffer.length,
|
|
50
61
|
flush: async (transport) => {
|
|
@@ -59,11 +70,10 @@ function createInMemoryXAPIQueue() {
|
|
|
59
70
|
}
|
|
60
71
|
|
|
61
72
|
// src/client.ts
|
|
62
|
-
var
|
|
73
|
+
var import_core2 = require("@lessonkit/core");
|
|
63
74
|
|
|
64
75
|
// src/telemetryMap.ts
|
|
65
76
|
var import_core = require("@lessonkit/core");
|
|
66
|
-
var import_core2 = require("@lessonkit/core");
|
|
67
77
|
|
|
68
78
|
// src/id.ts
|
|
69
79
|
function cryptoRandomId() {
|
|
@@ -87,130 +97,16 @@ var XAPIVerbs = {
|
|
|
87
97
|
answered: "http://adlnet.gov/expapi/verbs/answered",
|
|
88
98
|
experienced: "http://adlnet.gov/expapi/verbs/experienced"
|
|
89
99
|
};
|
|
90
|
-
function
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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");
|
|
213
|
-
}
|
|
100
|
+
function buildXapiScoreResult(opts) {
|
|
101
|
+
const max = typeof opts.maxScore === "number" ? opts.maxScore : void 0;
|
|
102
|
+
const raw = typeof opts.score === "number" ? opts.score : void 0;
|
|
103
|
+
if (typeof raw !== "number" && typeof max !== "number") return void 0;
|
|
104
|
+
return {
|
|
105
|
+
raw,
|
|
106
|
+
max,
|
|
107
|
+
min: 0,
|
|
108
|
+
scaled: typeof raw === "number" && typeof max === "number" && max > 0 ? raw / max : void 0
|
|
109
|
+
};
|
|
214
110
|
}
|
|
215
111
|
function statementFor(objectId, verb, timestamp, extra) {
|
|
216
112
|
return {
|
|
@@ -222,6 +118,113 @@ function statementFor(objectId, verb, timestamp, extra) {
|
|
|
222
118
|
context: extra?.context
|
|
223
119
|
};
|
|
224
120
|
}
|
|
121
|
+
function experiencedBlockStatement(courseId, lessonId, blockId, timestamp) {
|
|
122
|
+
return statementFor(
|
|
123
|
+
(0, import_core.buildLessonkitUrn)({ courseId, lessonId, blockId }),
|
|
124
|
+
XAPIVerbs.experienced,
|
|
125
|
+
timestamp
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
var experiencedBlockMapper = (event, ctx) => {
|
|
129
|
+
if (event.name === "interaction") {
|
|
130
|
+
const lessonId2 = event.lessonId;
|
|
131
|
+
const blockId2 = event.data?.blockId;
|
|
132
|
+
if (!lessonId2 || !blockId2) return null;
|
|
133
|
+
return experiencedBlockStatement(ctx.courseId, lessonId2, blockId2, ctx.timestamp);
|
|
134
|
+
}
|
|
135
|
+
const lessonId = event.lessonId;
|
|
136
|
+
const blockId = "data" in event && event.data && "blockId" in event.data ? event.data.blockId : void 0;
|
|
137
|
+
if (!lessonId || !blockId || typeof blockId !== "string") return null;
|
|
138
|
+
return experiencedBlockStatement(ctx.courseId, lessonId, blockId, ctx.timestamp);
|
|
139
|
+
};
|
|
140
|
+
var TELEMETRY_XAPI_MAPPERS = {
|
|
141
|
+
course_started: (_event, ctx) => statementFor((0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId }), XAPIVerbs.initialized, ctx.timestamp),
|
|
142
|
+
course_completed: (_event, ctx) => statementFor((0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId }), XAPIVerbs.completed, ctx.timestamp),
|
|
143
|
+
lesson_started: (event, ctx) => {
|
|
144
|
+
const lessonId = event.name === "lesson_started" ? event.lessonId : void 0;
|
|
145
|
+
if (!lessonId) return null;
|
|
146
|
+
return statementFor(
|
|
147
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId }),
|
|
148
|
+
XAPIVerbs.initialized,
|
|
149
|
+
ctx.timestamp
|
|
150
|
+
);
|
|
151
|
+
},
|
|
152
|
+
lesson_completed: (event, ctx) => {
|
|
153
|
+
if (event.name !== "lesson_completed") return null;
|
|
154
|
+
const lessonId = event.lessonId;
|
|
155
|
+
const data = event.data;
|
|
156
|
+
const result = {};
|
|
157
|
+
if (typeof data?.durationMs === "number") {
|
|
158
|
+
result.duration = formatDurationMs(data.durationMs);
|
|
159
|
+
}
|
|
160
|
+
if (typeof data?.success === "boolean") result.success = data.success;
|
|
161
|
+
const score = buildXapiScoreResult({ score: data?.score, maxScore: data?.maxScore });
|
|
162
|
+
if (score) result.score = score;
|
|
163
|
+
return statementFor((0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId }), XAPIVerbs.completed, ctx.timestamp, {
|
|
164
|
+
result: Object.keys(result).length ? result : void 0
|
|
165
|
+
});
|
|
166
|
+
},
|
|
167
|
+
lesson_time_on_task: () => null,
|
|
168
|
+
quiz_answered: (event, ctx) => {
|
|
169
|
+
if (event.name !== "quiz_answered") return null;
|
|
170
|
+
const result = {};
|
|
171
|
+
if (typeof event.data.correct === "boolean") result.success = event.data.correct;
|
|
172
|
+
return statementFor(
|
|
173
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
174
|
+
XAPIVerbs.answered,
|
|
175
|
+
ctx.timestamp,
|
|
176
|
+
{ result: Object.keys(result).length ? result : void 0 }
|
|
177
|
+
);
|
|
178
|
+
},
|
|
179
|
+
quiz_completed: (event, ctx) => {
|
|
180
|
+
if (event.name !== "quiz_completed") return null;
|
|
181
|
+
const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
|
|
182
|
+
return statementFor(
|
|
183
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
184
|
+
XAPIVerbs.completed,
|
|
185
|
+
ctx.timestamp,
|
|
186
|
+
{ result: score ? { score } : void 0 }
|
|
187
|
+
);
|
|
188
|
+
},
|
|
189
|
+
assessment_answered: (event, ctx) => {
|
|
190
|
+
if (event.name !== "assessment_answered") return null;
|
|
191
|
+
const result = {};
|
|
192
|
+
if (typeof event.data.correct === "boolean") result.success = event.data.correct;
|
|
193
|
+
return statementFor(
|
|
194
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
195
|
+
XAPIVerbs.answered,
|
|
196
|
+
ctx.timestamp,
|
|
197
|
+
{ result: Object.keys(result).length ? result : void 0 }
|
|
198
|
+
);
|
|
199
|
+
},
|
|
200
|
+
assessment_completed: (event, ctx) => {
|
|
201
|
+
if (event.name !== "assessment_completed") return null;
|
|
202
|
+
const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
|
|
203
|
+
return statementFor(
|
|
204
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
205
|
+
XAPIVerbs.completed,
|
|
206
|
+
ctx.timestamp,
|
|
207
|
+
{ result: score ? { score } : void 0 }
|
|
208
|
+
);
|
|
209
|
+
},
|
|
210
|
+
interaction: experiencedBlockMapper,
|
|
211
|
+
book_page_viewed: experiencedBlockMapper,
|
|
212
|
+
compound_page_viewed: experiencedBlockMapper,
|
|
213
|
+
hotspot_opened: experiencedBlockMapper,
|
|
214
|
+
accordion_section_toggled: experiencedBlockMapper,
|
|
215
|
+
flashcard_flipped: experiencedBlockMapper,
|
|
216
|
+
image_slider_changed: experiencedBlockMapper
|
|
217
|
+
};
|
|
218
|
+
function telemetryEventToXAPIStatement(event) {
|
|
219
|
+
const mapper = TELEMETRY_XAPI_MAPPERS[event.name];
|
|
220
|
+
if (!mapper) {
|
|
221
|
+
throw new Error(`Unhandled telemetry event: ${event.name}`);
|
|
222
|
+
}
|
|
223
|
+
return mapper(event, {
|
|
224
|
+
courseId: event.courseId,
|
|
225
|
+
timestamp: event.timestamp
|
|
226
|
+
});
|
|
227
|
+
}
|
|
225
228
|
|
|
226
229
|
// src/client.ts
|
|
227
230
|
function isDevEnvironment() {
|
|
@@ -231,7 +234,11 @@ function isDevEnvironment() {
|
|
|
231
234
|
function createXAPIClient(opts) {
|
|
232
235
|
const transport = opts?.transport;
|
|
233
236
|
const courseId = opts?.courseId;
|
|
234
|
-
const queue = opts?.queue ?? createInMemoryXAPIQueue(
|
|
237
|
+
const queue = opts?.queue ?? createInMemoryXAPIQueue({
|
|
238
|
+
maxSize: opts?.maxQueueSize,
|
|
239
|
+
onDepth: opts?.onQueueDepth,
|
|
240
|
+
onCap: opts?.onQueueCap
|
|
241
|
+
});
|
|
235
242
|
let warnedNoTransport = false;
|
|
236
243
|
let warnedTransportFailure = false;
|
|
237
244
|
const inflightById = /* @__PURE__ */ new Map();
|
|
@@ -293,7 +300,7 @@ function createXAPIClient(opts) {
|
|
|
293
300
|
if (!courseId) return;
|
|
294
301
|
emit({
|
|
295
302
|
name: "lesson_started",
|
|
296
|
-
timestamp: (0,
|
|
303
|
+
timestamp: (0, import_core2.nowIso)(),
|
|
297
304
|
courseId,
|
|
298
305
|
lessonId,
|
|
299
306
|
data: { lessonId }
|
|
@@ -309,7 +316,7 @@ function createXAPIClient(opts) {
|
|
|
309
316
|
if (!courseId) return;
|
|
310
317
|
emit({
|
|
311
318
|
name: "lesson_completed",
|
|
312
|
-
timestamp: (0,
|
|
319
|
+
timestamp: (0, import_core2.nowIso)(),
|
|
313
320
|
courseId,
|
|
314
321
|
lessonId,
|
|
315
322
|
data: { lessonId, durationMs, score, maxScore, success }
|
|
@@ -319,7 +326,7 @@ function createXAPIClient(opts) {
|
|
|
319
326
|
if (!courseId) return;
|
|
320
327
|
emit({
|
|
321
328
|
name: "course_completed",
|
|
322
|
-
timestamp: (0,
|
|
329
|
+
timestamp: (0, import_core2.nowIso)(),
|
|
323
330
|
courseId
|
|
324
331
|
});
|
|
325
332
|
}
|
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,13 +1,19 @@
|
|
|
1
1
|
// src/queue.ts
|
|
2
|
-
|
|
2
|
+
var DEFAULT_MAX_QUEUE_SIZE = 1e3;
|
|
3
|
+
function createInMemoryXAPIQueue(opts) {
|
|
4
|
+
const maxSize = opts?.maxSize ?? DEFAULT_MAX_QUEUE_SIZE;
|
|
3
5
|
const buffer = [];
|
|
4
6
|
let flushInFlight = null;
|
|
7
|
+
const notifyDepth = () => {
|
|
8
|
+
opts?.onDepth?.(buffer.length);
|
|
9
|
+
};
|
|
5
10
|
const runFlush = async (transport) => {
|
|
6
11
|
while (buffer.length) {
|
|
7
12
|
const statement = buffer[0];
|
|
8
13
|
try {
|
|
9
14
|
await transport(statement);
|
|
10
15
|
buffer.shift();
|
|
16
|
+
notifyDepth();
|
|
11
17
|
} catch {
|
|
12
18
|
return;
|
|
13
19
|
}
|
|
@@ -16,7 +22,12 @@ function createInMemoryXAPIQueue() {
|
|
|
16
22
|
return {
|
|
17
23
|
enqueue: (statement) => {
|
|
18
24
|
if (statement.id && buffer.some((s) => s.id === statement.id)) return;
|
|
25
|
+
if (buffer.length >= maxSize) {
|
|
26
|
+
buffer.shift();
|
|
27
|
+
opts?.onCap?.();
|
|
28
|
+
}
|
|
19
29
|
buffer.push(statement);
|
|
30
|
+
notifyDepth();
|
|
20
31
|
},
|
|
21
32
|
size: () => buffer.length,
|
|
22
33
|
flush: async (transport) => {
|
|
@@ -34,7 +45,6 @@ function createInMemoryXAPIQueue() {
|
|
|
34
45
|
import { nowIso } from "@lessonkit/core";
|
|
35
46
|
|
|
36
47
|
// src/telemetryMap.ts
|
|
37
|
-
import { assertNever } from "@lessonkit/core";
|
|
38
48
|
import { buildLessonkitUrn } from "@lessonkit/core";
|
|
39
49
|
|
|
40
50
|
// src/id.ts
|
|
@@ -59,130 +69,16 @@ var XAPIVerbs = {
|
|
|
59
69
|
answered: "http://adlnet.gov/expapi/verbs/answered",
|
|
60
70
|
experienced: "http://adlnet.gov/expapi/verbs/experienced"
|
|
61
71
|
};
|
|
62
|
-
function
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
buildLessonkitUrn({ courseId, lessonId }),
|
|
73
|
-
XAPIVerbs.initialized,
|
|
74
|
-
event.timestamp
|
|
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");
|
|
185
|
-
}
|
|
72
|
+
function buildXapiScoreResult(opts) {
|
|
73
|
+
const max = typeof opts.maxScore === "number" ? opts.maxScore : void 0;
|
|
74
|
+
const raw = typeof opts.score === "number" ? opts.score : void 0;
|
|
75
|
+
if (typeof raw !== "number" && typeof max !== "number") return void 0;
|
|
76
|
+
return {
|
|
77
|
+
raw,
|
|
78
|
+
max,
|
|
79
|
+
min: 0,
|
|
80
|
+
scaled: typeof raw === "number" && typeof max === "number" && max > 0 ? raw / max : void 0
|
|
81
|
+
};
|
|
186
82
|
}
|
|
187
83
|
function statementFor(objectId, verb, timestamp, extra) {
|
|
188
84
|
return {
|
|
@@ -194,6 +90,113 @@ function statementFor(objectId, verb, timestamp, extra) {
|
|
|
194
90
|
context: extra?.context
|
|
195
91
|
};
|
|
196
92
|
}
|
|
93
|
+
function experiencedBlockStatement(courseId, lessonId, blockId, timestamp) {
|
|
94
|
+
return statementFor(
|
|
95
|
+
buildLessonkitUrn({ courseId, lessonId, blockId }),
|
|
96
|
+
XAPIVerbs.experienced,
|
|
97
|
+
timestamp
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
var experiencedBlockMapper = (event, ctx) => {
|
|
101
|
+
if (event.name === "interaction") {
|
|
102
|
+
const lessonId2 = event.lessonId;
|
|
103
|
+
const blockId2 = event.data?.blockId;
|
|
104
|
+
if (!lessonId2 || !blockId2) return null;
|
|
105
|
+
return experiencedBlockStatement(ctx.courseId, lessonId2, blockId2, ctx.timestamp);
|
|
106
|
+
}
|
|
107
|
+
const lessonId = event.lessonId;
|
|
108
|
+
const blockId = "data" in event && event.data && "blockId" in event.data ? event.data.blockId : void 0;
|
|
109
|
+
if (!lessonId || !blockId || typeof blockId !== "string") return null;
|
|
110
|
+
return experiencedBlockStatement(ctx.courseId, lessonId, blockId, ctx.timestamp);
|
|
111
|
+
};
|
|
112
|
+
var TELEMETRY_XAPI_MAPPERS = {
|
|
113
|
+
course_started: (_event, ctx) => statementFor(buildLessonkitUrn({ courseId: ctx.courseId }), XAPIVerbs.initialized, ctx.timestamp),
|
|
114
|
+
course_completed: (_event, ctx) => statementFor(buildLessonkitUrn({ courseId: ctx.courseId }), XAPIVerbs.completed, ctx.timestamp),
|
|
115
|
+
lesson_started: (event, ctx) => {
|
|
116
|
+
const lessonId = event.name === "lesson_started" ? event.lessonId : void 0;
|
|
117
|
+
if (!lessonId) return null;
|
|
118
|
+
return statementFor(
|
|
119
|
+
buildLessonkitUrn({ courseId: ctx.courseId, lessonId }),
|
|
120
|
+
XAPIVerbs.initialized,
|
|
121
|
+
ctx.timestamp
|
|
122
|
+
);
|
|
123
|
+
},
|
|
124
|
+
lesson_completed: (event, ctx) => {
|
|
125
|
+
if (event.name !== "lesson_completed") return null;
|
|
126
|
+
const lessonId = event.lessonId;
|
|
127
|
+
const data = event.data;
|
|
128
|
+
const result = {};
|
|
129
|
+
if (typeof data?.durationMs === "number") {
|
|
130
|
+
result.duration = formatDurationMs(data.durationMs);
|
|
131
|
+
}
|
|
132
|
+
if (typeof data?.success === "boolean") result.success = data.success;
|
|
133
|
+
const score = buildXapiScoreResult({ score: data?.score, maxScore: data?.maxScore });
|
|
134
|
+
if (score) result.score = score;
|
|
135
|
+
return statementFor(buildLessonkitUrn({ courseId: ctx.courseId, lessonId }), XAPIVerbs.completed, ctx.timestamp, {
|
|
136
|
+
result: Object.keys(result).length ? result : void 0
|
|
137
|
+
});
|
|
138
|
+
},
|
|
139
|
+
lesson_time_on_task: () => null,
|
|
140
|
+
quiz_answered: (event, ctx) => {
|
|
141
|
+
if (event.name !== "quiz_answered") return null;
|
|
142
|
+
const result = {};
|
|
143
|
+
if (typeof event.data.correct === "boolean") result.success = event.data.correct;
|
|
144
|
+
return statementFor(
|
|
145
|
+
buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
146
|
+
XAPIVerbs.answered,
|
|
147
|
+
ctx.timestamp,
|
|
148
|
+
{ result: Object.keys(result).length ? result : void 0 }
|
|
149
|
+
);
|
|
150
|
+
},
|
|
151
|
+
quiz_completed: (event, ctx) => {
|
|
152
|
+
if (event.name !== "quiz_completed") return null;
|
|
153
|
+
const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
|
|
154
|
+
return statementFor(
|
|
155
|
+
buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
156
|
+
XAPIVerbs.completed,
|
|
157
|
+
ctx.timestamp,
|
|
158
|
+
{ result: score ? { score } : void 0 }
|
|
159
|
+
);
|
|
160
|
+
},
|
|
161
|
+
assessment_answered: (event, ctx) => {
|
|
162
|
+
if (event.name !== "assessment_answered") return null;
|
|
163
|
+
const result = {};
|
|
164
|
+
if (typeof event.data.correct === "boolean") result.success = event.data.correct;
|
|
165
|
+
return statementFor(
|
|
166
|
+
buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
167
|
+
XAPIVerbs.answered,
|
|
168
|
+
ctx.timestamp,
|
|
169
|
+
{ result: Object.keys(result).length ? result : void 0 }
|
|
170
|
+
);
|
|
171
|
+
},
|
|
172
|
+
assessment_completed: (event, ctx) => {
|
|
173
|
+
if (event.name !== "assessment_completed") return null;
|
|
174
|
+
const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
|
|
175
|
+
return statementFor(
|
|
176
|
+
buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
177
|
+
XAPIVerbs.completed,
|
|
178
|
+
ctx.timestamp,
|
|
179
|
+
{ result: score ? { score } : void 0 }
|
|
180
|
+
);
|
|
181
|
+
},
|
|
182
|
+
interaction: experiencedBlockMapper,
|
|
183
|
+
book_page_viewed: experiencedBlockMapper,
|
|
184
|
+
compound_page_viewed: experiencedBlockMapper,
|
|
185
|
+
hotspot_opened: experiencedBlockMapper,
|
|
186
|
+
accordion_section_toggled: experiencedBlockMapper,
|
|
187
|
+
flashcard_flipped: experiencedBlockMapper,
|
|
188
|
+
image_slider_changed: experiencedBlockMapper
|
|
189
|
+
};
|
|
190
|
+
function telemetryEventToXAPIStatement(event) {
|
|
191
|
+
const mapper = TELEMETRY_XAPI_MAPPERS[event.name];
|
|
192
|
+
if (!mapper) {
|
|
193
|
+
throw new Error(`Unhandled telemetry event: ${event.name}`);
|
|
194
|
+
}
|
|
195
|
+
return mapper(event, {
|
|
196
|
+
courseId: event.courseId,
|
|
197
|
+
timestamp: event.timestamp
|
|
198
|
+
});
|
|
199
|
+
}
|
|
197
200
|
|
|
198
201
|
// src/client.ts
|
|
199
202
|
function isDevEnvironment() {
|
|
@@ -203,7 +206,11 @@ function isDevEnvironment() {
|
|
|
203
206
|
function createXAPIClient(opts) {
|
|
204
207
|
const transport = opts?.transport;
|
|
205
208
|
const courseId = opts?.courseId;
|
|
206
|
-
const queue = opts?.queue ?? createInMemoryXAPIQueue(
|
|
209
|
+
const queue = opts?.queue ?? createInMemoryXAPIQueue({
|
|
210
|
+
maxSize: opts?.maxQueueSize,
|
|
211
|
+
onDepth: opts?.onQueueDepth,
|
|
212
|
+
onCap: opts?.onQueueCap
|
|
213
|
+
});
|
|
207
214
|
let warnedNoTransport = false;
|
|
208
215
|
let warnedTransportFailure = false;
|
|
209
216
|
const inflightById = /* @__PURE__ */ new Map();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lessonkit/xapi",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.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.2.0"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"tsup": "^8.5.0",
|