@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 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 createInMemoryXAPIQueue() {
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
- if (statement.id && buffer.some((s) => s.id === statement.id)) return;
47
- buffer.push(statement);
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 import_core3 = require("@lessonkit/core");
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
- const safe = Math.max(0, ms);
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 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 "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(statement);
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(statement.id);
297
+ const existing = inflightById.get(normalized.id);
250
298
  if (existing) {
251
299
  void existing.then(
252
300
  () => void 0,
253
301
  () => {
254
- sendOrQueue(statement);
302
+ sendOrQueue(normalized);
255
303
  }
256
304
  );
257
305
  return;
258
306
  }
259
- const transportFlight = Promise.resolve().then(() => transport(statement));
260
- const flight = transportFlight.catch(() => {
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(statement.id);
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
- const statement = telemetryEventToXAPIStatement(event);
276
- if (!statement) return;
277
- sendOrQueue(statement);
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, import_core3.nowIso)(),
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, import_core3.nowIso)(),
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, import_core3.nowIso)(),
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
- 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,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 createInMemoryXAPIQueue() {
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
- if (statement.id && buffer.some((s) => s.id === statement.id)) return;
19
- buffer.push(statement);
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
- const safe = Math.max(0, ms);
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 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 "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(statement);
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(statement.id);
269
+ const existing = inflightById.get(normalized.id);
222
270
  if (existing) {
223
271
  void existing.then(
224
272
  () => void 0,
225
273
  () => {
226
- sendOrQueue(statement);
274
+ sendOrQueue(normalized);
227
275
  }
228
276
  );
229
277
  return;
230
278
  }
231
- const transportFlight = Promise.resolve().then(() => transport(statement));
232
- const flight = transportFlight.catch(() => {
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(statement.id);
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
- const statement = telemetryEventToXAPIStatement(event);
248
- if (!statement) return;
249
- sendOrQueue(statement);
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.1.0",
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://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.1.0"
51
+ "@lessonkit/core": "1.3.0"
52
52
  },
53
53
  "devDependencies": {
54
54
  "tsup": "^8.5.0",