@lessonkit/xapi 1.5.0 → 1.7.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 +1 -1
- package/dist/index.cjs +132 -15
- package/dist/index.d.cts +62 -1
- package/dist/index.d.ts +62 -1
- package/dist/index.js +132 -15
- 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
|
@@ -218,16 +218,20 @@ function createInMemoryXAPIQueue(opts) {
|
|
|
218
218
|
if (buffer.length >= maxSize) {
|
|
219
219
|
if (headInFlight) {
|
|
220
220
|
if (buffer.length > 1) {
|
|
221
|
+
const evicted = buffer[1];
|
|
221
222
|
buffer.splice(1, 1);
|
|
222
223
|
opts?.onCap?.();
|
|
224
|
+
opts?.onOverflow?.(evicted);
|
|
223
225
|
} else {
|
|
224
226
|
opts?.onCap?.();
|
|
225
227
|
opts?.onOverflow?.(normalized);
|
|
226
228
|
return;
|
|
227
229
|
}
|
|
228
230
|
} else {
|
|
231
|
+
const evicted = buffer[0];
|
|
229
232
|
buffer.shift();
|
|
230
233
|
opts?.onCap?.();
|
|
234
|
+
opts?.onOverflow?.(evicted);
|
|
231
235
|
}
|
|
232
236
|
}
|
|
233
237
|
buffer.push(normalized);
|
|
@@ -244,7 +248,9 @@ function createInMemoryXAPIQueue(opts) {
|
|
|
244
248
|
return flushInFlight;
|
|
245
249
|
},
|
|
246
250
|
flushOnExit: (exitTransport) => {
|
|
251
|
+
const skipId = headInFlightId;
|
|
247
252
|
for (const statement of buffer) {
|
|
253
|
+
if (statement.id === skipId) continue;
|
|
248
254
|
try {
|
|
249
255
|
exitTransport(statement);
|
|
250
256
|
} catch {
|
|
@@ -253,7 +259,14 @@ function createInMemoryXAPIQueue(opts) {
|
|
|
253
259
|
buffer.length = 0;
|
|
254
260
|
notifyDepth();
|
|
255
261
|
},
|
|
256
|
-
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
|
+
}
|
|
257
270
|
};
|
|
258
271
|
}
|
|
259
272
|
|
|
@@ -263,6 +276,19 @@ var import_core2 = require("@lessonkit/core");
|
|
|
263
276
|
// src/deadLetter.ts
|
|
264
277
|
var STORAGE_KEY = "lk-xapi-dead-letter";
|
|
265
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
|
+
}
|
|
266
292
|
function readStorage() {
|
|
267
293
|
try {
|
|
268
294
|
const storage = globalThis.sessionStorage;
|
|
@@ -286,15 +312,23 @@ function loadDeadLetterStatements() {
|
|
|
286
312
|
return [];
|
|
287
313
|
}
|
|
288
314
|
}
|
|
289
|
-
function persistDeadLetterStatement(statement) {
|
|
315
|
+
function persistDeadLetterStatement(statement, opts) {
|
|
290
316
|
const storage = readStorage();
|
|
291
|
-
if (!storage)
|
|
317
|
+
if (!storage) {
|
|
318
|
+
reportPersistError(new Error("sessionStorage is unavailable"), statement, opts);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
292
321
|
try {
|
|
293
322
|
const existing = loadDeadLetterStatements();
|
|
294
323
|
if (existing.some((s) => s.id === statement.id)) return;
|
|
295
|
-
const
|
|
324
|
+
const combined = [...existing, statement];
|
|
325
|
+
if (combined.length > MAX_DEAD_LETTER) {
|
|
326
|
+
opts?.onTruncated?.(combined.length - MAX_DEAD_LETTER);
|
|
327
|
+
}
|
|
328
|
+
const next = combined.slice(-MAX_DEAD_LETTER);
|
|
296
329
|
storage.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
297
|
-
} catch {
|
|
330
|
+
} catch (err) {
|
|
331
|
+
reportPersistError(err, statement, opts);
|
|
298
332
|
}
|
|
299
333
|
}
|
|
300
334
|
function removeDeadLetterStatement(id) {
|
|
@@ -426,6 +460,7 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
426
460
|
lesson_completed: (event, ctx) => {
|
|
427
461
|
if (event.name !== "lesson_completed") return null;
|
|
428
462
|
const lessonId = event.lessonId;
|
|
463
|
+
if (!lessonId) return null;
|
|
429
464
|
const data = event.data;
|
|
430
465
|
const result = {};
|
|
431
466
|
if (typeof data?.durationMs === "number") {
|
|
@@ -553,6 +588,50 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
553
588
|
XAPIVerbs.experienced,
|
|
554
589
|
ctx.timestamp
|
|
555
590
|
);
|
|
591
|
+
},
|
|
592
|
+
image_juxtaposition_changed: experiencedBlockMapper,
|
|
593
|
+
timeline_event_viewed: experiencedBlockMapper,
|
|
594
|
+
image_sequence_changed: experiencedBlockMapper,
|
|
595
|
+
audio_recording_started: experiencedBlockMapper,
|
|
596
|
+
audio_recording_completed: (event, ctx) => {
|
|
597
|
+
if (event.name !== "audio_recording_completed") return null;
|
|
598
|
+
const lessonId = event.lessonId;
|
|
599
|
+
const blockId = event.data.blockId;
|
|
600
|
+
if (!blockId) return null;
|
|
601
|
+
return statementFor(
|
|
602
|
+
event,
|
|
603
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId }),
|
|
604
|
+
XAPIVerbs.completed,
|
|
605
|
+
ctx.timestamp
|
|
606
|
+
);
|
|
607
|
+
},
|
|
608
|
+
qr_content_revealed: experiencedBlockMapper,
|
|
609
|
+
advent_door_opened: experiencedBlockMapper,
|
|
610
|
+
map_stage_viewed: (event, ctx) => {
|
|
611
|
+
if (event.name !== "map_stage_viewed") return null;
|
|
612
|
+
const lessonId = event.lessonId;
|
|
613
|
+
const blockId = event.data.blockId;
|
|
614
|
+
const stageId = event.data.stageId;
|
|
615
|
+
if (!lessonId || !blockId || !stageId) return null;
|
|
616
|
+
return statementFor(
|
|
617
|
+
event,
|
|
618
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId, nodeId: stageId }),
|
|
619
|
+
XAPIVerbs.experienced,
|
|
620
|
+
ctx.timestamp
|
|
621
|
+
);
|
|
622
|
+
},
|
|
623
|
+
map_exit_selected: (event, ctx) => {
|
|
624
|
+
if (event.name !== "map_exit_selected") return null;
|
|
625
|
+
const lessonId = event.lessonId;
|
|
626
|
+
const blockId = event.data.blockId;
|
|
627
|
+
const toStageId = event.data.toStageId;
|
|
628
|
+
if (!lessonId || !blockId || !toStageId) return null;
|
|
629
|
+
return statementFor(
|
|
630
|
+
event,
|
|
631
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId, nodeId: toStageId }),
|
|
632
|
+
XAPIVerbs.experienced,
|
|
633
|
+
ctx.timestamp
|
|
634
|
+
);
|
|
556
635
|
}
|
|
557
636
|
};
|
|
558
637
|
function telemetryEventToXAPIStatement(event) {
|
|
@@ -577,17 +656,17 @@ function withStatementId2(statement) {
|
|
|
577
656
|
statement.id = cryptoRandomId();
|
|
578
657
|
return statement;
|
|
579
658
|
}
|
|
580
|
-
function
|
|
659
|
+
function isDevEnvironment2() {
|
|
581
660
|
const g = globalThis;
|
|
582
661
|
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
583
662
|
}
|
|
584
663
|
function defaultQueueCapHandler() {
|
|
585
|
-
if (
|
|
664
|
+
if (isDevEnvironment2()) {
|
|
586
665
|
console.warn("[lessonkit] xAPI queue reached capacity; oldest statement(s) dropped.");
|
|
587
666
|
}
|
|
588
667
|
}
|
|
589
668
|
function defaultHeadSkippedHandler(_statement, err) {
|
|
590
|
-
if (
|
|
669
|
+
if (isDevEnvironment2()) {
|
|
591
670
|
console.warn(
|
|
592
671
|
"[lessonkit] xAPI queue skipped statement after repeated transport failures:",
|
|
593
672
|
err instanceof Error ? err.message : err
|
|
@@ -598,16 +677,22 @@ function createXAPIClient(opts) {
|
|
|
598
677
|
const transport = opts?.transport;
|
|
599
678
|
const exitTransport = opts?.exitTransport;
|
|
600
679
|
const courseId = opts?.courseId;
|
|
680
|
+
const persistDeadLetter = (statement) => {
|
|
681
|
+
persistDeadLetterStatement(statement, {
|
|
682
|
+
onTruncated: opts?.onDeadLetterTruncated,
|
|
683
|
+
onPersistError: opts?.onDeadLetterPersistError
|
|
684
|
+
});
|
|
685
|
+
};
|
|
601
686
|
const queue = opts?.queue ?? createInMemoryXAPIQueue({
|
|
602
687
|
maxSize: opts?.maxQueueSize,
|
|
603
688
|
maxHeadFailures: opts?.maxHeadFailures,
|
|
604
689
|
onDepth: opts?.onQueueDepth,
|
|
605
690
|
onCap: opts?.onQueueCap ?? defaultQueueCapHandler,
|
|
606
691
|
onOverflow: (statement) => {
|
|
607
|
-
|
|
692
|
+
persistDeadLetter(statement);
|
|
608
693
|
},
|
|
609
694
|
onHeadSkipped: (statement, err) => {
|
|
610
|
-
|
|
695
|
+
persistDeadLetter(statement);
|
|
611
696
|
(opts?.onHeadSkipped ?? defaultHeadSkippedHandler)(statement, err);
|
|
612
697
|
}
|
|
613
698
|
});
|
|
@@ -647,7 +732,7 @@ function createXAPIClient(opts) {
|
|
|
647
732
|
() => markExitDelivered(statement),
|
|
648
733
|
() => {
|
|
649
734
|
exitHandoffIds.delete(statement.id);
|
|
650
|
-
|
|
735
|
+
persistDeadLetter(statement);
|
|
651
736
|
}
|
|
652
737
|
);
|
|
653
738
|
} else {
|
|
@@ -655,17 +740,35 @@ function createXAPIClient(opts) {
|
|
|
655
740
|
}
|
|
656
741
|
} catch {
|
|
657
742
|
exitHandoffIds.delete(statement.id);
|
|
658
|
-
|
|
743
|
+
persistDeadLetter(statement);
|
|
659
744
|
}
|
|
660
745
|
};
|
|
661
746
|
const pendingDuringFlush = [];
|
|
662
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
|
+
};
|
|
663
766
|
const sendOrQueueInternal = (statement) => {
|
|
664
767
|
const normalized = withStatementId2(statement);
|
|
665
768
|
if (exitDeliveredIds.has(normalized.id)) return;
|
|
666
769
|
if (!deliveryTransport) {
|
|
667
770
|
queue.enqueue(normalized);
|
|
668
|
-
if (
|
|
771
|
+
if (isDevEnvironment2() && !warnedNoTransport) {
|
|
669
772
|
warnedNoTransport = true;
|
|
670
773
|
console.warn(
|
|
671
774
|
"[lessonkit] xAPI statements are queued but no transport is configured; pass config.xapi.transport or config.xapi.client"
|
|
@@ -700,6 +803,7 @@ function createXAPIClient(opts) {
|
|
|
700
803
|
}
|
|
701
804
|
return;
|
|
702
805
|
}
|
|
806
|
+
queue.removeById(normalized.id);
|
|
703
807
|
inflightStatements.set(normalized.id, normalized);
|
|
704
808
|
inflightPayload.set(normalized.id, normalized);
|
|
705
809
|
const flight = Promise.resolve().then(async () => {
|
|
@@ -709,7 +813,7 @@ function createXAPIClient(opts) {
|
|
|
709
813
|
if (exitDeliveredIds.has(normalized.id) || exitHandoffIds.has(normalized.id)) return;
|
|
710
814
|
queue.enqueue(normalized);
|
|
711
815
|
opts?.onTransportError?.(err);
|
|
712
|
-
if (
|
|
816
|
+
if (isDevEnvironment2() && !warnedTransportFailure) {
|
|
713
817
|
warnedTransportFailure = true;
|
|
714
818
|
console.warn(
|
|
715
819
|
"[lessonkit] xAPI transport failed; statement re-queued. Check your LRS endpoint or transport implementation."
|
|
@@ -741,7 +845,7 @@ function createXAPIClient(opts) {
|
|
|
741
845
|
sendOrQueue(statement);
|
|
742
846
|
} catch (err) {
|
|
743
847
|
opts?.onMappingError?.(err);
|
|
744
|
-
if (
|
|
848
|
+
if (isDevEnvironment2()) {
|
|
745
849
|
console.warn(
|
|
746
850
|
"[lessonkit] xAPI mapping skipped:",
|
|
747
851
|
err instanceof Error ? err.message : err
|
|
@@ -765,6 +869,12 @@ function createXAPIClient(opts) {
|
|
|
765
869
|
sendOrQueue(statement);
|
|
766
870
|
},
|
|
767
871
|
queueSize: () => queue.size(),
|
|
872
|
+
abandonUndelivered: () => {
|
|
873
|
+
persistPendingDuringFlush();
|
|
874
|
+
for (const statement of queue.drainAll()) {
|
|
875
|
+
persistDeadLetter(statement);
|
|
876
|
+
}
|
|
877
|
+
},
|
|
768
878
|
flush: async () => {
|
|
769
879
|
if (!deliveryTransport) return;
|
|
770
880
|
for (; ; ) {
|
|
@@ -782,6 +892,9 @@ function createXAPIClient(opts) {
|
|
|
782
892
|
}
|
|
783
893
|
await runFlushLoop();
|
|
784
894
|
}
|
|
895
|
+
} catch (err) {
|
|
896
|
+
requeuePendingDuringFlush();
|
|
897
|
+
throw err;
|
|
785
898
|
} finally {
|
|
786
899
|
flushInProgress = false;
|
|
787
900
|
}
|
|
@@ -807,6 +920,7 @@ function createXAPIClient(opts) {
|
|
|
807
920
|
opts.abortInFlight?.(statement.id);
|
|
808
921
|
dispatchExitStatement(statement);
|
|
809
922
|
}
|
|
923
|
+
dispatchPendingDuringFlushOnExit();
|
|
810
924
|
queue.flushOnExit((statement) => {
|
|
811
925
|
dispatchExitStatement(statement);
|
|
812
926
|
});
|
|
@@ -913,6 +1027,9 @@ function containsPathTraversal(path) {
|
|
|
913
1027
|
return false;
|
|
914
1028
|
}
|
|
915
1029
|
function assertSafeLrsUrl(url, opts) {
|
|
1030
|
+
if (url.startsWith("//")) {
|
|
1031
|
+
throw new Error(`Unsafe LRS URL: protocol-relative URLs are not allowed "${url}"`);
|
|
1032
|
+
}
|
|
916
1033
|
if (url.startsWith("/")) {
|
|
917
1034
|
if (containsPathTraversal(url)) {
|
|
918
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;
|
|
@@ -94,6 +119,12 @@ declare function createXAPIClient(opts?: {
|
|
|
94
119
|
maxHeadFailures?: number;
|
|
95
120
|
onQueueDepth?: (size: number) => void;
|
|
96
121
|
onQueueCap?: () => void;
|
|
122
|
+
/** Called when dead-letter storage drops older entries beyond the cap (200). */
|
|
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;
|
|
97
128
|
onHeadSkipped?: (statement: XAPIStatement, err: unknown) => void;
|
|
98
129
|
/** Called when transport fails after retries (statement is re-queued). */
|
|
99
130
|
onTransportError?: (err: unknown) => void;
|
|
@@ -146,6 +177,18 @@ declare function isRetryableFetchError(err: unknown): boolean;
|
|
|
146
177
|
/**
|
|
147
178
|
* Creates an xAPI transport backed by fetch with timeout, retry backoff, and a
|
|
148
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`.
|
|
149
192
|
*/
|
|
150
193
|
declare function createFetchTransport(opts: CreateFetchTransportOptions): FetchTransportBundle;
|
|
151
194
|
type CreateFetchBatchSinkOptions = CreateFetchTransportOptions;
|
|
@@ -156,11 +199,29 @@ type FetchBatchSinkBundle = {
|
|
|
156
199
|
};
|
|
157
200
|
/**
|
|
158
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`.
|
|
159
214
|
*/
|
|
160
215
|
declare function createFetchBatchSink(opts: CreateFetchBatchSinkOptions): FetchBatchSinkBundle;
|
|
161
216
|
|
|
217
|
+
type PersistDeadLetterOptions = {
|
|
218
|
+
onTruncated?: (droppedCount: number) => void;
|
|
219
|
+
onPersistError?: (err: unknown, ctx: {
|
|
220
|
+
statement: XAPIStatement;
|
|
221
|
+
}) => void;
|
|
222
|
+
};
|
|
162
223
|
declare function loadDeadLetterStatements(): XAPIStatement[];
|
|
163
|
-
declare function persistDeadLetterStatement(statement: XAPIStatement): void;
|
|
224
|
+
declare function persistDeadLetterStatement(statement: XAPIStatement, opts?: PersistDeadLetterOptions): void;
|
|
164
225
|
|
|
165
226
|
/**
|
|
166
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;
|
|
@@ -94,6 +119,12 @@ declare function createXAPIClient(opts?: {
|
|
|
94
119
|
maxHeadFailures?: number;
|
|
95
120
|
onQueueDepth?: (size: number) => void;
|
|
96
121
|
onQueueCap?: () => void;
|
|
122
|
+
/** Called when dead-letter storage drops older entries beyond the cap (200). */
|
|
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;
|
|
97
128
|
onHeadSkipped?: (statement: XAPIStatement, err: unknown) => void;
|
|
98
129
|
/** Called when transport fails after retries (statement is re-queued). */
|
|
99
130
|
onTransportError?: (err: unknown) => void;
|
|
@@ -146,6 +177,18 @@ declare function isRetryableFetchError(err: unknown): boolean;
|
|
|
146
177
|
/**
|
|
147
178
|
* Creates an xAPI transport backed by fetch with timeout, retry backoff, and a
|
|
148
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`.
|
|
149
192
|
*/
|
|
150
193
|
declare function createFetchTransport(opts: CreateFetchTransportOptions): FetchTransportBundle;
|
|
151
194
|
type CreateFetchBatchSinkOptions = CreateFetchTransportOptions;
|
|
@@ -156,11 +199,29 @@ type FetchBatchSinkBundle = {
|
|
|
156
199
|
};
|
|
157
200
|
/**
|
|
158
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`.
|
|
159
214
|
*/
|
|
160
215
|
declare function createFetchBatchSink(opts: CreateFetchBatchSinkOptions): FetchBatchSinkBundle;
|
|
161
216
|
|
|
217
|
+
type PersistDeadLetterOptions = {
|
|
218
|
+
onTruncated?: (droppedCount: number) => void;
|
|
219
|
+
onPersistError?: (err: unknown, ctx: {
|
|
220
|
+
statement: XAPIStatement;
|
|
221
|
+
}) => void;
|
|
222
|
+
};
|
|
162
223
|
declare function loadDeadLetterStatements(): XAPIStatement[];
|
|
163
|
-
declare function persistDeadLetterStatement(statement: XAPIStatement): void;
|
|
224
|
+
declare function persistDeadLetterStatement(statement: XAPIStatement, opts?: PersistDeadLetterOptions): void;
|
|
164
225
|
|
|
165
226
|
/**
|
|
166
227
|
* Map a LessonKit telemetry event to an xAPI statement, or null if the event should not emit xAPI.
|
package/dist/index.js
CHANGED
|
@@ -181,16 +181,20 @@ function createInMemoryXAPIQueue(opts) {
|
|
|
181
181
|
if (buffer.length >= maxSize) {
|
|
182
182
|
if (headInFlight) {
|
|
183
183
|
if (buffer.length > 1) {
|
|
184
|
+
const evicted = buffer[1];
|
|
184
185
|
buffer.splice(1, 1);
|
|
185
186
|
opts?.onCap?.();
|
|
187
|
+
opts?.onOverflow?.(evicted);
|
|
186
188
|
} else {
|
|
187
189
|
opts?.onCap?.();
|
|
188
190
|
opts?.onOverflow?.(normalized);
|
|
189
191
|
return;
|
|
190
192
|
}
|
|
191
193
|
} else {
|
|
194
|
+
const evicted = buffer[0];
|
|
192
195
|
buffer.shift();
|
|
193
196
|
opts?.onCap?.();
|
|
197
|
+
opts?.onOverflow?.(evicted);
|
|
194
198
|
}
|
|
195
199
|
}
|
|
196
200
|
buffer.push(normalized);
|
|
@@ -207,7 +211,9 @@ function createInMemoryXAPIQueue(opts) {
|
|
|
207
211
|
return flushInFlight;
|
|
208
212
|
},
|
|
209
213
|
flushOnExit: (exitTransport) => {
|
|
214
|
+
const skipId = headInFlightId;
|
|
210
215
|
for (const statement of buffer) {
|
|
216
|
+
if (statement.id === skipId) continue;
|
|
211
217
|
try {
|
|
212
218
|
exitTransport(statement);
|
|
213
219
|
} catch {
|
|
@@ -216,7 +222,14 @@ function createInMemoryXAPIQueue(opts) {
|
|
|
216
222
|
buffer.length = 0;
|
|
217
223
|
notifyDepth();
|
|
218
224
|
},
|
|
219
|
-
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
|
+
}
|
|
220
233
|
};
|
|
221
234
|
}
|
|
222
235
|
|
|
@@ -226,6 +239,19 @@ import { nowIso } from "@lessonkit/core";
|
|
|
226
239
|
// src/deadLetter.ts
|
|
227
240
|
var STORAGE_KEY = "lk-xapi-dead-letter";
|
|
228
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
|
+
}
|
|
229
255
|
function readStorage() {
|
|
230
256
|
try {
|
|
231
257
|
const storage = globalThis.sessionStorage;
|
|
@@ -249,15 +275,23 @@ function loadDeadLetterStatements() {
|
|
|
249
275
|
return [];
|
|
250
276
|
}
|
|
251
277
|
}
|
|
252
|
-
function persistDeadLetterStatement(statement) {
|
|
278
|
+
function persistDeadLetterStatement(statement, opts) {
|
|
253
279
|
const storage = readStorage();
|
|
254
|
-
if (!storage)
|
|
280
|
+
if (!storage) {
|
|
281
|
+
reportPersistError(new Error("sessionStorage is unavailable"), statement, opts);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
255
284
|
try {
|
|
256
285
|
const existing = loadDeadLetterStatements();
|
|
257
286
|
if (existing.some((s) => s.id === statement.id)) return;
|
|
258
|
-
const
|
|
287
|
+
const combined = [...existing, statement];
|
|
288
|
+
if (combined.length > MAX_DEAD_LETTER) {
|
|
289
|
+
opts?.onTruncated?.(combined.length - MAX_DEAD_LETTER);
|
|
290
|
+
}
|
|
291
|
+
const next = combined.slice(-MAX_DEAD_LETTER);
|
|
259
292
|
storage.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
260
|
-
} catch {
|
|
293
|
+
} catch (err) {
|
|
294
|
+
reportPersistError(err, statement, opts);
|
|
261
295
|
}
|
|
262
296
|
}
|
|
263
297
|
function removeDeadLetterStatement(id) {
|
|
@@ -389,6 +423,7 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
389
423
|
lesson_completed: (event, ctx) => {
|
|
390
424
|
if (event.name !== "lesson_completed") return null;
|
|
391
425
|
const lessonId = event.lessonId;
|
|
426
|
+
if (!lessonId) return null;
|
|
392
427
|
const data = event.data;
|
|
393
428
|
const result = {};
|
|
394
429
|
if (typeof data?.durationMs === "number") {
|
|
@@ -516,6 +551,50 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
516
551
|
XAPIVerbs.experienced,
|
|
517
552
|
ctx.timestamp
|
|
518
553
|
);
|
|
554
|
+
},
|
|
555
|
+
image_juxtaposition_changed: experiencedBlockMapper,
|
|
556
|
+
timeline_event_viewed: experiencedBlockMapper,
|
|
557
|
+
image_sequence_changed: experiencedBlockMapper,
|
|
558
|
+
audio_recording_started: experiencedBlockMapper,
|
|
559
|
+
audio_recording_completed: (event, ctx) => {
|
|
560
|
+
if (event.name !== "audio_recording_completed") return null;
|
|
561
|
+
const lessonId = event.lessonId;
|
|
562
|
+
const blockId = event.data.blockId;
|
|
563
|
+
if (!blockId) return null;
|
|
564
|
+
return statementFor(
|
|
565
|
+
event,
|
|
566
|
+
buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId }),
|
|
567
|
+
XAPIVerbs.completed,
|
|
568
|
+
ctx.timestamp
|
|
569
|
+
);
|
|
570
|
+
},
|
|
571
|
+
qr_content_revealed: experiencedBlockMapper,
|
|
572
|
+
advent_door_opened: experiencedBlockMapper,
|
|
573
|
+
map_stage_viewed: (event, ctx) => {
|
|
574
|
+
if (event.name !== "map_stage_viewed") return null;
|
|
575
|
+
const lessonId = event.lessonId;
|
|
576
|
+
const blockId = event.data.blockId;
|
|
577
|
+
const stageId = event.data.stageId;
|
|
578
|
+
if (!lessonId || !blockId || !stageId) return null;
|
|
579
|
+
return statementFor(
|
|
580
|
+
event,
|
|
581
|
+
buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId, nodeId: stageId }),
|
|
582
|
+
XAPIVerbs.experienced,
|
|
583
|
+
ctx.timestamp
|
|
584
|
+
);
|
|
585
|
+
},
|
|
586
|
+
map_exit_selected: (event, ctx) => {
|
|
587
|
+
if (event.name !== "map_exit_selected") return null;
|
|
588
|
+
const lessonId = event.lessonId;
|
|
589
|
+
const blockId = event.data.blockId;
|
|
590
|
+
const toStageId = event.data.toStageId;
|
|
591
|
+
if (!lessonId || !blockId || !toStageId) return null;
|
|
592
|
+
return statementFor(
|
|
593
|
+
event,
|
|
594
|
+
buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId, nodeId: toStageId }),
|
|
595
|
+
XAPIVerbs.experienced,
|
|
596
|
+
ctx.timestamp
|
|
597
|
+
);
|
|
519
598
|
}
|
|
520
599
|
};
|
|
521
600
|
function telemetryEventToXAPIStatement(event) {
|
|
@@ -540,17 +619,17 @@ function withStatementId2(statement) {
|
|
|
540
619
|
statement.id = cryptoRandomId();
|
|
541
620
|
return statement;
|
|
542
621
|
}
|
|
543
|
-
function
|
|
622
|
+
function isDevEnvironment2() {
|
|
544
623
|
const g = globalThis;
|
|
545
624
|
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
546
625
|
}
|
|
547
626
|
function defaultQueueCapHandler() {
|
|
548
|
-
if (
|
|
627
|
+
if (isDevEnvironment2()) {
|
|
549
628
|
console.warn("[lessonkit] xAPI queue reached capacity; oldest statement(s) dropped.");
|
|
550
629
|
}
|
|
551
630
|
}
|
|
552
631
|
function defaultHeadSkippedHandler(_statement, err) {
|
|
553
|
-
if (
|
|
632
|
+
if (isDevEnvironment2()) {
|
|
554
633
|
console.warn(
|
|
555
634
|
"[lessonkit] xAPI queue skipped statement after repeated transport failures:",
|
|
556
635
|
err instanceof Error ? err.message : err
|
|
@@ -561,16 +640,22 @@ function createXAPIClient(opts) {
|
|
|
561
640
|
const transport = opts?.transport;
|
|
562
641
|
const exitTransport = opts?.exitTransport;
|
|
563
642
|
const courseId = opts?.courseId;
|
|
643
|
+
const persistDeadLetter = (statement) => {
|
|
644
|
+
persistDeadLetterStatement(statement, {
|
|
645
|
+
onTruncated: opts?.onDeadLetterTruncated,
|
|
646
|
+
onPersistError: opts?.onDeadLetterPersistError
|
|
647
|
+
});
|
|
648
|
+
};
|
|
564
649
|
const queue = opts?.queue ?? createInMemoryXAPIQueue({
|
|
565
650
|
maxSize: opts?.maxQueueSize,
|
|
566
651
|
maxHeadFailures: opts?.maxHeadFailures,
|
|
567
652
|
onDepth: opts?.onQueueDepth,
|
|
568
653
|
onCap: opts?.onQueueCap ?? defaultQueueCapHandler,
|
|
569
654
|
onOverflow: (statement) => {
|
|
570
|
-
|
|
655
|
+
persistDeadLetter(statement);
|
|
571
656
|
},
|
|
572
657
|
onHeadSkipped: (statement, err) => {
|
|
573
|
-
|
|
658
|
+
persistDeadLetter(statement);
|
|
574
659
|
(opts?.onHeadSkipped ?? defaultHeadSkippedHandler)(statement, err);
|
|
575
660
|
}
|
|
576
661
|
});
|
|
@@ -610,7 +695,7 @@ function createXAPIClient(opts) {
|
|
|
610
695
|
() => markExitDelivered(statement),
|
|
611
696
|
() => {
|
|
612
697
|
exitHandoffIds.delete(statement.id);
|
|
613
|
-
|
|
698
|
+
persistDeadLetter(statement);
|
|
614
699
|
}
|
|
615
700
|
);
|
|
616
701
|
} else {
|
|
@@ -618,17 +703,35 @@ function createXAPIClient(opts) {
|
|
|
618
703
|
}
|
|
619
704
|
} catch {
|
|
620
705
|
exitHandoffIds.delete(statement.id);
|
|
621
|
-
|
|
706
|
+
persistDeadLetter(statement);
|
|
622
707
|
}
|
|
623
708
|
};
|
|
624
709
|
const pendingDuringFlush = [];
|
|
625
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
|
+
};
|
|
626
729
|
const sendOrQueueInternal = (statement) => {
|
|
627
730
|
const normalized = withStatementId2(statement);
|
|
628
731
|
if (exitDeliveredIds.has(normalized.id)) return;
|
|
629
732
|
if (!deliveryTransport) {
|
|
630
733
|
queue.enqueue(normalized);
|
|
631
|
-
if (
|
|
734
|
+
if (isDevEnvironment2() && !warnedNoTransport) {
|
|
632
735
|
warnedNoTransport = true;
|
|
633
736
|
console.warn(
|
|
634
737
|
"[lessonkit] xAPI statements are queued but no transport is configured; pass config.xapi.transport or config.xapi.client"
|
|
@@ -663,6 +766,7 @@ function createXAPIClient(opts) {
|
|
|
663
766
|
}
|
|
664
767
|
return;
|
|
665
768
|
}
|
|
769
|
+
queue.removeById(normalized.id);
|
|
666
770
|
inflightStatements.set(normalized.id, normalized);
|
|
667
771
|
inflightPayload.set(normalized.id, normalized);
|
|
668
772
|
const flight = Promise.resolve().then(async () => {
|
|
@@ -672,7 +776,7 @@ function createXAPIClient(opts) {
|
|
|
672
776
|
if (exitDeliveredIds.has(normalized.id) || exitHandoffIds.has(normalized.id)) return;
|
|
673
777
|
queue.enqueue(normalized);
|
|
674
778
|
opts?.onTransportError?.(err);
|
|
675
|
-
if (
|
|
779
|
+
if (isDevEnvironment2() && !warnedTransportFailure) {
|
|
676
780
|
warnedTransportFailure = true;
|
|
677
781
|
console.warn(
|
|
678
782
|
"[lessonkit] xAPI transport failed; statement re-queued. Check your LRS endpoint or transport implementation."
|
|
@@ -704,7 +808,7 @@ function createXAPIClient(opts) {
|
|
|
704
808
|
sendOrQueue(statement);
|
|
705
809
|
} catch (err) {
|
|
706
810
|
opts?.onMappingError?.(err);
|
|
707
|
-
if (
|
|
811
|
+
if (isDevEnvironment2()) {
|
|
708
812
|
console.warn(
|
|
709
813
|
"[lessonkit] xAPI mapping skipped:",
|
|
710
814
|
err instanceof Error ? err.message : err
|
|
@@ -728,6 +832,12 @@ function createXAPIClient(opts) {
|
|
|
728
832
|
sendOrQueue(statement);
|
|
729
833
|
},
|
|
730
834
|
queueSize: () => queue.size(),
|
|
835
|
+
abandonUndelivered: () => {
|
|
836
|
+
persistPendingDuringFlush();
|
|
837
|
+
for (const statement of queue.drainAll()) {
|
|
838
|
+
persistDeadLetter(statement);
|
|
839
|
+
}
|
|
840
|
+
},
|
|
731
841
|
flush: async () => {
|
|
732
842
|
if (!deliveryTransport) return;
|
|
733
843
|
for (; ; ) {
|
|
@@ -745,6 +855,9 @@ function createXAPIClient(opts) {
|
|
|
745
855
|
}
|
|
746
856
|
await runFlushLoop();
|
|
747
857
|
}
|
|
858
|
+
} catch (err) {
|
|
859
|
+
requeuePendingDuringFlush();
|
|
860
|
+
throw err;
|
|
748
861
|
} finally {
|
|
749
862
|
flushInProgress = false;
|
|
750
863
|
}
|
|
@@ -770,6 +883,7 @@ function createXAPIClient(opts) {
|
|
|
770
883
|
opts.abortInFlight?.(statement.id);
|
|
771
884
|
dispatchExitStatement(statement);
|
|
772
885
|
}
|
|
886
|
+
dispatchPendingDuringFlushOnExit();
|
|
773
887
|
queue.flushOnExit((statement) => {
|
|
774
888
|
dispatchExitStatement(statement);
|
|
775
889
|
});
|
|
@@ -875,6 +989,9 @@ function containsPathTraversal(path) {
|
|
|
875
989
|
return false;
|
|
876
990
|
}
|
|
877
991
|
function assertSafeLrsUrl(url, opts) {
|
|
992
|
+
if (url.startsWith("//")) {
|
|
993
|
+
throw new Error(`Unsafe LRS URL: protocol-relative URLs are not allowed "${url}"`);
|
|
994
|
+
}
|
|
878
995
|
if (url.startsWith("/")) {
|
|
879
996
|
if (containsPathTraversal(url)) {
|
|
880
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.0",
|
|
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.0"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"tsup": "^8.5.0",
|