@lessonkit/xapi 1.2.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/dist/index.cjs CHANGED
@@ -26,35 +26,64 @@ __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
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
+ }
30
46
  var DEFAULT_MAX_QUEUE_SIZE = 1e3;
31
47
  function createInMemoryXAPIQueue(opts) {
32
48
  const maxSize = opts?.maxSize ?? DEFAULT_MAX_QUEUE_SIZE;
33
49
  const buffer = [];
34
50
  let flushInFlight = null;
51
+ let headInFlight = false;
35
52
  const notifyDepth = () => {
36
53
  opts?.onDepth?.(buffer.length);
37
54
  };
38
55
  const runFlush = async (transport) => {
39
56
  while (buffer.length) {
40
57
  const statement = buffer[0];
58
+ headInFlight = true;
41
59
  try {
42
60
  await transport(statement);
43
61
  buffer.shift();
44
62
  notifyDepth();
45
63
  } catch {
46
64
  return;
65
+ } finally {
66
+ headInFlight = false;
47
67
  }
48
68
  }
49
69
  };
50
70
  return {
51
71
  enqueue: (statement) => {
52
- if (statement.id && buffer.some((s) => s.id === statement.id)) return;
72
+ const normalized = withStatementId(statement);
73
+ if (buffer.some((s) => s.id === normalized.id)) return;
53
74
  if (buffer.length >= maxSize) {
54
- buffer.shift();
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
+ }
55
84
  opts?.onCap?.();
56
85
  }
57
- buffer.push(statement);
86
+ buffer.push(normalized);
58
87
  notifyDepth();
59
88
  },
60
89
  size: () => buffer.length,
@@ -75,16 +104,10 @@ var import_core2 = require("@lessonkit/core");
75
104
  // src/telemetryMap.ts
76
105
  var import_core = require("@lessonkit/core");
77
106
 
78
- // src/id.ts
79
- function cryptoRandomId() {
80
- const g = globalThis;
81
- if (g.crypto?.randomUUID) return g.crypto.randomUUID();
82
- return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 11)}`;
83
- }
84
-
85
107
  // src/duration.ts
86
108
  function formatDurationMs(ms) {
87
- const safe = Math.max(0, ms);
109
+ if (!Number.isFinite(ms) || ms < 0) return void 0;
110
+ const safe = ms;
88
111
  const seconds = safe / 1e3;
89
112
  const fixed = Number.isInteger(seconds) ? String(seconds) : seconds.toFixed(3).replace(/0+$/, "").replace(/\.$/, "");
90
113
  return `PT${fixed}S`;
@@ -101,12 +124,18 @@ function buildXapiScoreResult(opts) {
101
124
  const max = typeof opts.maxScore === "number" ? opts.maxScore : void 0;
102
125
  const raw = typeof opts.score === "number" ? opts.score : void 0;
103
126
  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
- };
127
+ if (typeof raw === "number" && !Number.isFinite(raw) || typeof max === "number" && !Number.isFinite(max)) {
128
+ return void 0;
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;
110
139
  }
111
140
  function statementFor(objectId, verb, timestamp, extra) {
112
141
  return {
@@ -129,7 +158,7 @@ var experiencedBlockMapper = (event, ctx) => {
129
158
  if (event.name === "interaction") {
130
159
  const lessonId2 = event.lessonId;
131
160
  const blockId2 = event.data?.blockId;
132
- if (!lessonId2 || !blockId2) return null;
161
+ if (!lessonId2 || !blockId2 || typeof blockId2 !== "string") return null;
133
162
  return experiencedBlockStatement(ctx.courseId, lessonId2, blockId2, ctx.timestamp);
134
163
  }
135
164
  const lessonId = event.lessonId;
@@ -155,7 +184,8 @@ var TELEMETRY_XAPI_MAPPERS = {
155
184
  const data = event.data;
156
185
  const result = {};
157
186
  if (typeof data?.durationMs === "number") {
158
- result.duration = formatDurationMs(data.durationMs);
187
+ const duration = formatDurationMs(data.durationMs);
188
+ if (duration !== void 0) result.duration = duration;
159
189
  }
160
190
  if (typeof data?.success === "boolean") result.success = data.success;
161
191
  const score = buildXapiScoreResult({ score: data?.score, maxScore: data?.maxScore });
@@ -209,6 +239,7 @@ var TELEMETRY_XAPI_MAPPERS = {
209
239
  },
210
240
  interaction: experiencedBlockMapper,
211
241
  book_page_viewed: experiencedBlockMapper,
242
+ slide_viewed: experiencedBlockMapper,
212
243
  compound_page_viewed: experiencedBlockMapper,
213
244
  hotspot_opened: experiencedBlockMapper,
214
245
  accordion_section_toggled: experiencedBlockMapper,
@@ -227,6 +258,15 @@ function telemetryEventToXAPIStatement(event) {
227
258
  }
228
259
 
229
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
+ }
230
270
  function isDevEnvironment() {
231
271
  const g = globalThis;
232
272
  return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
@@ -243,8 +283,9 @@ function createXAPIClient(opts) {
243
283
  let warnedTransportFailure = false;
244
284
  const inflightById = /* @__PURE__ */ new Map();
245
285
  const sendOrQueue = (statement) => {
286
+ const normalized = withStatementId2(statement);
246
287
  if (!transport) {
247
- queue.enqueue(statement);
288
+ queue.enqueue(normalized);
248
289
  if (isDevEnvironment() && !warnedNoTransport) {
249
290
  warnedNoTransport = true;
250
291
  console.warn(
@@ -253,35 +294,45 @@ function createXAPIClient(opts) {
253
294
  }
254
295
  return;
255
296
  }
256
- const existing = inflightById.get(statement.id);
297
+ const existing = inflightById.get(normalized.id);
257
298
  if (existing) {
258
299
  void existing.then(
259
300
  () => void 0,
260
301
  () => {
261
- sendOrQueue(statement);
302
+ sendOrQueue(normalized);
262
303
  }
263
304
  );
264
305
  return;
265
306
  }
266
- const transportFlight = Promise.resolve().then(() => transport(statement));
267
- const flight = transportFlight.catch(() => {
268
- queue.enqueue(statement);
307
+ const flight = Promise.resolve().then(() => transport(normalized)).catch(() => {
308
+ queue.enqueue(normalized);
269
309
  if (isDevEnvironment() && !warnedTransportFailure) {
270
310
  warnedTransportFailure = true;
271
311
  console.warn(
272
312
  "[lessonkit] xAPI transport failed; statement re-queued. Check your LRS endpoint or transport implementation."
273
313
  );
274
314
  }
315
+ throw new Error("xAPI transport failed");
275
316
  }).finally(() => {
276
- inflightById.delete(statement.id);
317
+ inflightById.delete(normalized.id);
318
+ });
319
+ inflightById.set(normalized.id, flight);
320
+ void flight.catch(() => {
277
321
  });
278
- inflightById.set(statement.id, transportFlight);
279
- void flight;
280
322
  };
281
323
  const emit = (event) => {
282
- const statement = telemetryEventToXAPIStatement(event);
283
- if (!statement) return;
284
- 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
+ }
285
336
  };
286
337
  return {
287
338
  send: (statement) => {
package/dist/index.js CHANGED
@@ -1,32 +1,61 @@
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
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
+ }
2
18
  var DEFAULT_MAX_QUEUE_SIZE = 1e3;
3
19
  function createInMemoryXAPIQueue(opts) {
4
20
  const maxSize = opts?.maxSize ?? DEFAULT_MAX_QUEUE_SIZE;
5
21
  const buffer = [];
6
22
  let flushInFlight = null;
23
+ let headInFlight = false;
7
24
  const notifyDepth = () => {
8
25
  opts?.onDepth?.(buffer.length);
9
26
  };
10
27
  const runFlush = async (transport) => {
11
28
  while (buffer.length) {
12
29
  const statement = buffer[0];
30
+ headInFlight = true;
13
31
  try {
14
32
  await transport(statement);
15
33
  buffer.shift();
16
34
  notifyDepth();
17
35
  } catch {
18
36
  return;
37
+ } finally {
38
+ headInFlight = false;
19
39
  }
20
40
  }
21
41
  };
22
42
  return {
23
43
  enqueue: (statement) => {
24
- if (statement.id && buffer.some((s) => s.id === statement.id)) return;
44
+ const normalized = withStatementId(statement);
45
+ if (buffer.some((s) => s.id === normalized.id)) return;
25
46
  if (buffer.length >= maxSize) {
26
- buffer.shift();
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
+ }
27
56
  opts?.onCap?.();
28
57
  }
29
- buffer.push(statement);
58
+ buffer.push(normalized);
30
59
  notifyDepth();
31
60
  },
32
61
  size: () => buffer.length,
@@ -47,16 +76,10 @@ import { nowIso } from "@lessonkit/core";
47
76
  // src/telemetryMap.ts
48
77
  import { buildLessonkitUrn } from "@lessonkit/core";
49
78
 
50
- // src/id.ts
51
- function cryptoRandomId() {
52
- const g = globalThis;
53
- if (g.crypto?.randomUUID) return g.crypto.randomUUID();
54
- return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 11)}`;
55
- }
56
-
57
79
  // src/duration.ts
