@lessonkit/xapi 0.9.2 → 1.0.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
@@ -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,21 +29,31 @@ 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
  }
@@ -118,10 +128,15 @@ function telemetryEventToXAPIStatement(event) {
118
128
  case "quiz_answered": {
119
129
  const lessonId = event.lessonId;
120
130
  const checkId = event.data.checkId;
131
+ const result = {};
132
+ if (typeof event.data.correct === "boolean") {
133
+ result.success = event.data.correct;
134
+ }
121
135
  return statementFor(
122
136
  (0, import_core.buildLessonkitUrn)({ courseId, lessonId, checkId }),
123
137
  XAPIVerbs.answered,
124
- event.timestamp
138
+ event.timestamp,
139
+ { result: Object.keys(result).length ? result : void 0 }
125
140
  );
126
141
  }
127
142
  case "quiz_completed": {
@@ -182,6 +197,7 @@ function createXAPIClient(opts) {
182
197
  const queue = opts?.queue ?? createInMemoryXAPIQueue();
183
198
  let warnedNoTransport = false;
184
199
  let warnedTransportFailure = false;
200
+ const inflightById = /* @__PURE__ */ new Map();
185
201
  const sendOrQueue = (statement) => {
186
202
  if (!transport) {
187
203
  queue.enqueue(statement);
@@ -193,7 +209,9 @@ function createXAPIClient(opts) {
193
209
  }
194
210
  return;
195
211
  }
196
- void Promise.resolve().then(() => transport(statement)).catch(() => {
212
+ const existing = inflightById.get(statement.id);
213
+ if (existing) return;
214
+ const flight = Promise.resolve().then(() => transport(statement)).catch(() => {
197
215
  queue.enqueue(statement);
198
216
  if (isDevEnvironment() && !warnedTransportFailure) {
199
217
  warnedTransportFailure = true;
@@ -201,7 +219,11 @@ function createXAPIClient(opts) {
201
219
  "[lessonkit] xAPI transport failed; statement re-queued. Check your LRS endpoint or transport implementation."
202
220
  );
203
221
  }
222
+ }).finally(() => {
223
+ inflightById.delete(statement.id);
204
224
  });
225
+ inflightById.set(statement.id, flight);
226
+ void flight;
205
227
  };
206
228
  const emit = (event) => {
207
229
  const statement = telemetryEventToXAPIStatement(event);
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
  }
@@ -90,10 +100,15 @@ function telemetryEventToXAPIStatement(event) {
90
100
  case "quiz_answered": {
91
101
  const lessonId = event.lessonId;
92
102
  const checkId = event.data.checkId;
103
+ const result = {};
104
+ if (typeof event.data.correct === "boolean") {
105
+ result.success = event.data.correct;
106
+ }
93
107
  return statementFor(
94
108
  buildLessonkitUrn({ courseId, lessonId, checkId }),
95
109
  XAPIVerbs.answered,
96
- event.timestamp
110
+ event.timestamp,
111
+ { result: Object.keys(result).length ? result : void 0 }
97
112
  );
98
113
  }
99
114
  case "quiz_completed": {
@@ -154,6 +169,7 @@ function createXAPIClient(opts) {
154
169
  const queue = opts?.queue ?? createInMemoryXAPIQueue();
155
170
  let warnedNoTransport = false;
156
171
  let warnedTransportFailure = false;
172
+ const inflightById = /* @__PURE__ */ new Map();
157
173
  const sendOrQueue = (statement) => {
158
174
  if (!transport) {
159
175
  queue.enqueue(statement);
@@ -165,7 +181,9 @@ function createXAPIClient(opts) {
165
181
  }
166
182
  return;
167
183
  }
168
- void Promise.resolve().then(() => transport(statement)).catch(() => {
184
+ const existing = inflightById.get(statement.id);
185
+ if (existing) return;
186
+ const flight = Promise.resolve().then(() => transport(statement)).catch(() => {
169
187
  queue.enqueue(statement);
170
188
  if (isDevEnvironment() && !warnedTransportFailure) {
171
189
  warnedTransportFailure = true;
@@ -173,7 +191,11 @@ function createXAPIClient(opts) {
173
191
  "[lessonkit] xAPI transport failed; statement re-queued. Check your LRS endpoint or transport implementation."
174
192
  );
175
193
  }
194
+ }).finally(() => {
195
+ inflightById.delete(statement.id);
176
196
  });
197
+ inflightById.set(statement.id, flight);
198
+ void flight;
177
199
  };
178
200
  const emit = (event) => {
179
201
  const statement = telemetryEventToXAPIStatement(event);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessonkit/xapi",
3
- "version": "0.9.2",
3
+ "version": "1.0.0",
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.2"
51
+ "@lessonkit/core": "1.0.0"
49
52
  },
50
53
  "devDependencies": {
51
54
  "tsup": "^8.5.0",