@lessonkit/xapi 1.0.2 → 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 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
- function createInMemoryXAPIQueue() {
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 import_core3 = require("@lessonkit/core");
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,94 +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 telemetryEventToXAPIStatement(event) {
91
- const { courseId } = event;
92
- switch (event.name) {
93
- case "course_started":
94
- return statementFor((0, import_core2.buildLessonkitUrn)({ courseId }), XAPIVerbs.initialized, event.timestamp);
95
- case "course_completed":
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 "interaction": {
166
- const lessonId = event.lessonId;
167
- const blockId = event.data?.blockId;
168
- if (!lessonId || !blockId) return null;
169
- return statementFor(
170
- (0, import_core2.buildLessonkitUrn)({ courseId, lessonId, blockId }),
171
- XAPIVerbs.experienced,
172
- event.timestamp
173
- );
174
- }
175
- default:
176
- return (0, import_core.assertNever)(event, "Unhandled telemetry event");
177
- }
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
+ };
178
110
  }
179
111
  function statementFor(objectId, verb, timestamp, extra) {
180
112
  return {
@@ -186,6 +118,113 @@ function statementFor(objectId, verb, timestamp, extra) {
186
118
  context: extra?.context
187
119
  };
188
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
+ }
189
228
 
190
229
  // src/client.ts
191
230
  function isDevEnvironment() {
@@ -195,7 +234,11 @@ function isDevEnvironment() {
195
234
  function createXAPIClient(opts) {
196
235
  const transport = opts?.transport;
197
236
  const courseId = opts?.courseId;
198
- const queue = opts?.queue ?? createInMemoryXAPIQueue();
237
+ const queue = opts?.queue ?? createInMemoryXAPIQueue({
238
+ maxSize: opts?.maxQueueSize,
239
+ onDepth: opts?.onQueueDepth,
240
+ onCap: opts?.onQueueCap
241
+ });
199
242
  let warnedNoTransport = false;
200
243
  let warnedTransportFailure = false;
201
244
  const inflightById = /* @__PURE__ */ new Map();
@@ -237,7 +280,8 @@ function createXAPIClient(opts) {
237
280
  };
238
281
  const emit = (event) => {
239
282
  const statement = telemetryEventToXAPIStatement(event);
240
- if (statement) sendOrQueue(statement);
283
+ if (!statement) return;
284
+ sendOrQueue(statement);
241
285
  };
242
286
  return {
243
287
  send: (statement) => {
@@ -256,7 +300,7 @@ function createXAPIClient(opts) {
256
300
  if (!courseId) return;
257
301
  emit({
258
302
  name: "lesson_started",
259
- timestamp: (0, import_core3.nowIso)(),
303
+ timestamp: (0, import_core2.nowIso)(),
260
304
  courseId,
261
305
  lessonId,
262
306
  data: { lessonId }
@@ -272,7 +316,7 @@ function createXAPIClient(opts) {
272
316
  if (!courseId) return;
273
317
  emit({
274
318
  name: "lesson_completed",
275
- timestamp: (0, import_core3.nowIso)(),
319
+ timestamp: (0, import_core2.nowIso)(),
276
320
  courseId,
277
321
  lessonId,
278
322
  data: { lessonId, durationMs, score, maxScore, success }
@@ -282,7 +326,7 @@ function createXAPIClient(opts) {
282
326
  if (!courseId) return;
283
327
  emit({
284
328
  name: "course_completed",
285
- timestamp: (0, import_core3.nowIso)(),
329
+ timestamp: (0, import_core2.nowIso)(),
286
330
  courseId
287
331
  });
288
332
  }
package/dist/index.d.cts CHANGED
@@ -52,12 +52,24 @@ type XAPIClient = {
52
52
  completeCourse: () => void;
53
53
  };
54
54
 
55
- declare function createInMemoryXAPIQueue(): XAPIQueue;
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
- declare function createInMemoryXAPIQueue(): XAPIQueue;
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
- function createInMemoryXAPIQueue() {
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,94 +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 telemetryEventToXAPIStatement(event) {
63
- const { courseId } = event;
64
- switch (event.name) {
65
- case "course_started":
66
- return statementFor(buildLessonkitUrn({ courseId }), XAPIVerbs.initialized, event.timestamp);
67
- case "course_completed":
68
- return statementFor(buildLessonkitUrn({ courseId }), XAPIVerbs.completed, event.timestamp);
69
- case "lesson_started": {
70
- const lessonId = event.lessonId;
71
- return statementFor(
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 "interaction": {
138
- const lessonId = event.lessonId;
139
- const blockId = event.data?.blockId;
140
- if (!lessonId || !blockId) return null;
141
- return statementFor(
142
- buildLessonkitUrn({ courseId, lessonId, blockId }),
143
- XAPIVerbs.experienced,
144
- event.timestamp
145
- );
146
- }
147
- default:
148
- return assertNever(event, "Unhandled telemetry event");
149
- }
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
+ };
150
82
  }
151
83
  function statementFor(objectId, verb, timestamp, extra) {
152
84
  return {
@@ -158,6 +90,113 @@ function statementFor(objectId, verb, timestamp, extra) {
158
90
  context: extra?.context
159
91
  };
160
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
+ }
161
200
 
162
201
  // src/client.ts
163
202
  function isDevEnvironment() {
@@ -167,7 +206,11 @@ function isDevEnvironment() {
167
206
  function createXAPIClient(opts) {
168
207
  const transport = opts?.transport;
169
208
  const courseId = opts?.courseId;
170
- const queue = opts?.queue ?? createInMemoryXAPIQueue();
209
+ const queue = opts?.queue ?? createInMemoryXAPIQueue({
210
+ maxSize: opts?.maxQueueSize,
211
+ onDepth: opts?.onQueueDepth,
212
+ onCap: opts?.onQueueCap
213
+ });
171
214
  let warnedNoTransport = false;
172
215
  let warnedTransportFailure = false;
173
216
  const inflightById = /* @__PURE__ */ new Map();
@@ -209,7 +252,8 @@ function createXAPIClient(opts) {
209
252
  };
210
253
  const emit = (event) => {
211
254
  const statement = telemetryEventToXAPIStatement(event);
212
- if (statement) sendOrQueue(statement);
255
+ if (!statement) return;
256
+ sendOrQueue(statement);
213
257
  };
214
258
  return {
215
259
  send: (statement) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessonkit/xapi",
3
- "version": "1.0.2",
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://github.com/eddiethedean/lessonkit",
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.0.2"
51
+ "@lessonkit/core": "1.2.0"
52
52
  },
53
53
  "devDependencies": {
54
54
  "tsup": "^8.5.0",