58
80
  function formatDurationMs(ms) {
59
- const safe = Math.max(0, ms);
81
+ if (!Number.isFinite(ms) || ms < 0) return void 0;
82
+ const safe = ms;
60
83
  const seconds = safe / 1e3;
61
84
  const fixed = Number.isInteger(seconds) ? String(seconds) : seconds.toFixed(3).replace(/0+$/, "").replace(/\.$/, "");
62
85
  return `PT${fixed}S`;
@@ -73,12 +96,18 @@ function buildXapiScoreResult(opts) {
73
96
  const max = typeof opts.maxScore === "number" ? opts.maxScore : void 0;
74
97
  const raw = typeof opts.score === "number" ? opts.score : void 0;
75
98
  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
- };
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;
109
+ }
110
+ return result;
82
111
  }
83
112
  function statementFor(objectId, verb, timestamp, extra) {
84
113
  return {
@@ -101,7 +130,7 @@ var experiencedBlockMapper = (event, ctx) => {
101
130
  if (event.name === "interaction") {
102
131
  const lessonId2 = event.lessonId;
103
132
  const blockId2 = event.data?.blockId;
104
- if (!lessonId2 || !blockId2) return null;
133
+ if (!lessonId2 || !blockId2 || typeof blockId2 !== "string") return null;
105
134
  return experiencedBlockStatement(ctx.courseId, lessonId2, blockId2, ctx.timestamp);
106
135
  }
107
136
  const lessonId = event.lessonId;
@@ -127,7 +156,8 @@ var TELEMETRY_XAPI_MAPPERS = {
127
156
  const data = event.data;
128
157
  const result = {};
129
158
  if (typeof data?.durationMs === "number") {
130
- result.duration = formatDurationMs(data.durationMs);
159
+ const duration = formatDurationMs(data.durationMs);
160
+ if (duration !== void 0) result.duration = duration;
131
161
  }
132
162
  if (typeof data?.success === "boolean") result.success = data.success;
133
163
  const score = buildXapiScoreResult({ score: data?.score, maxScore: data?.maxScore });
@@ -181,6 +211,7 @@ var TELEMETRY_XAPI_MAPPERS = {
181
211
  },
182
212
  interaction: experiencedBlockMapper,
183
213
  book_page_viewed: experiencedBlockMapper,
214
+ slide_viewed: experiencedBlockMapper,
184
215
  compound_page_viewed: experiencedBlockMapper,
185
216
  hotspot_opened: experiencedBlockMapper,
186
217
  accordion_section_toggled: experiencedBlockMapper,
@@ -199,6 +230,15 @@ function telemetryEventToXAPIStatement(event) {
199
230
  }
200
231
 
201
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
+ }
202
242
  function isDevEnvironment() {
203
243
  const g = globalThis;
204
244
  return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
@@ -215,8 +255,9 @@ function createXAPIClient(opts) {
215
255
  let warnedTransportFailure = false;
216
256
  const inflightById = /* @__PURE__ */ new Map();
217
257
  const sendOrQueue = (statement) => {
258
+ const normalized = withStatementId2(statement);
218
259
  if (!transport) {
219
- queue.enqueue(statement);
260
+ queue.enqueue(normalized);
220
261
  if (isDevEnvironment() && !warnedNoTransport) {
221
262
  warnedNoTransport = true;
222
263
  console.warn(
@@ -225,35 +266,45 @@ function createXAPIClient(opts) {
225
266
  }
226
267
  return;
227
268
  }
228
- const existing = inflightById.get(statement.id);
269
+ const existing = inflightById.get(normalized.id);
229
270
  if (existing) {
230
271
  void existing.then(
231
272
  () => void 0,
232
273
  () => {
233
- sendOrQueue(statement);
274
+ sendOrQueue(normalized);
234
275
  }
235
276
  );
236
277
  return;
237
278
  }
238
- const transportFlight = Promise.resolve().then(() => transport(statement));
239
- const flight = transportFlight.catch(() => {
240
- queue.enqueue(statement);
279
+ const flight = Promise.resolve().then(() => transport(normalized)).catch(() => {
280
+ queue.enqueue(normalized);
241
281
  if (isDevEnvironment() && !warnedTransportFailure) {
242
282
  warnedTransportFailure = true;
243
283
  console.warn(
244
284
  "[lessonkit] xAPI transport failed; statement re-queued. Check your LRS endpoint or transport implementation."
245
285
  );
246
286
  }
287
+ throw new Error("xAPI transport failed");
247
288
  }).finally(() => {
248
- inflightById.delete(statement.id);
289
+ inflightById.delete(normalized.id);
290
+ });
291
+ inflightById.set(normalized.id, flight);
292
+ void flight.catch(() => {
249
293
  });
250
- inflightById.set(statement.id, transportFlight);
251
- void flight;
252
294
  };
253
295
  const emit = (event) => {
254
- const statement = telemetryEventToXAPIStatement(event);
255
- if (!statement) return;
256
- 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
+ }
257
308
  };
258
309
  return {
259
310
  send: (statement) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessonkit/xapi",
3
- "version": "1.2.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",
@@ -48,7 +48,7 @@
48
48
  "lint": "echo \"(no lint configured yet)\""
49
49
  },
50
50
  "dependencies": {
51
- "@lessonkit/core": "1.2.0"
51
+ "@lessonkit/core": "1.3.0"
52
52
  },
53
53
  "devDependencies": {
54
54
  "tsup": "^8.5.0",