@lessonkit/xapi 1.6.0 → 1.7.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 +1 -1
- package/dist/index.cjs +74 -17
- package/dist/index.d.cts +60 -3
- package/dist/index.d.ts +60 -3
- package/dist/index.js +74 -17
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@ const { batchSink, exitBatchSink } = createFetchBatchSink({ url: "/api/telemetry
|
|
|
55
55
|
|
|
56
56
|
## Docs
|
|
57
57
|
|
|
58
|
-
[
|
|
58
|
+
[5-minute guide](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/getting-started-in-5-minutes.html) · [LMS Go-Live](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/lms-go-live.html) · [Backend proxy cookbook](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/backend-proxy-cookbook.html) · [TypeDoc API index](https://lessonkit.readthedocs.io/en/latest/reference/api.html)
|
|
59
59
|
|
|
60
60
|
## License
|
|
61
61
|
|
package/dist/index.cjs
CHANGED
|
@@ -259,7 +259,14 @@ function createInMemoryXAPIQueue(opts) {
|
|
|
259
259
|
buffer.length = 0;
|
|
260
260
|
notifyDepth();
|
|
261
261
|
},
|
|
262
|
-
getHeadInFlightId: () => headInFlightId
|
|
262
|
+
getHeadInFlightId: () => headInFlightId,
|
|
263
|
+
drainAll: () => {
|
|
264
|
+
if (!buffer.length) return [];
|
|
265
|
+
const drained = buffer.splice(0, buffer.length);
|
|
266
|
+
headFailureCount = 0;
|
|
267
|
+
notifyDepth();
|
|
268
|
+
return drained;
|
|
269
|
+
}
|
|
263
270
|
};
|
|
264
271
|
}
|
|
265
272
|
|
|
@@ -269,6 +276,19 @@ var import_core2 = require("@lessonkit/core");
|
|
|
269
276
|
// src/deadLetter.ts
|
|
270
277
|
var STORAGE_KEY = "lk-xapi-dead-letter";
|
|
271
278
|
var MAX_DEAD_LETTER = 200;
|
|
279
|
+
function isDevEnvironment() {
|
|
280
|
+
const g = globalThis;
|
|
281
|
+
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
282
|
+
}
|
|
283
|
+
function reportPersistError(err, statement, opts) {
|
|
284
|
+
opts?.onPersistError?.(err, { statement });
|
|
285
|
+
if (!opts?.onPersistError && isDevEnvironment()) {
|
|
286
|
+
console.warn(
|
|
287
|
+
"[lessonkit] xAPI dead-letter persist failed:",
|
|
288
|
+
err instanceof Error ? err.message : err
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
272
292
|
function readStorage() {
|
|
273
293
|
try {
|
|
274
294
|
const storage = globalThis.sessionStorage;
|
|
@@ -294,7 +314,10 @@ function loadDeadLetterStatements() {
|
|
|
294
314
|
}
|
|
295
315
|
function persistDeadLetterStatement(statement, opts) {
|
|
296
316
|
const storage = readStorage();
|
|
297
|
-
if (!storage)
|
|
317
|
+
if (!storage) {
|
|
318
|
+
reportPersistError(new Error("sessionStorage is unavailable"), statement, opts);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
298
321
|
try {
|
|
299
322
|
const existing = loadDeadLetterStatements();
|
|
300
323
|
if (existing.some((s) => s.id === statement.id)) return;
|
|
@@ -304,7 +327,8 @@ function persistDeadLetterStatement(statement, opts) {
|
|
|
304
327
|
}
|
|
305
328
|
const next = combined.slice(-MAX_DEAD_LETTER);
|
|
306
329
|
storage.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
307
|
-
} catch {
|
|
330
|
+
} catch (err) {
|
|
331
|
+
reportPersistError(err, statement, opts);
|
|
308
332
|
}
|
|
309
333
|
}
|
|
310
334
|
function removeDeadLetterStatement(id) {
|
|
@@ -632,17 +656,17 @@ function withStatementId2(statement) {
|
|
|
632
656
|
statement.id = cryptoRandomId();
|
|
633
657
|
return statement;
|
|
634
658
|
}
|
|
635
|
-
function
|
|
659
|
+
function isDevEnvironment2() {
|
|
636
660
|
const g = globalThis;
|
|
637
661
|
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
638
662
|
}
|
|
639
663
|
function defaultQueueCapHandler() {
|
|
640
|
-
if (
|
|
664
|
+
if (isDevEnvironment2()) {
|
|
641
665
|
console.warn("[lessonkit] xAPI queue reached capacity; oldest statement(s) dropped.");
|
|
642
666
|
}
|
|
643
667
|
}
|
|
644
668
|
function defaultHeadSkippedHandler(_statement, err) {
|
|
645
|
-
if (
|
|
669
|
+
if (isDevEnvironment2()) {
|
|
646
670
|
console.warn(
|
|
647
671
|
"[lessonkit] xAPI queue skipped statement after repeated transport failures:",
|
|
648
672
|
err instanceof Error ? err.message : err
|
|
@@ -653,20 +677,22 @@ function createXAPIClient(opts) {
|
|
|
653
677
|
const transport = opts?.transport;
|
|
654
678
|
const exitTransport = opts?.exitTransport;
|
|
655
679
|
const courseId = opts?.courseId;
|
|
680
|
+
const persistDeadLetter = (statement) => {
|
|
681
|
+
persistDeadLetterStatement(statement, {
|
|
682
|
+
onTruncated: opts?.onDeadLetterTruncated,
|
|
683
|
+
onPersistError: opts?.onDeadLetterPersistError
|
|
684
|
+
});
|
|
685
|
+
};
|
|
656
686
|
const queue = opts?.queue ?? createInMemoryXAPIQueue({
|
|
657
687
|
maxSize: opts?.maxQueueSize,
|
|
658
688
|
maxHeadFailures: opts?.maxHeadFailures,
|
|
659
689
|
onDepth: opts?.onQueueDepth,
|
|
660
690
|
onCap: opts?.onQueueCap ?? defaultQueueCapHandler,
|
|
661
691
|
onOverflow: (statement) => {
|
|
662
|
-
|
|
663
|
-
onTruncated: opts?.onDeadLetterTruncated
|
|
664
|
-
});
|
|
692
|
+
persistDeadLetter(statement);
|
|
665
693
|
},
|
|
666
694
|
onHeadSkipped: (statement, err) => {
|
|
667
|
-
|
|
668
|
-
onTruncated: opts?.onDeadLetterTruncated
|
|
669
|
-
});
|
|
695
|
+
persistDeadLetter(statement);
|
|
670
696
|
(opts?.onHeadSkipped ?? defaultHeadSkippedHandler)(statement, err);
|
|
671
697
|
}
|
|
672
698
|
});
|
|
@@ -706,7 +732,7 @@ function createXAPIClient(opts) {
|
|
|
706
732
|
() => markExitDelivered(statement),
|
|
707
733
|
() => {
|
|
708
734
|
exitHandoffIds.delete(statement.id);
|
|
709
|
-
|
|
735
|
+
persistDeadLetter(statement);
|
|
710
736
|
}
|
|
711
737
|
);
|
|
712
738
|
} else {
|
|
@@ -714,17 +740,35 @@ function createXAPIClient(opts) {
|
|
|
714
740
|
}
|
|
715
741
|
} catch {
|
|
716
742
|
exitHandoffIds.delete(statement.id);
|
|
717
|
-
|
|
743
|
+
persistDeadLetter(statement);
|
|
718
744
|
}
|
|
719
745
|
};
|
|
720
746
|
const pendingDuringFlush = [];
|
|
721
747
|
let flushInProgress = false;
|
|
748
|
+
const requeuePendingDuringFlush = () => {
|
|
749
|
+
const batch = pendingDuringFlush.splice(0, pendingDuringFlush.length);
|
|
750
|
+
for (const statement of batch) {
|
|
751
|
+
queue.enqueue(statement);
|
|
752
|
+
}
|
|
753
|
+
};
|
|
754
|
+
const persistPendingDuringFlush = () => {
|
|
755
|
+
const batch = pendingDuringFlush.splice(0, pendingDuringFlush.length);
|
|
756
|
+
for (const statement of batch) {
|
|
757
|
+
persistDeadLetter(statement);
|
|
758
|
+
}
|
|
759
|
+
};
|
|
760
|
+
const dispatchPendingDuringFlushOnExit = () => {
|
|
761
|
+
const batch = pendingDuringFlush.splice(0, pendingDuringFlush.length);
|
|
762
|
+
for (const statement of batch) {
|
|
763
|
+
dispatchExitStatement(statement);
|
|
764
|
+
}
|
|
765
|
+
};
|
|
722
766
|
const sendOrQueueInternal = (statement) => {
|
|
723
767
|
const normalized = withStatementId2(statement);
|
|
724
768
|
if (exitDeliveredIds.has(normalized.id)) return;
|
|
725
769
|
if (!deliveryTransport) {
|
|
726
770
|
queue.enqueue(normalized);
|
|
727
|
-
if (
|
|
771
|
+
if (isDevEnvironment2() && !warnedNoTransport) {
|
|
728
772
|
warnedNoTransport = true;
|
|
729
773
|
console.warn(
|
|
730
774
|
"[lessonkit] xAPI statements are queued but no transport is configured; pass config.xapi.transport or config.xapi.client"
|
|
@@ -769,7 +813,7 @@ function createXAPIClient(opts) {
|
|
|
769
813
|
if (exitDeliveredIds.has(normalized.id) || exitHandoffIds.has(normalized.id)) return;
|
|
770
814
|
queue.enqueue(normalized);
|
|
771
815
|
opts?.onTransportError?.(err);
|
|
772
|
-
if (
|
|
816
|
+
if (isDevEnvironment2() && !warnedTransportFailure) {
|
|
773
817
|
warnedTransportFailure = true;
|
|
774
818
|
console.warn(
|
|
775
819
|
"[lessonkit] xAPI transport failed; statement re-queued. Check your LRS endpoint or transport implementation."
|
|
@@ -801,7 +845,7 @@ function createXAPIClient(opts) {
|
|
|
801
845
|
sendOrQueue(statement);
|
|
802
846
|
} catch (err) {
|
|
803
847
|
opts?.onMappingError?.(err);
|
|
804
|
-
if (
|
|
848
|
+
if (isDevEnvironment2()) {
|
|
805
849
|
console.warn(
|
|
806
850
|
"[lessonkit] xAPI mapping skipped:",
|
|
807
851
|
err instanceof Error ? err.message : err
|
|
@@ -825,6 +869,12 @@ function createXAPIClient(opts) {
|
|
|
825
869
|
sendOrQueue(statement);
|
|
826
870
|
},
|
|
827
871
|
queueSize: () => queue.size(),
|
|
872
|
+
abandonUndelivered: () => {
|
|
873
|
+
persistPendingDuringFlush();
|
|
874
|
+
for (const statement of queue.drainAll()) {
|
|
875
|
+
persistDeadLetter(statement);
|
|
876
|
+
}
|
|
877
|
+
},
|
|
828
878
|
flush: async () => {
|
|
829
879
|
if (!deliveryTransport) return;
|
|
830
880
|
for (; ; ) {
|
|
@@ -842,6 +892,9 @@ function createXAPIClient(opts) {
|
|
|
842
892
|
}
|
|
843
893
|
await runFlushLoop();
|
|
844
894
|
}
|
|
895
|
+
} catch (err) {
|
|
896
|
+
requeuePendingDuringFlush();
|
|
897
|
+
throw err;
|
|
845
898
|
} finally {
|
|
846
899
|
flushInProgress = false;
|
|
847
900
|
}
|
|
@@ -867,6 +920,7 @@ function createXAPIClient(opts) {
|
|
|
867
920
|
opts.abortInFlight?.(statement.id);
|
|
868
921
|
dispatchExitStatement(statement);
|
|
869
922
|
}
|
|
923
|
+
dispatchPendingDuringFlushOnExit();
|
|
870
924
|
queue.flushOnExit((statement) => {
|
|
871
925
|
dispatchExitStatement(statement);
|
|
872
926
|
});
|
|
@@ -973,6 +1027,9 @@ function containsPathTraversal(path) {
|
|
|
973
1027
|
return false;
|
|
974
1028
|
}
|
|
975
1029
|
function assertSafeLrsUrl(url, opts) {
|
|
1030
|
+
if (url.startsWith("//")) {
|
|
1031
|
+
throw new Error(`Unsafe LRS URL: protocol-relative URLs are not allowed "${url}"`);
|
|
1032
|
+
}
|
|
976
1033
|
if (url.startsWith("/")) {
|
|
977
1034
|
if (containsPathTraversal(url)) {
|
|
978
1035
|
throw new Error(`Unsafe LRS URL: path traversal in "${url}"`);
|
package/dist/index.d.cts
CHANGED
|
@@ -39,6 +39,8 @@ type XAPIQueue = {
|
|
|
39
39
|
size: () => number;
|
|
40
40
|
/** Statement id currently being delivered via flush, if any. */
|
|
41
41
|
getHeadInFlightId?: () => string | undefined;
|
|
42
|
+
/** Remove and return all queued statements (does not affect in-flight direct transport). */
|
|
43
|
+
drainAll: () => XAPIStatement[];
|
|
42
44
|
};
|
|
43
45
|
type XAPIExitTransport = (statement: XAPIStatement) => void | Promise<void>;
|
|
44
46
|
type XAPIClient = {
|
|
@@ -46,6 +48,11 @@ type XAPIClient = {
|
|
|
46
48
|
flush: () => Promise<void>;
|
|
47
49
|
/** Best-effort synchronous flush for pagehide using keepalive transport when configured. */
|
|
48
50
|
flushOnExit?: () => void;
|
|
51
|
+
/**
|
|
52
|
+
* Persist any queued (undelivered) statements to sessionStorage dead-letter storage.
|
|
53
|
+
* Used when a client is discarded after a failed final flush (e.g. course switch).
|
|
54
|
+
*/
|
|
55
|
+
abandonUndelivered?: () => void;
|
|
49
56
|
queueSize: () => number;
|
|
50
57
|
startedLesson: (opts: {
|
|
51
58
|
lessonId: LessonId;
|
|
@@ -79,6 +86,24 @@ declare function createInMemoryXAPIQueue(opts?: InMemoryXAPIQueueOptions): XAPIQ
|
|
|
79
86
|
/**
|
|
80
87
|
* Imperative xAPI client with in-memory queue, retry flush, and optional pagehide delivery.
|
|
81
88
|
* Prefer wiring transport via `LessonkitProvider` config from `@lessonkit/react` in React apps.
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* ```ts
|
|
92
|
+
* import { createXAPIClient, createFetchTransport } from "@lessonkit/xapi";
|
|
93
|
+
*
|
|
94
|
+
* const client = createXAPIClient({
|
|
95
|
+
* courseId: "my-course",
|
|
96
|
+
* transport: createFetchTransport({ url: "/api/xapi/statements" }),
|
|
97
|
+
* onTransportError: (err) => console.error("LRS delivery failed", err),
|
|
98
|
+
* });
|
|
99
|
+
*
|
|
100
|
+
* await client.trackTelemetryEvent({
|
|
101
|
+
* name: "quiz_answered",
|
|
102
|
+
* courseId: "my-course",
|
|
103
|
+
* lessonId: "lesson-1",
|
|
104
|
+
* checkId: "q1",
|
|
105
|
+
* });
|
|
106
|
+
* ```
|
|
82
107
|
*/
|
|
83
108
|
declare function createXAPIClient(opts?: {
|
|
84
109
|
transport?: XAPITransport;
|
|
@@ -96,6 +121,10 @@ declare function createXAPIClient(opts?: {
|
|
|
96
121
|
onQueueCap?: () => void;
|
|
97
122
|
/** Called when dead-letter storage drops older entries beyond the cap (200). */
|
|
98
123
|
onDeadLetterTruncated?: (droppedCount: number) => void;
|
|
124
|
+
/** Called when a statement cannot be persisted to sessionStorage dead-letter storage. */
|
|
125
|
+
onDeadLetterPersistError?: (err: unknown, ctx: {
|
|
126
|
+
statement: XAPIStatement;
|
|
127
|
+
}) => void;
|
|
99
128
|
onHeadSkipped?: (statement: XAPIStatement, err: unknown) => void;
|
|
100
129
|
/** Called when transport fails after retries (statement is re-queued). */
|
|
101
130
|
onTransportError?: (err: unknown) => void;
|
|
@@ -148,6 +177,18 @@ declare function isRetryableFetchError(err: unknown): boolean;
|
|
|
148
177
|
/**
|
|
149
178
|
* Creates an xAPI transport backed by fetch with timeout, retry backoff, and a
|
|
150
179
|
* keepalive exit transport for pagehide delivery.
|
|
180
|
+
*
|
|
181
|
+
* @example
|
|
182
|
+
* ```ts
|
|
183
|
+
* import { createFetchTransport } from "@lessonkit/xapi";
|
|
184
|
+
*
|
|
185
|
+
* const { transport, exitTransport, abortInFlight } = createFetchTransport({
|
|
186
|
+
* url: import.meta.env.VITE_XAPI_PROXY_URL,
|
|
187
|
+
* headers: () => ({ Authorization: "Bearer …" }),
|
|
188
|
+
* });
|
|
189
|
+
* ```
|
|
190
|
+
*
|
|
191
|
+
* @throws When `url` points at a private/loopback host without `allowPrivateHosts: true`.
|
|
151
192
|
*/
|
|
152
193
|
declare function createFetchTransport(opts: CreateFetchTransportOptions): FetchTransportBundle;
|
|
153
194
|
type CreateFetchBatchSinkOptions = CreateFetchTransportOptions;
|
|
@@ -158,13 +199,29 @@ type FetchBatchSinkBundle = {
|
|
|
158
199
|
};
|
|
159
200
|
/**
|
|
160
201
|
* Batch analytics sink with timeout, retry backoff, and keepalive exit delivery.
|
|
202
|
+
* Wire as `config.tracking.batchSink` and `config.tracking.exitBatchSink` in production.
|
|
203
|
+
*
|
|
204
|
+
* @example
|
|
205
|
+
* ```ts
|
|
206
|
+
* import { createFetchBatchSink } from "@lessonkit/xapi";
|
|
207
|
+
*
|
|
208
|
+
* const { batchSink, exitBatchSink } = createFetchBatchSink({
|
|
209
|
+
* url: import.meta.env.VITE_ANALYTICS_URL,
|
|
210
|
+
* });
|
|
211
|
+
* ```
|
|
212
|
+
*
|
|
213
|
+
* @throws When `url` points at a private/loopback host without `allowPrivateHosts: true`.
|
|
161
214
|
*/
|
|
162
215
|
declare function createFetchBatchSink(opts: CreateFetchBatchSinkOptions): FetchBatchSinkBundle;
|
|
163
216
|
|
|
164
|
-
|
|
165
|
-
declare function persistDeadLetterStatement(statement: XAPIStatement, opts?: {
|
|
217
|
+
type PersistDeadLetterOptions = {
|
|
166
218
|
onTruncated?: (droppedCount: number) => void;
|
|
167
|
-
|
|
219
|
+
onPersistError?: (err: unknown, ctx: {
|
|
220
|
+
statement: XAPIStatement;
|
|
221
|
+
}) => void;
|
|
222
|
+
};
|
|
223
|
+
declare function loadDeadLetterStatements(): XAPIStatement[];
|
|
224
|
+
declare function persistDeadLetterStatement(statement: XAPIStatement, opts?: PersistDeadLetterOptions): void;
|
|
168
225
|
|
|
169
226
|
/**
|
|
170
227
|
* Map a LessonKit telemetry event to an xAPI statement, or null if the event should not emit xAPI.
|
package/dist/index.d.ts
CHANGED
|
@@ -39,6 +39,8 @@ type XAPIQueue = {
|
|
|
39
39
|
size: () => number;
|
|
40
40
|
/** Statement id currently being delivered via flush, if any. */
|
|
41
41
|
getHeadInFlightId?: () => string | undefined;
|
|
42
|
+
/** Remove and return all queued statements (does not affect in-flight direct transport). */
|
|
43
|
+
drainAll: () => XAPIStatement[];
|
|
42
44
|
};
|
|
43
45
|
type XAPIExitTransport = (statement: XAPIStatement) => void | Promise<void>;
|
|
44
46
|
type XAPIClient = {
|
|
@@ -46,6 +48,11 @@ type XAPIClient = {
|
|
|
46
48
|
flush: () => Promise<void>;
|
|
47
49
|
/** Best-effort synchronous flush for pagehide using keepalive transport when configured. */
|
|
48
50
|
flushOnExit?: () => void;
|
|
51
|
+
/**
|
|
52
|
+
* Persist any queued (undelivered) statements to sessionStorage dead-letter storage.
|
|
53
|
+
* Used when a client is discarded after a failed final flush (e.g. course switch).
|
|
54
|
+
*/
|
|
55
|
+
abandonUndelivered?: () => void;
|
|
49
56
|
queueSize: () => number;
|
|
50
57
|
startedLesson: (opts: {
|
|
51
58
|
lessonId: LessonId;
|
|
@@ -79,6 +86,24 @@ declare function createInMemoryXAPIQueue(opts?: InMemoryXAPIQueueOptions): XAPIQ
|
|
|
79
86
|
/**
|
|
80
87
|
* Imperative xAPI client with in-memory queue, retry flush, and optional pagehide delivery.
|
|
81
88
|
* Prefer wiring transport via `LessonkitProvider` config from `@lessonkit/react` in React apps.
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* ```ts
|
|
92
|
+
* import { createXAPIClient, createFetchTransport } from "@lessonkit/xapi";
|
|
93
|
+
*
|
|
94
|
+
* const client = createXAPIClient({
|
|
95
|
+
* courseId: "my-course",
|
|
96
|
+
* transport: createFetchTransport({ url: "/api/xapi/statements" }),
|
|
97
|
+
* onTransportError: (err) => console.error("LRS delivery failed", err),
|
|
98
|
+
* });
|
|
99
|
+
*
|
|
100
|
+
* await client.trackTelemetryEvent({
|
|
101
|
+
* name: "quiz_answered",
|
|
102
|
+
* courseId: "my-course",
|
|
103
|
+
* lessonId: "lesson-1",
|
|
104
|
+
* checkId: "q1",
|
|
105
|
+
* });
|
|
106
|
+
* ```
|
|
82
107
|
*/
|
|
83
108
|
declare function createXAPIClient(opts?: {
|
|
84
109
|
transport?: XAPITransport;
|
|
@@ -96,6 +121,10 @@ declare function createXAPIClient(opts?: {
|
|
|
96
121
|
onQueueCap?: () => void;
|
|
97
122
|
/** Called when dead-letter storage drops older entries beyond the cap (200). */
|
|
98
123
|
onDeadLetterTruncated?: (droppedCount: number) => void;
|
|
124
|
+
/** Called when a statement cannot be persisted to sessionStorage dead-letter storage. */
|
|
125
|
+
onDeadLetterPersistError?: (err: unknown, ctx: {
|
|
126
|
+
statement: XAPIStatement;
|
|
127
|
+
}) => void;
|
|
99
128
|
onHeadSkipped?: (statement: XAPIStatement, err: unknown) => void;
|
|
100
129
|
/** Called when transport fails after retries (statement is re-queued). */
|
|
101
130
|
onTransportError?: (err: unknown) => void;
|
|
@@ -148,6 +177,18 @@ declare function isRetryableFetchError(err: unknown): boolean;
|
|
|
148
177
|
/**
|
|
149
178
|
* Creates an xAPI transport backed by fetch with timeout, retry backoff, and a
|
|
150
179
|
* keepalive exit transport for pagehide delivery.
|
|
180
|
+
*
|
|
181
|
+
* @example
|
|
182
|
+
* ```ts
|
|
183
|
+
* import { createFetchTransport } from "@lessonkit/xapi";
|
|
184
|
+
*
|
|
185
|
+
* const { transport, exitTransport, abortInFlight } = createFetchTransport({
|
|
186
|
+
* url: import.meta.env.VITE_XAPI_PROXY_URL,
|
|
187
|
+
* headers: () => ({ Authorization: "Bearer …" }),
|
|
188
|
+
* });
|
|
189
|
+
* ```
|
|
190
|
+
*
|
|
191
|
+
* @throws When `url` points at a private/loopback host without `allowPrivateHosts: true`.
|
|
151
192
|
*/
|
|
152
193
|
declare function createFetchTransport(opts: CreateFetchTransportOptions): FetchTransportBundle;
|
|
153
194
|
type CreateFetchBatchSinkOptions = CreateFetchTransportOptions;
|
|
@@ -158,13 +199,29 @@ type FetchBatchSinkBundle = {
|
|
|
158
199
|
};
|
|
159
200
|
/**
|
|
160
201
|
* Batch analytics sink with timeout, retry backoff, and keepalive exit delivery.
|
|
202
|
+
* Wire as `config.tracking.batchSink` and `config.tracking.exitBatchSink` in production.
|
|
203
|
+
*
|
|
204
|
+
* @example
|
|
205
|
+
* ```ts
|
|
206
|
+
* import { createFetchBatchSink } from "@lessonkit/xapi";
|
|
207
|
+
*
|
|
208
|
+
* const { batchSink, exitBatchSink } = createFetchBatchSink({
|
|
209
|
+
* url: import.meta.env.VITE_ANALYTICS_URL,
|
|
210
|
+
* });
|
|
211
|
+
* ```
|
|
212
|
+
*
|
|
213
|
+
* @throws When `url` points at a private/loopback host without `allowPrivateHosts: true`.
|
|
161
214
|
*/
|
|
162
215
|
declare function createFetchBatchSink(opts: CreateFetchBatchSinkOptions): FetchBatchSinkBundle;
|
|
163
216
|
|
|
164
|
-
|
|
165
|
-
declare function persistDeadLetterStatement(statement: XAPIStatement, opts?: {
|
|
217
|
+
type PersistDeadLetterOptions = {
|
|
166
218
|
onTruncated?: (droppedCount: number) => void;
|
|
167
|
-
|
|
219
|
+
onPersistError?: (err: unknown, ctx: {
|
|
220
|
+
statement: XAPIStatement;
|
|
221
|
+
}) => void;
|
|
222
|
+
};
|
|
223
|
+
declare function loadDeadLetterStatements(): XAPIStatement[];
|
|
224
|
+
declare function persistDeadLetterStatement(statement: XAPIStatement, opts?: PersistDeadLetterOptions): void;
|
|
168
225
|
|
|
169
226
|
/**
|
|
170
227
|
* Map a LessonKit telemetry event to an xAPI statement, or null if the event should not emit xAPI.
|
package/dist/index.js
CHANGED
|
@@ -222,7 +222,14 @@ function createInMemoryXAPIQueue(opts) {
|
|
|
222
222
|
buffer.length = 0;
|
|
223
223
|
notifyDepth();
|
|
224
224
|
},
|
|
225
|
-
getHeadInFlightId: () => headInFlightId
|
|
225
|
+
getHeadInFlightId: () => headInFlightId,
|
|
226
|
+
drainAll: () => {
|
|
227
|
+
if (!buffer.length) return [];
|
|
228
|
+
const drained = buffer.splice(0, buffer.length);
|
|
229
|
+
headFailureCount = 0;
|
|
230
|
+
notifyDepth();
|
|
231
|
+
return drained;
|
|
232
|
+
}
|
|
226
233
|
};
|
|
227
234
|
}
|
|
228
235
|
|
|
@@ -232,6 +239,19 @@ import { nowIso } from "@lessonkit/core";
|
|
|
232
239
|
// src/deadLetter.ts
|
|
233
240
|
var STORAGE_KEY = "lk-xapi-dead-letter";
|
|
234
241
|
var MAX_DEAD_LETTER = 200;
|
|
242
|
+
function isDevEnvironment() {
|
|
243
|
+
const g = globalThis;
|
|
244
|
+
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
245
|
+
}
|
|
246
|
+
function reportPersistError(err, statement, opts) {
|
|
247
|
+
opts?.onPersistError?.(err, { statement });
|
|
248
|
+
if (!opts?.onPersistError && isDevEnvironment()) {
|
|
249
|
+
console.warn(
|
|
250
|
+
"[lessonkit] xAPI dead-letter persist failed:",
|
|
251
|
+
err instanceof Error ? err.message : err
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
235
255
|
function readStorage() {
|
|
236
256
|
try {
|
|
237
257
|
const storage = globalThis.sessionStorage;
|
|
@@ -257,7 +277,10 @@ function loadDeadLetterStatements() {
|
|
|
257
277
|
}
|
|
258
278
|
function persistDeadLetterStatement(statement, opts) {
|
|
259
279
|
const storage = readStorage();
|
|
260
|
-
if (!storage)
|
|
280
|
+
if (!storage) {
|
|
281
|
+
reportPersistError(new Error("sessionStorage is unavailable"), statement, opts);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
261
284
|
try {
|
|
262
285
|
const existing = loadDeadLetterStatements();
|
|
263
286
|
if (existing.some((s) => s.id === statement.id)) return;
|
|
@@ -267,7 +290,8 @@ function persistDeadLetterStatement(statement, opts) {
|
|
|
267
290
|
}
|
|
268
291
|
const next = combined.slice(-MAX_DEAD_LETTER);
|
|
269
292
|
storage.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
270
|
-
} catch {
|
|
293
|
+
} catch (err) {
|
|
294
|
+
reportPersistError(err, statement, opts);
|
|
271
295
|
}
|
|
272
296
|
}
|
|
273
297
|
function removeDeadLetterStatement(id) {
|
|
@@ -595,17 +619,17 @@ function withStatementId2(statement) {
|
|
|
595
619
|
statement.id = cryptoRandomId();
|
|
596
620
|
return statement;
|
|
597
621
|
}
|
|
598
|
-
function
|
|
622
|
+
function isDevEnvironment2() {
|
|
599
623
|
const g = globalThis;
|
|
600
624
|
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
601
625
|
}
|
|
602
626
|
function defaultQueueCapHandler() {
|
|
603
|
-
if (
|
|
627
|
+
if (isDevEnvironment2()) {
|
|
604
628
|
console.warn("[lessonkit] xAPI queue reached capacity; oldest statement(s) dropped.");
|
|
605
629
|
}
|
|
606
630
|
}
|
|
607
631
|
function defaultHeadSkippedHandler(_statement, err) {
|
|
608
|
-
if (
|
|
632
|
+
if (isDevEnvironment2()) {
|
|
609
633
|
console.warn(
|
|
610
634
|
"[lessonkit] xAPI queue skipped statement after repeated transport failures:",
|
|
611
635
|
err instanceof Error ? err.message : err
|
|
@@ -616,20 +640,22 @@ function createXAPIClient(opts) {
|
|
|
616
640
|
const transport = opts?.transport;
|
|
617
641
|
const exitTransport = opts?.exitTransport;
|
|
618
642
|
const courseId = opts?.courseId;
|
|
643
|
+
const persistDeadLetter = (statement) => {
|
|
644
|
+
persistDeadLetterStatement(statement, {
|
|
645
|
+
onTruncated: opts?.onDeadLetterTruncated,
|
|
646
|
+
onPersistError: opts?.onDeadLetterPersistError
|
|
647
|
+
});
|
|
648
|
+
};
|
|
619
649
|
const queue = opts?.queue ?? createInMemoryXAPIQueue({
|
|
620
650
|
maxSize: opts?.maxQueueSize,
|
|
621
651
|
maxHeadFailures: opts?.maxHeadFailures,
|
|
622
652
|
onDepth: opts?.onQueueDepth,
|
|
623
653
|
onCap: opts?.onQueueCap ?? defaultQueueCapHandler,
|
|
624
654
|
onOverflow: (statement) => {
|
|
625
|
-
|
|
626
|
-
onTruncated: opts?.onDeadLetterTruncated
|
|
627
|
-
});
|
|
655
|
+
persistDeadLetter(statement);
|
|
628
656
|
},
|
|
629
657
|
onHeadSkipped: (statement, err) => {
|
|
630
|
-
|
|
631
|
-
onTruncated: opts?.onDeadLetterTruncated
|
|
632
|
-
});
|
|
658
|
+
persistDeadLetter(statement);
|
|
633
659
|
(opts?.onHeadSkipped ?? defaultHeadSkippedHandler)(statement, err);
|
|
634
660
|
}
|
|
635
661
|
});
|
|
@@ -669,7 +695,7 @@ function createXAPIClient(opts) {
|
|
|
669
695
|
() => markExitDelivered(statement),
|
|
670
696
|
() => {
|
|
671
697
|
exitHandoffIds.delete(statement.id);
|
|
672
|
-
|
|
698
|
+
persistDeadLetter(statement);
|
|
673
699
|
}
|
|
674
700
|
);
|
|
675
701
|
} else {
|
|
@@ -677,17 +703,35 @@ function createXAPIClient(opts) {
|
|
|
677
703
|
}
|
|
678
704
|
} catch {
|
|
679
705
|
exitHandoffIds.delete(statement.id);
|
|
680
|
-
|
|
706
|
+
persistDeadLetter(statement);
|
|
681
707
|
}
|
|
682
708
|
};
|
|
683
709
|
const pendingDuringFlush = [];
|
|
684
710
|
let flushInProgress = false;
|
|
711
|
+
const requeuePendingDuringFlush = () => {
|
|
712
|
+
const batch = pendingDuringFlush.splice(0, pendingDuringFlush.length);
|
|
713
|
+
for (const statement of batch) {
|
|
714
|
+
queue.enqueue(statement);
|
|
715
|
+
}
|
|
716
|
+
};
|
|
717
|
+
const persistPendingDuringFlush = () => {
|
|
718
|
+
const batch = pendingDuringFlush.splice(0, pendingDuringFlush.length);
|
|
719
|
+
for (const statement of batch) {
|
|
720
|
+
persistDeadLetter(statement);
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
const dispatchPendingDuringFlushOnExit = () => {
|
|
724
|
+
const batch = pendingDuringFlush.splice(0, pendingDuringFlush.length);
|
|
725
|
+
for (const statement of batch) {
|
|
726
|
+
dispatchExitStatement(statement);
|
|
727
|
+
}
|
|
728
|
+
};
|
|
685
729
|
const sendOrQueueInternal = (statement) => {
|
|
686
730
|
const normalized = withStatementId2(statement);
|
|
687
731
|
if (exitDeliveredIds.has(normalized.id)) return;
|
|
688
732
|
if (!deliveryTransport) {
|
|
689
733
|
queue.enqueue(normalized);
|
|
690
|
-
if (
|
|
734
|
+
if (isDevEnvironment2() && !warnedNoTransport) {
|
|
691
735
|
warnedNoTransport = true;
|
|
692
736
|
console.warn(
|
|
693
737
|
"[lessonkit] xAPI statements are queued but no transport is configured; pass config.xapi.transport or config.xapi.client"
|
|
@@ -732,7 +776,7 @@ function createXAPIClient(opts) {
|
|
|
732
776
|
if (exitDeliveredIds.has(normalized.id) || exitHandoffIds.has(normalized.id)) return;
|
|
733
777
|
queue.enqueue(normalized);
|
|
734
778
|
opts?.onTransportError?.(err);
|
|
735
|
-
if (
|
|
779
|
+
if (isDevEnvironment2() && !warnedTransportFailure) {
|
|
736
780
|
warnedTransportFailure = true;
|
|
737
781
|
console.warn(
|
|
738
782
|
"[lessonkit] xAPI transport failed; statement re-queued. Check your LRS endpoint or transport implementation."
|
|
@@ -764,7 +808,7 @@ function createXAPIClient(opts) {
|
|
|
764
808
|
sendOrQueue(statement);
|
|
765
809
|
} catch (err) {
|
|
766
810
|
opts?.onMappingError?.(err);
|
|
767
|
-
if (
|
|
811
|
+
if (isDevEnvironment2()) {
|
|
768
812
|
console.warn(
|
|
769
813
|
"[lessonkit] xAPI mapping skipped:",
|
|
770
814
|
err instanceof Error ? err.message : err
|
|
@@ -788,6 +832,12 @@ function createXAPIClient(opts) {
|
|
|
788
832
|
sendOrQueue(statement);
|
|
789
833
|
},
|
|
790
834
|
queueSize: () => queue.size(),
|
|
835
|
+
abandonUndelivered: () => {
|
|
836
|
+
persistPendingDuringFlush();
|
|
837
|
+
for (const statement of queue.drainAll()) {
|
|
838
|
+
persistDeadLetter(statement);
|
|
839
|
+
}
|
|
840
|
+
},
|
|
791
841
|
flush: async () => {
|
|
792
842
|
if (!deliveryTransport) return;
|
|
793
843
|
for (; ; ) {
|
|
@@ -805,6 +855,9 @@ function createXAPIClient(opts) {
|
|
|
805
855
|
}
|
|
806
856
|
await runFlushLoop();
|
|
807
857
|
}
|
|
858
|
+
} catch (err) {
|
|
859
|
+
requeuePendingDuringFlush();
|
|
860
|
+
throw err;
|
|
808
861
|
} finally {
|
|
809
862
|
flushInProgress = false;
|
|
810
863
|
}
|
|
@@ -830,6 +883,7 @@ function createXAPIClient(opts) {
|
|
|
830
883
|
opts.abortInFlight?.(statement.id);
|
|
831
884
|
dispatchExitStatement(statement);
|
|
832
885
|
}
|
|
886
|
+
dispatchPendingDuringFlushOnExit();
|
|
833
887
|
queue.flushOnExit((statement) => {
|
|
834
888
|
dispatchExitStatement(statement);
|
|
835
889
|
});
|
|
@@ -935,6 +989,9 @@ function containsPathTraversal(path) {
|
|
|
935
989
|
return false;
|
|
936
990
|
}
|
|
937
991
|
function assertSafeLrsUrl(url, opts) {
|
|
992
|
+
if (url.startsWith("//")) {
|
|
993
|
+
throw new Error(`Unsafe LRS URL: protocol-relative URLs are not allowed "${url}"`);
|
|
994
|
+
}
|
|
938
995
|
if (url.startsWith("/")) {
|
|
939
996
|
if (containsPathTraversal(url)) {
|
|
940
997
|
throw new Error(`Unsafe LRS URL: path traversal in "${url}"`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lessonkit/xapi",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "xAPI statement generation primitives for LessonKit.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -39,8 +39,8 @@
|
|
|
39
39
|
"dist"
|
|
40
40
|
],
|
|
41
41
|
"scripts": {
|
|
42
|
-
"build": "tsup src/index.ts --format esm,cjs --dts",
|
|
43
|
-
"dev": "tsup src/index.ts --format esm,cjs --dts --watch",
|
|
42
|
+
"build": "tsup src/index.ts --format esm,cjs --dts --tsconfig tsconfig.build.json",
|
|
43
|
+
"dev": "tsup src/index.ts --format esm,cjs --dts --watch --tsconfig tsconfig.build.json",
|
|
44
44
|
"prepublishOnly": "npm run build",
|
|
45
45
|
"typecheck": "tsc -p tsconfig.json",
|
|
46
46
|
"test": "vitest run --passWithNoTests",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"lint": "eslint --max-warnings 0 \"src/**/*.{ts,tsx}\" \"test/**/*.{ts,tsx}\""
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@lessonkit/core": "1.
|
|
51
|
+
"@lessonkit/core": "1.7.1"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"tsup": "^8.5.0",
|