@lessonkit/xapi 0.9.3 → 1.0.1

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
@@ -1,41 +1,47 @@
1
- # `@lessonkit/xapi`
1
+ # @lessonkit/xapi
2
2
 
3
- [![CI](https://github.com/eddiethedean/lessonkit/actions/workflows/ci.yml/badge.svg)](https://github.com/eddiethedean/lessonkit/actions/workflows/ci.yml)
4
- [![Documentation](https://readthedocs.org/projects/lessonkit/badge/?version=latest)](https://lessonkit.readthedocs.io/en/latest/)
5
3
  [![npm](https://img.shields.io/npm/v/@lessonkit/xapi.svg)](https://www.npmjs.com/package/@lessonkit/xapi)
4
+ [![Documentation](https://readthedocs.org/projects/lessonkit/badge/?version=latest)](https://lessonkit.readthedocs.io/en/latest/reference/xapi.html)
6
5
  [![License](https://img.shields.io/github/license/eddiethedean/lessonkit)](https://github.com/eddiethedean/lessonkit/blob/main/LICENSE)
7
6
 
8
- xAPI statement generation primitives.
7
+ xAPI statement generation, in-memory queueing, and telemetry-to-xAPI mapping.
9
8
 
10
- **Docs:** [xAPI reference](https://lessonkit.readthedocs.io/en/latest/reference/xapi.html) · [Telemetry reference](https://lessonkit.readthedocs.io/en/latest/reference/telemetry.html) · [Telemetry & xAPI guide](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/telemetry-and-xapi.html)
9
+ Requires Node.js **18+**.
11
10
 
12
11
  ## Install
13
12
 
14
13
  ```bash
15
- npm install @lessonkit/xapi
14
+ npm install @lessonkit/xapi @lessonkit/core
16
15
  ```
17
16
 
18
- ## Quick example
17
+ ## Usage
19
18
 
20
- ```ts
21
- import { createXAPIClient } from "@lessonkit/xapi";
19
+ ```typescript
20
+ import { createXAPIClient, telemetryEventToXAPIStatement } from "@lessonkit/xapi";
22
21
 
23
22
  const xapi = createXAPIClient({
24
- courseId: "cyber-basics",
25
- transport: (statement) => {
26
- console.log(statement);
23
+ courseId: "my-course",
24
+ transport: async (statement) => {
25
+ await fetch("/xapi/statements", { method: "POST", body: JSON.stringify(statement) });
27
26
  },
28
27
  });
29
28
 
30
- xapi.completeLesson({ lessonId: "phishing-101", durationMs: 1500, success: true, score: 7, maxScore: 10 });
29
+ xapi.completeLesson({ lessonId: "lesson-1", durationMs: 1200, success: true });
30
+ await xapi.flush();
31
31
  ```
32
32
 
33
- Prefer mapping from telemetry: `telemetryEventToXAPIStatement(event)` (canonical object URNs).
33
+ Map from telemetry events: `telemetryEventToXAPIStatement(event)` — uses canonical LessonKit URNs.
34
34
 
35
- ## Notes (0.6.0)
35
+ ## Behavior
36
36
 
37
- - `createXAPIClient` requires `courseId` for lifecycle helpers; React uses the mapper after each `track()`.
38
- - If the transport throws/rejects, statements are queued in-memory.
39
- - Call `await xapi.flush()` to retry queued statements.
37
+ - No transport statements queue in memory (dev warns once).
38
+ - Transport failure re-queue; call `flush()` to retry.
39
+ - Concurrent `flush()` calls are coalesced.
40
40
 
41
- See the [telemetry reference](https://lessonkit.readthedocs.io/en/latest/reference/telemetry.html) and [identity reference](https://lessonkit.readthedocs.io/en/latest/reference/identity.html) for URNs and event mapping.
41
+ ## Docs
42
+
43
+ [xAPI reference](https://lessonkit.readthedocs.io/en/latest/reference/xapi.html) · [Telemetry & xAPI guide](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/telemetry-and-xapi.html)
44
+
45
+ ## License
46
+
47
+ Apache-2.0
package/dist/index.cjs CHANGED
@@ -29,30 +29,41 @@ module.exports = __toCommonJS(index_exports);
29
29
  // src/queue.ts
30
30
  function createInMemoryXAPIQueue() {
31
31
  const buffer = [];
32
+ let flushInFlight = null;
33
+ const runFlush = async (transport) => {
34
+ while (buffer.length) {
35
+ const statement = buffer[0];
36
+ try {
37
+ await transport(statement);
38
+ buffer.shift();
39
+ } catch {
40
+ return;
41
+ }
42
+ }
43
+ };
32
44
  return {
33
45
  enqueue: (statement) => {
46
+ if (statement.id && buffer.some((s) => s.id === statement.id)) return;
34
47
  buffer.push(statement);
35
48
  },
36
49
  size: () => buffer.length,
37
50
  flush: async (transport) => {
38
- while (buffer.length) {
39
- const statement = buffer[0];
40
- try {
41
- await transport(statement);
42
- buffer.shift();
43
- } catch {
44
- return;
45
- }
46
- }
51
+ if (flushInFlight) return flushInFlight;
52
+ if (!buffer.length) return;
53
+ flushInFlight = runFlush(transport).finally(() => {
54
+ flushInFlight = null;
55
+ });
56
+ return flushInFlight;
47
57
  }
48
58
  };
49
59
  }
50
60
 
51
61
  // src/client.ts
52
- var import_core2 = require("@lessonkit/core");
62
+ var import_core3 = require("@lessonkit/core");
53
63
 
54
64
  // src/telemetryMap.ts
55
65
  var import_core = require("@lessonkit/core");
66
+ var import_core2 = require("@lessonkit/core");
56
67
 
57
68
  // src/id.ts
58
69
  function cryptoRandomId() {
@@ -80,13 +91,13 @@ function telemetryEventToXAPIStatement(event) {
80
91
  const { courseId } = event;
81
92
  switch (event.name) {
82
93
  case "course_started":
83
- return statementFor((0, import_core.buildLessonkitUrn)({ courseId }), XAPIVerbs.initialized, event.timestamp);
94
+ return statementFor((0, import_core2.buildLessonkitUrn)({ courseId }), XAPIVerbs.initialized, event.timestamp);
84
95
  case "course_completed":
85
- return statementFor((0, import_core.buildLessonkitUrn)({ courseId }), XAPIVerbs.completed, event.timestamp);
96
+ return statementFor((0, import_core2.buildLessonkitUrn)({ courseId }), XAPIVerbs.completed, event.timestamp);
86
97
  case "lesson_started": {
87
98
  const lessonId = event.lessonId;
88
99
  return statementFor(
89
- (0, import_core.buildLessonkitUrn)({ courseId, lessonId }),
100
+ (0, import_core2.buildLessonkitUrn)({ courseId, lessonId }),
90
101
  XAPIVerbs.initialized,
91
102
  event.timestamp
92
103
  );
@@ -109,7 +120,7 @@ function telemetryEventToXAPIStatement(event) {
109
120
  scaled: typeof raw === "number" && typeof max === "number" && max > 0 ? raw / max : void 0
110
121
  };
111
122
  }
112
- return statementFor((0, import_core.buildLessonkitUrn)({ courseId, lessonId }), XAPIVerbs.completed, event.timestamp, {
123
+ return statementFor((0, import_core2.buildLessonkitUrn)({ courseId, lessonId }), XAPIVerbs.completed, event.timestamp, {
113
124
  result: Object.keys(result).length ? result : void 0
114
125
  });
115
126
  }
@@ -118,10 +129,15 @@ function telemetryEventToXAPIStatement(event) {
118
129
  case "quiz_answered": {
119
130
  const lessonId = event.lessonId;
120
131
  const checkId = event.data.checkId;
132
+ const result = {};
133
+ if (typeof event.data.correct === "boolean") {
134
+ result.success = event.data.correct;
135
+ }
121
136
  return statementFor(
122
- (0, import_core.buildLessonkitUrn)({ courseId, lessonId, checkId }),
137
+ (0, import_core2.buildLessonkitUrn)({ courseId, lessonId, checkId }),
123
138
  XAPIVerbs.answered,
124
- event.timestamp
139
+ event.timestamp,
140
+ { result: Object.keys(result).length ? result : void 0 }
125
141
  );
126
142
  }
127
143
  case "quiz_completed": {
@@ -140,7 +156,7 @@ function telemetryEventToXAPIStatement(event) {
140
156
  };
141
157
  }
142
158
  return statementFor(
143
- (0, import_core.buildLessonkitUrn)({ courseId, lessonId, checkId }),
159
+ (0, import_core2.buildLessonkitUrn)({ courseId, lessonId, checkId }),
144
160
  XAPIVerbs.completed,
145
161
  event.timestamp,
146
162
  { result: Object.keys(result).length ? result : void 0 }
@@ -151,13 +167,13 @@ function telemetryEventToXAPIStatement(event) {
151
167
  const blockId = event.data?.blockId;
152
168
  if (!lessonId || !blockId) return null;
153
169
  return statementFor(
154
- (0, import_core.buildLessonkitUrn)({ courseId, lessonId, blockId }),
170
+ (0, import_core2.buildLessonkitUrn)({ courseId, lessonId, blockId }),
155
171
  XAPIVerbs.experienced,
156
172
  event.timestamp
157
173
  );
158
174
  }
159
175
  default:
160
- return null;
176
+ return (0, import_core.assertNever)(event, "Unhandled telemetry event");
161
177
  }
162
178
  }
163
179
  function statementFor(objectId, verb, timestamp, extra) {
@@ -182,6 +198,7 @@ function createXAPIClient(opts) {
182
198
  const queue = opts?.queue ?? createInMemoryXAPIQueue();
183
199
  let warnedNoTransport = false;
184
200
  let warnedTransportFailure = false;
201
+ const inflightById = /* @__PURE__ */ new Map();
185
202
  const sendOrQueue = (statement) => {
186
203
  if (!transport) {
187
204
  queue.enqueue(statement);
@@ -193,7 +210,9 @@ function createXAPIClient(opts) {
193
210
  }
194
211
  return;
195
212
  }
196
- void Promise.resolve().then(() => transport(statement)).catch(() => {
213
+ const existing = inflightById.get(statement.id);
214
+ if (existing) return;
215
+ const flight = Promise.resolve().then(() => transport(statement)).catch(() => {
197
216
  queue.enqueue(statement);
198
217
  if (isDevEnvironment() && !warnedTransportFailure) {
199
218
  warnedTransportFailure = true;
@@ -201,7 +220,11 @@ function createXAPIClient(opts) {
201
220
  "[lessonkit] xAPI transport failed; statement re-queued. Check your LRS endpoint or transport implementation."
202
221
  );
203
222
  }
223
+ }).finally(() => {
224
+ inflightById.delete(statement.id);
204
225
  });
226
+ inflightById.set(statement.id, flight);
227
+ void flight;
205
228
  };
206
229
  const emit = (event) => {
207
230
  const statement = telemetryEventToXAPIStatement(event);
@@ -215,12 +238,16 @@ function createXAPIClient(opts) {
215
238
  flush: async () => {
216
239
  if (!transport) return;
217
240
  await queue.flush(transport);
241
+ const flights = [...inflightById.values()];
242
+ if (flights.length > 0) {
243
+ await Promise.allSettled(flights);
244
+ }
218
245
  },
219
246
  startedLesson: ({ lessonId }) => {
220
247
  if (!courseId) return;
221
248
  emit({
222
249
  name: "lesson_started",
223
- timestamp: (0, import_core2.nowIso)(),
250
+ timestamp: (0, import_core3.nowIso)(),
224
251
  courseId,
225
252
  lessonId,
226
253
  data: { lessonId }
@@ -236,7 +263,7 @@ function createXAPIClient(opts) {
236
263
  if (!courseId) return;
237
264
  emit({
238
265
  name: "lesson_completed",
239
- timestamp: (0, import_core2.nowIso)(),
266
+ timestamp: (0, import_core3.nowIso)(),
240
267
  courseId,
241
268
  lessonId,
242
269
  data: { lessonId, durationMs, score, maxScore, success }
@@ -246,7 +273,7 @@ function createXAPIClient(opts) {
246
273
  if (!courseId) return;
247
274
  emit({
248
275
  name: "course_completed",
249
- timestamp: (0, import_core2.nowIso)(),
276
+ timestamp: (0, import_core3.nowIso)(),
250
277
  courseId
251
278
  });
252
279
  }
package/dist/index.d.cts CHANGED
@@ -1,14 +1,32 @@
1
1
  import { LessonId, CourseId, TelemetryEvent } from '@lessonkit/core';
2
2
 
3
+ type XAPIVerbIri = "http://adlnet.gov/expapi/verbs/initialized" | "http://adlnet.gov/expapi/verbs/completed" | "http://adlnet.gov/expapi/verbs/answered" | "http://adlnet.gov/expapi/verbs/experienced";
4
+ type XAPIScore = {
5
+ raw?: number;
6
+ max?: number;
7
+ min?: number;
8
+ scaled?: number;
9
+ };
10
+ type XAPIResult = {
11
+ duration?: string;
12
+ success?: boolean;
13
+ score?: XAPIScore;
14
+ completion?: boolean;
15
+ };
16
+ type XAPIObjectDefinition = {
17
+ name?: Record<string, string>;
18
+ description?: Record<string, string>;
19
+ type?: string;
20
+ };
3
21
  type XAPIStatement = {
4
22
  id: string;
5
23
  timestamp: string;
6
- verb: string;
24
+ verb: XAPIVerbIri;
7
25
  object: {
8
26
  id: string;
9
- definition?: Record<string, unknown>;
27
+ definition?: XAPIObjectDefinition;
10
28
  };
11
- result?: Record<string, unknown>;
29
+ result?: XAPIResult;
12
30
  context?: Record<string, unknown>;
13
31
  };
14
32
  type XAPITransport = (statement: XAPIStatement) => void | Promise<void>;
@@ -48,4 +66,4 @@ declare function createXAPIClient(opts?: {
48
66
  */
49
67
  declare function telemetryEventToXAPIStatement(event: TelemetryEvent): XAPIStatement | null;
50
68
 
51
- export { type XAPIClient, type XAPIQueue, type XAPIStatement, type XAPITransport, createInMemoryXAPIQueue, createXAPIClient, telemetryEventToXAPIStatement };
69
+ export { 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
@@ -1,14 +1,32 @@
1
1
  import { LessonId, CourseId, TelemetryEvent } from '@lessonkit/core';
2
2
 
3
+ type XAPIVerbIri = "http://adlnet.gov/expapi/verbs/initialized" | "http://adlnet.gov/expapi/verbs/completed" | "http://adlnet.gov/expapi/verbs/answered" | "http://adlnet.gov/expapi/verbs/experienced";
4
+ type XAPIScore = {
5
+ raw?: number;
6
+ max?: number;
7
+ min?: number;
8
+ scaled?: number;
9
+ };
10
+ type XAPIResult = {
11
+ duration?: string;
12
+ success?: boolean;
13
+ score?: XAPIScore;
14
+ completion?: boolean;
15
+ };
16
+ type XAPIObjectDefinition = {
17
+ name?: Record<string, string>;
18
+ description?: Record<string, string>;
19
+ type?: string;
20
+ };
3
21
  type XAPIStatement = {
4
22
  id: string;
5
23
  timestamp: string;
6
- verb: string;
24
+ verb: XAPIVerbIri;
7
25
  object: {
8
26
  id: string;
9
- definition?: Record<string, unknown>;
27
+ definition?: XAPIObjectDefinition;
10
28
  };
11
- result?: Record<string, unknown>;
29
+ result?: XAPIResult;
12
30
  context?: Record<string, unknown>;
13
31
  };
14
32
  type XAPITransport = (statement: XAPIStatement) => void | Promise<void>;
@@ -48,4 +66,4 @@ declare function createXAPIClient(opts?: {
48
66
  */
49
67
  declare function telemetryEventToXAPIStatement(event: TelemetryEvent): XAPIStatement | null;
50
68
 
51
- export { type XAPIClient, type XAPIQueue, type XAPIStatement, type XAPITransport, createInMemoryXAPIQueue, createXAPIClient, telemetryEventToXAPIStatement };
69
+ export { 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,21 +1,31 @@
1
1
  // src/queue.ts
2
2
  function createInMemoryXAPIQueue() {
3
3
  const buffer = [];
4
+ let flushInFlight = null;
5
+ const runFlush = async (transport) => {
6
+ while (buffer.length) {
7
+ const statement = buffer[0];
8
+ try {
9
+ await transport(statement);
10
+ buffer.shift();
11
+ } catch {
12
+ return;
13
+ }
14
+ }
15
+ };
4
16
  return {
5
17
  enqueue: (statement) => {
18
+ if (statement.id && buffer.some((s) => s.id === statement.id)) return;
6
19
  buffer.push(statement);
7
20
  },
8
21
  size: () => buffer.length,
9
22
  flush: async (transport) => {
10
- while (buffer.length) {
11
- const statement = buffer[0];
12
- try {
13
- await transport(statement);
14
- buffer.shift();
15
- } catch {
16
- return;
17
- }
18
- }
23
+ if (flushInFlight) return flushInFlight;
24
+ if (!buffer.length) return;
25
+ flushInFlight = runFlush(transport).finally(() => {
26
+ flushInFlight = null;
27
+ });
28
+ return flushInFlight;
19
29
  }
20
30
  };
21
31
  }
@@ -24,6 +34,7 @@ function createInMemoryXAPIQueue() {
24
34
  import { nowIso } from "@lessonkit/core";
25
35
 
26
36
  // src/telemetryMap.ts
37
+ import { assertNever } from "@lessonkit/core";
27
38
  import { buildLessonkitUrn } from "@lessonkit/core";
28
39
 
29
40
  // src/id.ts
@@ -90,10 +101,15 @@ function telemetryEventToXAPIStatement(event) {
90
101
  case "quiz_answered": {
91
102
  const lessonId = event.lessonId;
92
103
  const checkId = event.data.checkId;
104
+ const result = {};
105
+ if (typeof event.data.correct === "boolean") {
106
+ result.success = event.data.correct;
107
+ }
93
108
  return statementFor(
94
109
  buildLessonkitUrn({ courseId, lessonId, checkId }),
95
110
  XAPIVerbs.answered,
96
- event.timestamp
111
+ event.timestamp,
112
+ { result: Object.keys(result).length ? result : void 0 }
97
113
  );
98
114
  }
99
115
  case "quiz_completed": {
@@ -129,7 +145,7 @@ function telemetryEventToXAPIStatement(event) {
129
145
  );
130
146
  }
131
147
  default:
132
- return null;
148
+ return assertNever(event, "Unhandled telemetry event");
133
149
  }
134
150
  }
135
151
  function statementFor(objectId, verb, timestamp, extra) {
@@ -154,6 +170,7 @@ function createXAPIClient(opts) {
154
170
  const queue = opts?.queue ?? createInMemoryXAPIQueue();
155
171
  let warnedNoTransport = false;
156
172
  let warnedTransportFailure = false;
173
+ const inflightById = /* @__PURE__ */ new Map();
157
174
  const sendOrQueue = (statement) => {
158
175
  if (!transport) {
159
176
  queue.enqueue(statement);
@@ -165,7 +182,9 @@ function createXAPIClient(opts) {
165
182
  }
166
183
  return;
167
184
  }
168
- void Promise.resolve().then(() => transport(statement)).catch(() => {
185
+ const existing = inflightById.get(statement.id);
186
+ if (existing) return;
187
+ const flight = Promise.resolve().then(() => transport(statement)).catch(() => {
169
188
  queue.enqueue(statement);
170
189
  if (isDevEnvironment() && !warnedTransportFailure) {
171
190
  warnedTransportFailure = true;
@@ -173,7 +192,11 @@ function createXAPIClient(opts) {
173
192
  "[lessonkit] xAPI transport failed; statement re-queued. Check your LRS endpoint or transport implementation."
174
193
  );
175
194
  }
195
+ }).finally(() => {
196
+ inflightById.delete(statement.id);
176
197
  });
198
+ inflightById.set(statement.id, flight);
199
+ void flight;
177
200
  };
178
201
  const emit = (event) => {
179
202
  const statement = telemetryEventToXAPIStatement(event);
@@ -187,6 +210,10 @@ function createXAPIClient(opts) {
187
210
  flush: async () => {
188
211
  if (!transport) return;
189
212
  await queue.flush(transport);
213
+ const flights = [...inflightById.values()];
214
+ if (flights.length > 0) {
215
+ await Promise.allSettled(flights);
216
+ }
190
217
  },
191
218
  startedLesson: ({ lessonId }) => {
192
219
  if (!courseId) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessonkit/xapi",
3
- "version": "0.9.3",
3
+ "version": "1.0.1",
4
4
  "private": false,
5
5
  "description": "xAPI statement generation primitives for LessonKit.",
6
6
  "license": "Apache-2.0",
@@ -21,6 +21,9 @@
21
21
  "training",
22
22
  "lrs"
23
23
  ],
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
24
27
  "type": "module",
25
28
  "main": "./dist/index.cjs",
26
29
  "module": "./dist/index.js",
@@ -45,7 +48,7 @@
45
48
  "lint": "echo \"(no lint configured yet)\""
46
49
  },
47
50
  "dependencies": {
48
- "@lessonkit/core": "0.9.3"
51
+ "@lessonkit/core": "1.0.1"
49
52
  },
50
53
  "devDependencies": {
51
54
  "tsup": "^8.5.0",