@lessonkit/core 1.4.0 → 1.5.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 +28 -2
- package/dist/{chunk-PEWFPVQ6.js → chunk-KFXFQ6B2.js} +230 -45
- package/dist/index.cjs +481 -110
- package/dist/index.d.cts +56 -12
- package/dist/index.d.ts +56 -12
- package/dist/index.js +322 -122
- package/dist/{testing-BhVGckZ5.d.cts → testing-BFr8oEfw.d.cts} +46 -7
- package/dist/{testing-BhVGckZ5.d.ts → testing-BFr8oEfw.d.ts} +46 -7
- package/dist/testing.cjs +1 -3
- package/dist/testing.d.cts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +1 -1
- package/package.json +3 -3
- package/telemetry-catalog.v3.json +37 -0
package/dist/index.cjs
CHANGED
|
@@ -23,6 +23,8 @@ __export(index_exports, {
|
|
|
23
23
|
ACCORDION_FORBIDDEN_CHILD_TYPES: () => ACCORDION_FORBIDDEN_CHILD_TYPES,
|
|
24
24
|
ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES: () => ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES,
|
|
25
25
|
BLOCKS_14_PAGE_SLIDE: () => BLOCKS_14_PAGE_SLIDE,
|
|
26
|
+
BRANCHING_SCENARIO_ALLOWED_CHILD_TYPES: () => BRANCHING_SCENARIO_ALLOWED_CHILD_TYPES,
|
|
27
|
+
BRANCH_NODE_ALLOWED_CHILD_TYPES: () => BRANCH_NODE_ALLOWED_CHILD_TYPES,
|
|
26
28
|
COMPOUND_MAX_NESTING_DEPTH: () => COMPOUND_MAX_NESTING_DEPTH,
|
|
27
29
|
COMPOUND_RESUME_SCHEMA_VERSION: () => COMPOUND_RESUME_SCHEMA_VERSION,
|
|
28
30
|
ID_MAX_LENGTH: () => ID_MAX_LENGTH,
|
|
@@ -72,11 +74,14 @@ __export(index_exports, {
|
|
|
72
74
|
hasCourseStarted: () => hasCourseStarted,
|
|
73
75
|
hasCourseStartedEmittedToTracking: () => hasCourseStartedEmittedToTracking,
|
|
74
76
|
hasCourseStartedPipelineDelivered: () => hasCourseStartedPipelineDelivered,
|
|
77
|
+
hasCourseStartedXapiSent: () => hasCourseStartedXapiSent,
|
|
75
78
|
isChildTypeAllowed: () => isChildTypeAllowed,
|
|
79
|
+
isLifecycleTelemetryEvent: () => isLifecycleTelemetryEvent,
|
|
76
80
|
loadCompoundState: () => loadCompoundState,
|
|
77
81
|
markCourseStarted: () => markCourseStarted,
|
|
78
82
|
markCourseStartedEmittedToTracking: () => markCourseStartedEmittedToTracking,
|
|
79
83
|
markCourseStartedPipelineDelivered: () => markCourseStartedPipelineDelivered,
|
|
84
|
+
markCourseStartedXapiSent: () => markCourseStartedXapiSent,
|
|
80
85
|
migrateCourseStartedMark: () => migrateCourseStartedMark,
|
|
81
86
|
nowIso: () => nowIso,
|
|
82
87
|
parseBlockId: () => parseBlockId,
|
|
@@ -95,6 +100,7 @@ __export(index_exports, {
|
|
|
95
100
|
telemetryCatalogVersion: () => telemetryCatalogVersion,
|
|
96
101
|
tryBuildTelemetryEvent: () => tryBuildTelemetryEvent,
|
|
97
102
|
tryEmitCourseStarted: () => tryEmitCourseStarted,
|
|
103
|
+
validateBranchGraph: () => validateBranchGraph,
|
|
98
104
|
validateId: () => validateId
|
|
99
105
|
});
|
|
100
106
|
module.exports = __toCommonJS(index_exports);
|
|
@@ -172,12 +178,26 @@ function uniqueFallbackId(input, usedIds) {
|
|
|
172
178
|
const hash = shortHash(input);
|
|
173
179
|
for (let n = 0; n < 100; n++) {
|
|
174
180
|
const candidate = (n === 0 ? `id-${hash}` : `id-${hash}-${n}`).slice(0, 64);
|
|
175
|
-
const
|
|
176
|
-
if (
|
|
181
|
+
const validated = validateId(candidate);
|
|
182
|
+
if (validated.ok && !usedIds.has(validated.id)) return validated.id;
|
|
183
|
+
}
|
|
184
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
185
|
+
const randomSuffix = Math.random().toString(36).slice(2, 8);
|
|
186
|
+
const candidate = `id-${hash}-${randomSuffix}`.slice(0, 64);
|
|
187
|
+
const validated = validateId(candidate);
|
|
188
|
+
if (validated.ok && !usedIds.has(validated.id)) return validated.id;
|
|
177
189
|
}
|
|
178
190
|
const timed = `id-${hash}-${Date.now().toString(36)}`.slice(0, 64);
|
|
179
|
-
const
|
|
180
|
-
|
|
191
|
+
const timedValidated = validateId(timed);
|
|
192
|
+
if (timedValidated.ok && !usedIds.has(timedValidated.id)) return timedValidated.id;
|
|
193
|
+
const cryptoApi = globalThis.crypto;
|
|
194
|
+
for (let attempt = 0; attempt < 1e3; attempt++) {
|
|
195
|
+
const suffix = typeof cryptoApi?.randomUUID === "function" ? cryptoApi.randomUUID().replace(/-/g, "").slice(0, 12) : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
196
|
+
const candidate = `id-${hash}-${suffix}`.slice(0, 64);
|
|
197
|
+
const validated = validateId(candidate);
|
|
198
|
+
if (validated.ok && !usedIds.has(validated.id)) return validated.id;
|
|
199
|
+
}
|
|
200
|
+
throw new Error(`[lessonkit] unable to derive unique id for input: ${input.slice(0, 32)}`);
|
|
181
201
|
}
|
|
182
202
|
function slugifyId(input) {
|
|
183
203
|
const slug = input.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-").slice(0, 64);
|
|
@@ -219,6 +239,13 @@ function buildLessonkitUrn(parts) {
|
|
|
219
239
|
}
|
|
220
240
|
urn += `:block:${blockId}`;
|
|
221
241
|
}
|
|
242
|
+
if (parts.nodeId !== void 0) {
|
|
243
|
+
const nodeId = assertValidId(parts.nodeId, "blockId");
|
|
244
|
+
if (parts.blockId === void 0) {
|
|
245
|
+
throw new Error("buildLessonkitUrn: nodeId requires blockId");
|
|
246
|
+
}
|
|
247
|
+
urn += `:node:${nodeId}`;
|
|
248
|
+
}
|
|
222
249
|
return urn;
|
|
223
250
|
}
|
|
224
251
|
|
|
@@ -263,19 +290,25 @@ function isPlainSerializableChildState(value) {
|
|
|
263
290
|
(entry) => isValidChildResumeValue(entry)
|
|
264
291
|
);
|
|
265
292
|
}
|
|
266
|
-
function parseCompoundResumeState(raw) {
|
|
293
|
+
function parseCompoundResumeState(raw, opts) {
|
|
267
294
|
if (!raw || typeof raw !== "object") return null;
|
|
268
295
|
const obj = raw;
|
|
269
296
|
if (obj.schemaVersion !== COMPOUND_RESUME_SCHEMA_VERSION) return null;
|
|
270
297
|
if (typeof obj.activePageIndex !== "number" || !Number.isFinite(obj.activePageIndex)) return null;
|
|
271
298
|
const childStates = {};
|
|
299
|
+
const droppedChildKeys = [];
|
|
272
300
|
if (obj.childStates && typeof obj.childStates === "object" && !Array.isArray(obj.childStates)) {
|
|
273
301
|
for (const [key, value] of Object.entries(obj.childStates)) {
|
|
274
302
|
if (isPlainSerializableChildState(value)) {
|
|
275
303
|
childStates[key] = value;
|
|
304
|
+
} else {
|
|
305
|
+
droppedChildKeys.push(key);
|
|
276
306
|
}
|
|
277
307
|
}
|
|
278
308
|
}
|
|
309
|
+
if (droppedChildKeys.length > 0) {
|
|
310
|
+
opts?.onDroppedChildKeys?.(droppedChildKeys);
|
|
311
|
+
}
|
|
279
312
|
const activeChapterIndex = typeof obj.activeChapterIndex === "number" && Number.isFinite(obj.activeChapterIndex) ? obj.activeChapterIndex : void 0;
|
|
280
313
|
return {
|
|
281
314
|
schemaVersion: COMPOUND_RESUME_SCHEMA_VERSION,
|
|
@@ -300,17 +333,21 @@ var COMPOUND_STATE_PREFIX = "lessonkit:compound:";
|
|
|
300
333
|
function compoundStateStorageKey(courseId, compoundId) {
|
|
301
334
|
return `${COMPOUND_STATE_PREFIX}${courseId}:${compoundId}`;
|
|
302
335
|
}
|
|
303
|
-
function loadCompoundState(storage, courseId, compoundId) {
|
|
336
|
+
function loadCompoundState(storage, courseId, compoundId, opts) {
|
|
304
337
|
const key = compoundStateStorageKey(courseId, compoundId);
|
|
305
338
|
const raw = storage.getItem(key);
|
|
306
339
|
if (!raw) return null;
|
|
307
340
|
try {
|
|
308
|
-
const parsed = parseCompoundResumeState(JSON.parse(raw));
|
|
309
|
-
if (parsed === null
|
|
310
|
-
|
|
341
|
+
const parsed = parseCompoundResumeState(JSON.parse(raw), opts);
|
|
342
|
+
if (parsed === null) {
|
|
343
|
+
opts?.onCorrupt?.();
|
|
344
|
+
if (isDevEnvironment()) {
|
|
345
|
+
console.warn(`[lessonkit] Ignoring corrupt compound resume state at ${key}`);
|
|
346
|
+
}
|
|
311
347
|
}
|
|
312
348
|
return parsed;
|
|
313
349
|
} catch {
|
|
350
|
+
opts?.onCorrupt?.();
|
|
314
351
|
if (isDevEnvironment()) {
|
|
315
352
|
console.warn(`[lessonkit] Ignoring corrupt compound resume state at ${key}`);
|
|
316
353
|
}
|
|
@@ -367,8 +404,45 @@ var PAGE_ALLOWED_CHILD_TYPES = [
|
|
|
367
404
|
"FindHotspot",
|
|
368
405
|
"FindMultipleHotspots",
|
|
369
406
|
"ImageSlider",
|
|
407
|
+
"Embed",
|
|
408
|
+
"Chart",
|
|
370
409
|
"ProgressTracker"
|
|
371
410
|
];
|
|
411
|
+
var BRANCH_NODE_ALLOWED_CHILD_TYPES = [
|
|
412
|
+
"Text",
|
|
413
|
+
"Heading",
|
|
414
|
+
"Image",
|
|
415
|
+
"Video",
|
|
416
|
+
"Scenario",
|
|
417
|
+
"Reflection",
|
|
418
|
+
"Quiz",
|
|
419
|
+
"KnowledgeCheck",
|
|
420
|
+
"TrueFalse",
|
|
421
|
+
"FillInTheBlanks",
|
|
422
|
+
"DragAndDrop",
|
|
423
|
+
"DragTheWords",
|
|
424
|
+
"MarkTheWords",
|
|
425
|
+
"Summary",
|
|
426
|
+
"ImagePairing",
|
|
427
|
+
"ImageSequencing",
|
|
428
|
+
"MemoryGame",
|
|
429
|
+
"InformationWall",
|
|
430
|
+
"ParallaxSlideshow",
|
|
431
|
+
"Questionnaire",
|
|
432
|
+
"Essay",
|
|
433
|
+
"ArithmeticQuiz",
|
|
434
|
+
"Accordion",
|
|
435
|
+
"DialogCards",
|
|
436
|
+
"Flashcards",
|
|
437
|
+
"ImageHotspots",
|
|
438
|
+
"FindHotspot",
|
|
439
|
+
"FindMultipleHotspots",
|
|
440
|
+
"ImageSlider",
|
|
441
|
+
"Embed",
|
|
442
|
+
"Chart",
|
|
443
|
+
"BranchChoice"
|
|
444
|
+
];
|
|
445
|
+
var BRANCHING_SCENARIO_ALLOWED_CHILD_TYPES = ["BranchNode"];
|
|
372
446
|
var INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES = ["Page"];
|
|
373
447
|
var SLIDE_ALLOWED_CHILD_TYPES = [
|
|
374
448
|
"Text",
|
|
@@ -399,7 +473,9 @@ var SLIDE_ALLOWED_CHILD_TYPES = [
|
|
|
399
473
|
"ImageHotspots",
|
|
400
474
|
"FindHotspot",
|
|
401
475
|
"FindMultipleHotspots",
|
|
402
|
-
"ImageSlider"
|
|
476
|
+
"ImageSlider",
|
|
477
|
+
"Embed",
|
|
478
|
+
"Chart"
|
|
403
479
|
];
|
|
404
480
|
var SLIDE_DECK_ALLOWED_CHILD_TYPES = ["Slide"];
|
|
405
481
|
var TIMED_CUE_ALLOWED_CHILD_TYPES = [
|
|
@@ -441,7 +517,9 @@ var ALLOWLISTS = {
|
|
|
441
517
|
SlideDeck: SLIDE_DECK_ALLOWED_CHILD_TYPES,
|
|
442
518
|
TimedCue: TIMED_CUE_ALLOWED_CHILD_TYPES,
|
|
443
519
|
InteractiveVideo: INTERACTIVE_VIDEO_ALLOWED_CHILD_TYPES,
|
|
444
|
-
AssessmentSequence: ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES
|
|
520
|
+
AssessmentSequence: ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES,
|
|
521
|
+
BranchingScenario: BRANCHING_SCENARIO_ALLOWED_CHILD_TYPES,
|
|
522
|
+
BranchNode: BRANCH_NODE_ALLOWED_CHILD_TYPES
|
|
445
523
|
};
|
|
446
524
|
var COMPOUND_MAX_NESTING_DEPTH = {
|
|
447
525
|
Page: 1,
|
|
@@ -450,7 +528,9 @@ var COMPOUND_MAX_NESTING_DEPTH = {
|
|
|
450
528
|
SlideDeck: 2,
|
|
451
529
|
TimedCue: 1,
|
|
452
530
|
InteractiveVideo: 2,
|
|
453
|
-
AssessmentSequence: 1
|
|
531
|
+
AssessmentSequence: 1,
|
|
532
|
+
BranchingScenario: 2,
|
|
533
|
+
BranchNode: 1
|
|
454
534
|
};
|
|
455
535
|
function getAllowedChildTypes(parent) {
|
|
456
536
|
return ALLOWLISTS[parent];
|
|
@@ -461,6 +541,82 @@ function isChildTypeAllowed(parent, childType) {
|
|
|
461
541
|
var ACCORDION_FORBIDDEN_CHILD_TYPES = ["Accordion"];
|
|
462
542
|
var BLOCKS_14_PAGE_SLIDE = PAGE_AND_SLIDE_14_BLOCKS;
|
|
463
543
|
|
|
544
|
+
// src/branchGraph.ts
|
|
545
|
+
function validateBranchGraph(startNodeId, nodes) {
|
|
546
|
+
const issues = [];
|
|
547
|
+
if (nodes.length === 0) {
|
|
548
|
+
issues.push({ code: "empty_graph", message: "Branch graph has no nodes" });
|
|
549
|
+
return { ok: false, issues, reachableNodeIds: [] };
|
|
550
|
+
}
|
|
551
|
+
const nodeIds = /* @__PURE__ */ new Set();
|
|
552
|
+
for (const node of nodes) {
|
|
553
|
+
if (nodeIds.has(node.nodeId)) {
|
|
554
|
+
issues.push({
|
|
555
|
+
code: "duplicate_node_id",
|
|
556
|
+
message: `Duplicate nodeId "${node.nodeId}"`,
|
|
557
|
+
nodeId: node.nodeId
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
nodeIds.add(node.nodeId);
|
|
561
|
+
}
|
|
562
|
+
if (!nodeIds.has(startNodeId)) {
|
|
563
|
+
issues.push({
|
|
564
|
+
code: "start_not_found",
|
|
565
|
+
message: `startNodeId "${startNodeId}" does not match any BranchNode`,
|
|
566
|
+
nodeId: startNodeId
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
if (nodes.length > 1 && nodeIds.has(startNodeId)) {
|
|
570
|
+
const startNode = nodes.find((n) => n.nodeId === startNodeId);
|
|
571
|
+
if (startNode && startNode.choices.length === 0) {
|
|
572
|
+
issues.push({
|
|
573
|
+
code: "start_no_choices",
|
|
574
|
+
message: `startNodeId "${startNodeId}" has no BranchChoice children in a multi-node scenario`,
|
|
575
|
+
nodeId: startNodeId
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
for (const node of nodes) {
|
|
580
|
+
for (const choice of node.choices) {
|
|
581
|
+
if (!nodeIds.has(choice.targetNodeId)) {
|
|
582
|
+
issues.push({
|
|
583
|
+
code: "unknown_target",
|
|
584
|
+
message: `Choice from "${node.nodeId}" references unknown target "${choice.targetNodeId}"`,
|
|
585
|
+
nodeId: node.nodeId
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
const reachable = /* @__PURE__ */ new Set();
|
|
591
|
+
if (nodeIds.has(startNodeId)) {
|
|
592
|
+
const queue = [startNodeId];
|
|
593
|
+
while (queue.length > 0) {
|
|
594
|
+
const current = queue.shift();
|
|
595
|
+
if (reachable.has(current)) continue;
|
|
596
|
+
reachable.add(current);
|
|
597
|
+
const node = nodes.find((n) => n.nodeId === current);
|
|
598
|
+
if (!node) continue;
|
|
599
|
+
for (const choice of node.choices) {
|
|
600
|
+
if (!reachable.has(choice.targetNodeId)) queue.push(choice.targetNodeId);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
for (const nodeId of nodeIds) {
|
|
605
|
+
if (!reachable.has(nodeId)) {
|
|
606
|
+
issues.push({
|
|
607
|
+
code: "unreachable_node",
|
|
608
|
+
message: `Node "${nodeId}" is not reachable from startNodeId "${startNodeId}"`,
|
|
609
|
+
nodeId
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return {
|
|
614
|
+
ok: issues.length === 0,
|
|
615
|
+
issues,
|
|
616
|
+
reachableNodeIds: [...reachable]
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
464
620
|
// src/telemetryCatalog.ts
|
|
465
621
|
var telemetryCatalogVersion = 1;
|
|
466
622
|
var TELEMETRY_EVENT_CATALOG = [
|
|
@@ -663,6 +819,22 @@ var TELEMETRY_EVENT_CATALOG_V3 = [
|
|
|
663
819
|
dataFields: ["blockId", "fieldCount"],
|
|
664
820
|
xapiVerb: "http://adlnet.gov/expapi/verbs/completed",
|
|
665
821
|
urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
|
|
822
|
+
},
|
|
823
|
+
{
|
|
824
|
+
name: "branch_node_viewed",
|
|
825
|
+
description: "Learner viewed a node in a BranchingScenario",
|
|
826
|
+
requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
|
|
827
|
+
dataFields: ["blockId", "nodeId", "nodeIndex", "nodeTitle"],
|
|
828
|
+
xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
|
|
829
|
+
urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}:node:{nodeId}"
|
|
830
|
+
},
|
|
831
|
+
{
|
|
832
|
+
name: "branch_selected",
|
|
833
|
+
description: "Learner selected a branch choice in a BranchingScenario",
|
|
834
|
+
requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
|
|
835
|
+
dataFields: ["blockId", "fromNodeId", "toNodeId", "label", "scoreWeight"],
|
|
836
|
+
xapiVerb: "http://adlnet.gov/expapi/verbs/answered",
|
|
837
|
+
urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}:node:{toNodeId}"
|
|
666
838
|
}
|
|
667
839
|
];
|
|
668
840
|
function buildTelemetryCatalogV3() {
|
|
@@ -694,22 +866,12 @@ function invokeTrackingSink(sink, event) {
|
|
|
694
866
|
void result.catch((err) => warnDev("[lessonkit] tracking sink failed:", err));
|
|
695
867
|
}
|
|
696
868
|
}
|
|
697
|
-
function invokePipelineSink(sinkId, emit) {
|
|
698
|
-
let result;
|
|
699
|
-
try {
|
|
700
|
-
result = emit();
|
|
701
|
-
} catch (err) {
|
|
702
|
-
warnDev(`[lessonkit] telemetry sink "${sinkId}" failed:`, err);
|
|
703
|
-
return;
|
|
704
|
-
}
|
|
705
|
-
if (result != null && typeof result.catch === "function") {
|
|
706
|
-
void result.catch(
|
|
707
|
-
(err) => warnDev(`[lessonkit] telemetry sink "${sinkId}" failed:`, err)
|
|
708
|
-
);
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
869
|
|
|
712
870
|
// src/trackingClient.ts
|
|
871
|
+
function eventDedupKey(event) {
|
|
872
|
+
const id = event.id?.trim();
|
|
873
|
+
return id || void 0;
|
|
874
|
+
}
|
|
713
875
|
function createTrackingClient(opts) {
|
|
714
876
|
const sink = opts?.sink;
|
|
715
877
|
const batchSink = opts?.batchSink;
|
|
@@ -727,13 +889,14 @@ function createTrackingClient(opts) {
|
|
|
727
889
|
let disposed2 = false;
|
|
728
890
|
return {
|
|
729
891
|
track: (event) => {
|
|
730
|
-
if (disposed2) return;
|
|
892
|
+
if (disposed2) return false;
|
|
731
893
|
if (sink) {
|
|
732
894
|
try {
|
|
733
895
|
invokeTrackingSink(sink, event);
|
|
734
896
|
} catch {
|
|
735
897
|
}
|
|
736
898
|
}
|
|
899
|
+
return true;
|
|
737
900
|
},
|
|
738
901
|
deliver: async (event) => {
|
|
739
902
|
if (disposed2) return false;
|
|
@@ -746,19 +909,28 @@ function createTrackingClient(opts) {
|
|
|
746
909
|
};
|
|
747
910
|
}
|
|
748
911
|
if (!sink && !batchSink) {
|
|
749
|
-
return { track: () =>
|
|
750
|
-
} };
|
|
912
|
+
return { track: () => true };
|
|
751
913
|
}
|
|
752
914
|
const buffer = [];
|
|
915
|
+
const pendingDeliverIds = /* @__PURE__ */ new Set();
|
|
753
916
|
let flushInFlight = null;
|
|
754
|
-
let inflightExitBatch = null;
|
|
755
917
|
let disposed = false;
|
|
756
918
|
let disposing = false;
|
|
757
919
|
let intervalId;
|
|
920
|
+
const clearPendingDeliverIds = (events) => {
|
|
921
|
+
for (const event of events) {
|
|
922
|
+
const key = eventDedupKey(event);
|
|
923
|
+
if (key) pendingDeliverIds.delete(key);
|
|
924
|
+
}
|
|
925
|
+
};
|
|
926
|
+
const isEventBuffered = (event) => {
|
|
927
|
+
const key = eventDedupKey(event);
|
|
928
|
+
if (!key) return false;
|
|
929
|
+
return buffer.some((buffered) => eventDedupKey(buffered) === key);
|
|
930
|
+
};
|
|
758
931
|
const runFlush = () => {
|
|
759
932
|
if (!buffer.length) return Promise.resolve(true);
|
|
760
933
|
const events = buffer.splice(0, buffer.length);
|
|
761
|
-
inflightExitBatch = events;
|
|
762
934
|
let succeeded = false;
|
|
763
935
|
return Promise.resolve().then(async () => {
|
|
764
936
|
if (batchSink) {
|
|
@@ -779,12 +951,13 @@ function createTrackingClient(opts) {
|
|
|
779
951
|
buffer.unshift(...events);
|
|
780
952
|
}
|
|
781
953
|
}).then(async () => {
|
|
954
|
+
if (succeeded) {
|
|
955
|
+
clearPendingDeliverIds(events);
|
|
956
|
+
}
|
|
782
957
|
if (succeeded && buffer.length > 0 && !disposed) {
|
|
783
958
|
return runFlush();
|
|
784
959
|
}
|
|
785
960
|
return succeeded;
|
|
786
|
-
}).finally(() => {
|
|
787
|
-
inflightExitBatch = null;
|
|
788
961
|
});
|
|
789
962
|
};
|
|
790
963
|
const flush = () => {
|
|
@@ -805,18 +978,27 @@ function createTrackingClient(opts) {
|
|
|
805
978
|
if (!delivered) break;
|
|
806
979
|
}
|
|
807
980
|
if (buffer.length > 0) {
|
|
981
|
+
const droppedCount = buffer.length;
|
|
808
982
|
if (isDevEnvironment()) {
|
|
809
983
|
console.warn(
|
|
810
|
-
`[lessonkit] dropped ${
|
|
984
|
+
`[lessonkit] dropped ${droppedCount} buffered telemetry event(s) after dispose flush cap`
|
|
811
985
|
);
|
|
812
986
|
}
|
|
987
|
+
for (let i = 0; i < droppedCount; i++) {
|
|
988
|
+
opts?.onBufferDrop?.();
|
|
989
|
+
}
|
|
813
990
|
buffer.length = 0;
|
|
991
|
+
pendingDeliverIds.clear();
|
|
814
992
|
}
|
|
815
993
|
};
|
|
816
994
|
intervalId = flushIntervalMs > 0 ? globalThis.setInterval(() => void flush(), flushIntervalMs) : void 0;
|
|
817
995
|
intervalId?.unref?.();
|
|
818
996
|
const track = (event) => {
|
|
819
|
-
if (disposed || disposing) return;
|
|
997
|
+
if (disposed || disposing) return false;
|
|
998
|
+
const key = eventDedupKey(event);
|
|
999
|
+
if (key && (pendingDeliverIds.has(key) || isEventBuffered(event))) {
|
|
1000
|
+
return true;
|
|
1001
|
+
}
|
|
820
1002
|
if (buffer.length >= maxBufferSize) {
|
|
821
1003
|
opts?.onBufferDrop?.();
|
|
822
1004
|
if (!warnedBufferCap && isDevEnvironment()) {
|
|
@@ -825,29 +1007,37 @@ function createTrackingClient(opts) {
|
|
|
825
1007
|
`[lessonkit] telemetry batch buffer capped at ${maxBufferSize} events; new events are dropped until the buffer drains.`
|
|
826
1008
|
);
|
|
827
1009
|
}
|
|
828
|
-
return;
|
|
1010
|
+
return false;
|
|
829
1011
|
}
|
|
830
1012
|
buffer.push(event);
|
|
831
1013
|
if (buffer.length >= maxBatchSize) void flush();
|
|
1014
|
+
return true;
|
|
832
1015
|
};
|
|
833
1016
|
return {
|
|
834
1017
|
track,
|
|
835
1018
|
deliver: async (event) => {
|
|
836
|
-
|
|
1019
|
+
const key = eventDedupKey(event);
|
|
1020
|
+
if (key && (pendingDeliverIds.has(key) || isEventBuffered(event))) {
|
|
1021
|
+
return flush();
|
|
1022
|
+
}
|
|
1023
|
+
if (!track(event)) return false;
|
|
1024
|
+
if (key) pendingDeliverIds.add(key);
|
|
837
1025
|
return flush();
|
|
838
1026
|
},
|
|
839
1027
|
flush,
|
|
840
1028
|
flushOnExit: opts?.exitBatchSink ? () => {
|
|
841
|
-
const
|
|
842
|
-
const fromInflight = inflightExitBatch ? [...inflightExitBatch] : [];
|
|
843
|
-
const events = [...fromInflight, ...fromBuffer];
|
|
1029
|
+
const events = buffer.splice(0, buffer.length);
|
|
844
1030
|
if (!events.length) return;
|
|
845
1031
|
try {
|
|
846
1032
|
const result = opts.exitBatchSink(events);
|
|
847
1033
|
if (result != null && typeof result.catch === "function") {
|
|
848
|
-
void result.
|
|
1034
|
+
void result.then(() => {
|
|
1035
|
+
clearPendingDeliverIds(events);
|
|
1036
|
+
}).catch(() => {
|
|
849
1037
|
buffer.unshift(...events);
|
|
850
1038
|
});
|
|
1039
|
+
} else {
|
|
1040
|
+
clearPendingDeliverIds(events);
|
|
851
1041
|
}
|
|
852
1042
|
} catch {
|
|
853
1043
|
buffer.unshift(...events);
|
|
@@ -869,10 +1059,21 @@ function createTrackingClient(opts) {
|
|
|
869
1059
|
}
|
|
870
1060
|
|
|
871
1061
|
// src/ids.ts
|
|
1062
|
+
function randomSessionIdFallback() {
|
|
1063
|
+
const g = globalThis;
|
|
1064
|
+
if (g.crypto?.getRandomValues) {
|
|
1065
|
+
const bytes = new Uint8Array(16);
|
|
1066
|
+
g.crypto.getRandomValues(bytes);
|
|
1067
|
+
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
1068
|
+
}
|
|
1069
|
+
throw new Error(
|
|
1070
|
+
"[lessonkit] createSessionId requires crypto.randomUUID or crypto.getRandomValues"
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
872
1073
|
function createSessionId() {
|
|
873
1074
|
const g = globalThis;
|
|
874
1075
|
if (g.crypto?.randomUUID) return g.crypto.randomUUID();
|
|
875
|
-
return
|
|
1076
|
+
return randomSessionIdFallback();
|
|
876
1077
|
}
|
|
877
1078
|
|
|
878
1079
|
// src/time.ts
|
|
@@ -1151,6 +1352,34 @@ var TELEMETRY_EVENT_REGISTRY = {
|
|
|
1151
1352
|
data: opts.data
|
|
1152
1353
|
};
|
|
1153
1354
|
}
|
|
1355
|
+
},
|
|
1356
|
+
branch_node_viewed: {
|
|
1357
|
+
requiresLessonId: true,
|
|
1358
|
+
build: (opts, base) => {
|
|
1359
|
+
if (opts.name !== "branch_node_viewed") throw new Error("unexpected event");
|
|
1360
|
+
const lessonId = opts.lessonId;
|
|
1361
|
+
if (!lessonId) throw new Error("branch_node_viewed requires active lessonId");
|
|
1362
|
+
return {
|
|
1363
|
+
name: "branch_node_viewed",
|
|
1364
|
+
...base,
|
|
1365
|
+
lessonId,
|
|
1366
|
+
data: opts.data
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
},
|
|
1370
|
+
branch_selected: {
|
|
1371
|
+
requiresLessonId: true,
|
|
1372
|
+
build: (opts, base) => {
|
|
1373
|
+
if (opts.name !== "branch_selected") throw new Error("unexpected event");
|
|
1374
|
+
const lessonId = opts.lessonId;
|
|
1375
|
+
if (!lessonId) throw new Error("branch_selected requires active lessonId");
|
|
1376
|
+
return {
|
|
1377
|
+
name: "branch_selected",
|
|
1378
|
+
...base,
|
|
1379
|
+
lessonId,
|
|
1380
|
+
data: opts.data
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1154
1383
|
}
|
|
1155
1384
|
};
|
|
1156
1385
|
function buildTelemetryEventFromRegistry(opts) {
|
|
@@ -1183,8 +1412,8 @@ function buildTelemetryEvent(opts) {
|
|
|
1183
1412
|
}
|
|
1184
1413
|
function tryBuildTelemetryEvent(opts) {
|
|
1185
1414
|
const entry = getTelemetryEventRegistryEntry(opts.name);
|
|
1186
|
-
if (entry.requiresLessonId && !opts.lessonId
|
|
1187
|
-
if (isDevEnvironment()) {
|
|
1415
|
+
if (entry.requiresLessonId && !opts.lessonId) {
|
|
1416
|
+
if (isDevEnvironment() && entry.tryBuildMissingLessonWarning) {
|
|
1188
1417
|
if (entry.tryBuildMissingLessonWarning === "quiz" && !warnedMissingQuizLesson) {
|
|
1189
1418
|
warnedMissingQuizLesson = true;
|
|
1190
1419
|
console.warn(
|
|
@@ -1204,21 +1433,44 @@ function tryBuildTelemetryEvent(opts) {
|
|
|
1204
1433
|
}
|
|
1205
1434
|
|
|
1206
1435
|
// src/telemetryPipeline.ts
|
|
1207
|
-
|
|
1208
|
-
|
|
1436
|
+
var LIFECYCLE_TELEMETRY_EVENTS = /* @__PURE__ */ new Set([
|
|
1437
|
+
"course_started",
|
|
1438
|
+
"course_completed",
|
|
1439
|
+
"lesson_started",
|
|
1440
|
+
"lesson_completed",
|
|
1441
|
+
"lesson_time_on_task"
|
|
1442
|
+
]);
|
|
1443
|
+
function isLifecycleTelemetryEvent(name) {
|
|
1444
|
+
return LIFECYCLE_TELEMETRY_EVENTS.has(name);
|
|
1445
|
+
}
|
|
1446
|
+
async function invokeSink(sink, event, emitCtx) {
|
|
1447
|
+
let result;
|
|
1448
|
+
try {
|
|
1449
|
+
result = sink.emit(event, emitCtx);
|
|
1450
|
+
} catch (err) {
|
|
1451
|
+
warnDev(`[lessonkit] telemetry sink "${sink.id}" failed:`, err);
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
if (result != null && typeof result.then === "function") {
|
|
1455
|
+
try {
|
|
1456
|
+
await result;
|
|
1457
|
+
} catch (err) {
|
|
1458
|
+
warnDev(`[lessonkit] telemetry sink "${sink.id}" failed:`, err);
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1209
1461
|
}
|
|
1210
1462
|
function createTelemetryPipeline(sinks) {
|
|
1211
1463
|
const list = [...sinks];
|
|
1212
1464
|
return {
|
|
1213
1465
|
sinks: list,
|
|
1214
|
-
emit(event, ctx) {
|
|
1466
|
+
async emit(event, ctx) {
|
|
1215
1467
|
const emitCtx = ctx ?? {
|
|
1216
1468
|
courseId: event.courseId,
|
|
1217
1469
|
sessionId: event.sessionId,
|
|
1218
1470
|
attemptId: event.attemptId
|
|
1219
1471
|
};
|
|
1220
1472
|
for (const sink of list) {
|
|
1221
|
-
invokeSink(sink, event, emitCtx);
|
|
1473
|
+
await invokeSink(sink, event, emitCtx);
|
|
1222
1474
|
}
|
|
1223
1475
|
}
|
|
1224
1476
|
};
|
|
@@ -1240,14 +1492,44 @@ function createDefaultClock() {
|
|
|
1240
1492
|
};
|
|
1241
1493
|
}
|
|
1242
1494
|
function createNoopStorage() {
|
|
1495
|
+
const memory = /* @__PURE__ */ new Map();
|
|
1243
1496
|
return {
|
|
1244
|
-
getItem: () => null,
|
|
1245
|
-
setItem: () =>
|
|
1497
|
+
getItem: (key) => memory.get(key) ?? null,
|
|
1498
|
+
setItem: (key, value) => {
|
|
1499
|
+
memory.set(key, value);
|
|
1500
|
+
return true;
|
|
1501
|
+
},
|
|
1502
|
+
removeItem: (key) => {
|
|
1503
|
+
memory.delete(key);
|
|
1504
|
+
},
|
|
1505
|
+
resetForTests: () => {
|
|
1506
|
+
memory.clear();
|
|
1507
|
+
}
|
|
1246
1508
|
};
|
|
1247
1509
|
}
|
|
1248
1510
|
function createMemoryBackedSessionStorage(session) {
|
|
1249
1511
|
const memory = /* @__PURE__ */ new Map();
|
|
1512
|
+
const tombstones = /* @__PURE__ */ new Set();
|
|
1250
1513
|
let warnedPersistFailure = false;
|
|
1514
|
+
const syncFromStorageEvent = (key, newValue) => {
|
|
1515
|
+
if (key === null) {
|
|
1516
|
+
memory.clear();
|
|
1517
|
+
tombstones.clear();
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
tombstones.delete(key);
|
|
1521
|
+
if (newValue === null) {
|
|
1522
|
+
memory.delete(key);
|
|
1523
|
+
} else {
|
|
1524
|
+
memory.set(key, newValue);
|
|
1525
|
+
}
|
|
1526
|
+
};
|
|
1527
|
+
if (typeof window !== "undefined") {
|
|
1528
|
+
window.addEventListener("storage", (event) => {
|
|
1529
|
+
if (event.storageArea !== sessionStorage) return;
|
|
1530
|
+
syncFromStorageEvent(event.key, event.newValue);
|
|
1531
|
+
});
|
|
1532
|
+
}
|
|
1251
1533
|
const warnPersistFailure = () => {
|
|
1252
1534
|
if (warnedPersistFailure) return;
|
|
1253
1535
|
warnedPersistFailure = true;
|
|
@@ -1260,6 +1542,7 @@ function createMemoryBackedSessionStorage(session) {
|
|
|
1260
1542
|
};
|
|
1261
1543
|
return {
|
|
1262
1544
|
getItem: (key) => {
|
|
1545
|
+
if (tombstones.has(key)) return null;
|
|
1263
1546
|
if (memory.has(key)) return memory.get(key);
|
|
1264
1547
|
try {
|
|
1265
1548
|
const value = session.getItem(key);
|
|
@@ -1270,6 +1553,7 @@ function createMemoryBackedSessionStorage(session) {
|
|
|
1270
1553
|
}
|
|
1271
1554
|
},
|
|
1272
1555
|
setItem: (key, value) => {
|
|
1556
|
+
tombstones.delete(key);
|
|
1273
1557
|
memory.set(key, value);
|
|
1274
1558
|
try {
|
|
1275
1559
|
session.setItem(key, value);
|
|
@@ -1283,12 +1567,15 @@ function createMemoryBackedSessionStorage(session) {
|
|
|
1283
1567
|
memory.delete(key);
|
|
1284
1568
|
try {
|
|
1285
1569
|
session.removeItem(key);
|
|
1570
|
+
tombstones.delete(key);
|
|
1286
1571
|
} catch {
|
|
1287
1572
|
warnPersistFailure();
|
|
1573
|
+
tombstones.add(key);
|
|
1288
1574
|
}
|
|
1289
1575
|
},
|
|
1290
1576
|
resetForTests: () => {
|
|
1291
1577
|
memory.clear();
|
|
1578
|
+
tombstones.clear();
|
|
1292
1579
|
}
|
|
1293
1580
|
};
|
|
1294
1581
|
}
|
|
@@ -1360,6 +1647,11 @@ function createProgressController() {
|
|
|
1360
1647
|
}
|
|
1361
1648
|
return { didComplete: false };
|
|
1362
1649
|
}
|
|
1650
|
+
if (!lessonStartTimes.has(lessonId) && isDevEnvironment()) {
|
|
1651
|
+
console.warn(
|
|
1652
|
+
`[lessonkit] completeLesson("${lessonId}") called without activating the lesson first`
|
|
1653
|
+
);
|
|
1654
|
+
}
|
|
1363
1655
|
completedLessonIds = new Set(completedLessonIds).add(lessonId);
|
|
1364
1656
|
if (activeLessonId === lessonId) {
|
|
1365
1657
|
activeLessonId = void 0;
|
|
@@ -1380,7 +1672,6 @@ function createProgressController() {
|
|
|
1380
1672
|
// src/session.ts
|
|
1381
1673
|
var SESSION_STORAGE_KEY = "lessonkit:sessionId";
|
|
1382
1674
|
var volatileSessionIds = /* @__PURE__ */ new WeakMap();
|
|
1383
|
-
var sharedVolatileSessionId = null;
|
|
1384
1675
|
function isDevEnvironment2() {
|
|
1385
1676
|
const g = globalThis;
|
|
1386
1677
|
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
@@ -1391,10 +1682,23 @@ function getTabSessionId(storage) {
|
|
|
1391
1682
|
var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
|
|
1392
1683
|
var COURSE_STARTED_TRACKING_PREFIX = "lessonkit:course_started_tracking:";
|
|
1393
1684
|
var COURSE_STARTED_PIPELINE_PREFIX = "lessonkit:course_started_pipeline:";
|
|
1685
|
+
var COURSE_STARTED_XAPI_PREFIX = "lessonkit:course_started_xapi:";
|
|
1686
|
+
function sessionKeySegment(sessionId) {
|
|
1687
|
+
const validated = validateId(sessionId);
|
|
1688
|
+
return validated.ok ? validated.id : encodeURIComponent(sessionId);
|
|
1689
|
+
}
|
|
1394
1690
|
function resolveSessionId(storage, provided) {
|
|
1395
1691
|
if (provided !== void 0) {
|
|
1396
1692
|
const trimmed = provided.trim();
|
|
1397
|
-
if (trimmed.length > 0)
|
|
1693
|
+
if (trimmed.length > 0) {
|
|
1694
|
+
const validated = validateId(trimmed);
|
|
1695
|
+
if (validated.ok) return validated.id;
|
|
1696
|
+
if (isDevEnvironment2()) {
|
|
1697
|
+
console.warn(
|
|
1698
|
+
`[lessonkit] Invalid sessionId "${trimmed}"; falling back to tab or generated id.`
|
|
1699
|
+
);
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1398
1702
|
}
|
|
1399
1703
|
const existing = storage.getItem(SESSION_STORAGE_KEY);
|
|
1400
1704
|
if (existing) return existing;
|
|
@@ -1403,27 +1707,27 @@ function resolveSessionId(storage, provided) {
|
|
|
1403
1707
|
const id = createSessionId();
|
|
1404
1708
|
const persisted = storage.setItem(SESSION_STORAGE_KEY, id);
|
|
1405
1709
|
if (!persisted) {
|
|
1406
|
-
|
|
1407
|
-
sharedVolatileSessionId = id;
|
|
1408
|
-
}
|
|
1409
|
-
volatileSessionIds.set(storage, sharedVolatileSessionId);
|
|
1710
|
+
volatileSessionIds.set(storage, id);
|
|
1410
1711
|
if (isDevEnvironment2()) {
|
|
1411
1712
|
console.warn(
|
|
1412
|
-
"[lessonkit] session id could not be persisted;
|
|
1713
|
+
"[lessonkit] session id could not be persisted; using in-memory id for this storage."
|
|
1413
1714
|
);
|
|
1414
1715
|
}
|
|
1415
|
-
return
|
|
1716
|
+
return id;
|
|
1416
1717
|
}
|
|
1417
1718
|
return id;
|
|
1418
1719
|
}
|
|
1419
1720
|
function courseStartedStorageKey(sessionId, courseId) {
|
|
1420
|
-
return `${COURSE_STARTED_PREFIX}${sessionId}:${courseId ?? ""}`;
|
|
1721
|
+
return `${COURSE_STARTED_PREFIX}${sessionKeySegment(sessionId)}:${courseId ?? ""}`;
|
|
1421
1722
|
}
|
|
1422
1723
|
function courseStartedTrackingStorageKey(sessionId, courseId) {
|
|
1423
|
-
return `${COURSE_STARTED_TRACKING_PREFIX}${sessionId}:${courseId ?? ""}`;
|
|
1724
|
+
return `${COURSE_STARTED_TRACKING_PREFIX}${sessionKeySegment(sessionId)}:${courseId ?? ""}`;
|
|
1424
1725
|
}
|
|
1425
1726
|
function courseStartedPipelineStorageKey(sessionId, courseId) {
|
|
1426
|
-
return `${COURSE_STARTED_PIPELINE_PREFIX}${sessionId}:${courseId ?? ""}`;
|
|
1727
|
+
return `${COURSE_STARTED_PIPELINE_PREFIX}${sessionKeySegment(sessionId)}:${courseId ?? ""}`;
|
|
1728
|
+
}
|
|
1729
|
+
function courseStartedXapiStorageKey(sessionId, courseId) {
|
|
1730
|
+
return `${COURSE_STARTED_XAPI_PREFIX}${sessionKeySegment(sessionId)}:${courseId ?? ""}`;
|
|
1427
1731
|
}
|
|
1428
1732
|
function hasCourseStarted(storage, sessionId, courseId) {
|
|
1429
1733
|
if (!courseId) return false;
|
|
@@ -1449,49 +1753,82 @@ function markCourseStartedPipelineDelivered(storage, sessionId, courseId) {
|
|
|
1449
1753
|
if (!courseId) return false;
|
|
1450
1754
|
return storage.setItem(courseStartedPipelineStorageKey(sessionId, courseId), "1");
|
|
1451
1755
|
}
|
|
1756
|
+
function hasCourseStartedXapiSent(storage, sessionId, courseId) {
|
|
1757
|
+
if (!courseId) return false;
|
|
1758
|
+
return storage.getItem(courseStartedXapiStorageKey(sessionId, courseId)) === "1";
|
|
1759
|
+
}
|
|
1760
|
+
function markCourseStartedXapiSent(storage, sessionId, courseId) {
|
|
1761
|
+
if (!courseId) return false;
|
|
1762
|
+
return storage.setItem(courseStartedXapiStorageKey(sessionId, courseId), "1");
|
|
1763
|
+
}
|
|
1452
1764
|
function resetSharedVolatileSessionIdForTests() {
|
|
1453
|
-
|
|
1765
|
+
}
|
|
1766
|
+
function migrateStorageMark(storage, fromKey, toKey, hasMark) {
|
|
1767
|
+
if (!hasMark) return;
|
|
1768
|
+
if (storage.setItem(toKey, "1")) {
|
|
1769
|
+
storage.removeItem?.(fromKey);
|
|
1770
|
+
}
|
|
1454
1771
|
}
|
|
1455
1772
|
function migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId) {
|
|
1456
1773
|
if (!courseId || fromSessionId === toSessionId) return;
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
storage
|
|
1468
|
-
|
|
1774
|
+
migrateStorageMark(
|
|
1775
|
+
storage,
|
|
1776
|
+
courseStartedStorageKey(fromSessionId, courseId),
|
|
1777
|
+
courseStartedStorageKey(toSessionId, courseId),
|
|
1778
|
+
hasCourseStarted(storage, fromSessionId, courseId)
|
|
1779
|
+
);
|
|
1780
|
+
migrateStorageMark(
|
|
1781
|
+
storage,
|
|
1782
|
+
courseStartedTrackingStorageKey(fromSessionId, courseId),
|
|
1783
|
+
courseStartedTrackingStorageKey(toSessionId, courseId),
|
|
1784
|
+
hasCourseStartedEmittedToTracking(storage, fromSessionId, courseId)
|
|
1785
|
+
);
|
|
1786
|
+
migrateStorageMark(
|
|
1787
|
+
storage,
|
|
1788
|
+
courseStartedPipelineStorageKey(fromSessionId, courseId),
|
|
1789
|
+
courseStartedPipelineStorageKey(toSessionId, courseId),
|
|
1790
|
+
hasCourseStartedPipelineDelivered(storage, fromSessionId, courseId)
|
|
1791
|
+
);
|
|
1792
|
+
migrateStorageMark(
|
|
1793
|
+
storage,
|
|
1794
|
+
courseStartedXapiStorageKey(fromSessionId, courseId),
|
|
1795
|
+
courseStartedXapiStorageKey(toSessionId, courseId),
|
|
1796
|
+
hasCourseStartedXapiSent(storage, fromSessionId, courseId)
|
|
1797
|
+
);
|
|
1469
1798
|
}
|
|
1470
1799
|
|
|
1471
1800
|
// src/runtime/courseLifecycle.ts
|
|
1472
|
-
var courseStartedEmitFlights = /* @__PURE__ */ new
|
|
1801
|
+
var courseStartedEmitFlights = /* @__PURE__ */ new Map();
|
|
1473
1802
|
function tryEmitCourseStarted(ctx, deps, alreadyEmittedToSink) {
|
|
1474
1803
|
const flightKey = `${ctx.sessionId}:${ctx.courseId}`;
|
|
1475
1804
|
const marked = hasCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
|
|
1476
1805
|
if (alreadyEmittedToSink) {
|
|
1477
|
-
return { emitted: true, marked };
|
|
1806
|
+
return Promise.resolve({ emitted: true, marked });
|
|
1478
1807
|
}
|
|
1479
|
-
|
|
1480
|
-
|
|
1808
|
+
const existing = courseStartedEmitFlights.get(flightKey);
|
|
1809
|
+
if (existing) {
|
|
1810
|
+
return existing;
|
|
1481
1811
|
}
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1812
|
+
const flight = Promise.resolve().then(() => {
|
|
1813
|
+
try {
|
|
1814
|
+
const emitted = deps.emitCourseStartedEvent(ctx);
|
|
1815
|
+
if (emitted && !marked) {
|
|
1816
|
+
markCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
|
|
1817
|
+
}
|
|
1818
|
+
return {
|
|
1819
|
+
emitted,
|
|
1820
|
+
marked: hasCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId)
|
|
1821
|
+
};
|
|
1822
|
+
} catch {
|
|
1823
|
+
return { emitted: false, marked };
|
|
1824
|
+
} finally {
|
|
1825
|
+
if (courseStartedEmitFlights.get(flightKey) === flight) {
|
|
1826
|
+
courseStartedEmitFlights.delete(flightKey);
|
|
1827
|
+
}
|
|
1487
1828
|
}
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
};
|
|
1492
|
-
} finally {
|
|
1493
|
-
courseStartedEmitFlights.delete(flightKey);
|
|
1494
|
-
}
|
|
1829
|
+
});
|
|
1830
|
+
courseStartedEmitFlights.set(flightKey, flight);
|
|
1831
|
+
return flight;
|
|
1495
1832
|
}
|
|
1496
1833
|
function buildCourseStartedTelemetryEvent(ctx) {
|
|
1497
1834
|
return buildTelemetryEvent({
|
|
@@ -1540,6 +1877,20 @@ function warnDuplicatePlugin(id) {
|
|
|
1540
1877
|
if (typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production") return;
|
|
1541
1878
|
console.warn(`[lessonkit] plugin id "${id}" was registered more than once; using the latest definition`);
|
|
1542
1879
|
}
|
|
1880
|
+
function stableUserHash(user) {
|
|
1881
|
+
if (!user) return "";
|
|
1882
|
+
const keys = Object.keys(user).sort();
|
|
1883
|
+
const normalized = {};
|
|
1884
|
+
for (const key of keys) {
|
|
1885
|
+
normalized[key] = user[key];
|
|
1886
|
+
}
|
|
1887
|
+
let h = 0;
|
|
1888
|
+
const serialized = JSON.stringify(normalized);
|
|
1889
|
+
for (let i = 0; i < serialized.length; i++) {
|
|
1890
|
+
h = Math.imul(31, h) + serialized.charCodeAt(i) >>> 0;
|
|
1891
|
+
}
|
|
1892
|
+
return h.toString(36);
|
|
1893
|
+
}
|
|
1543
1894
|
function createPluginRegistry(plugins = []) {
|
|
1544
1895
|
const registry = /* @__PURE__ */ new Map();
|
|
1545
1896
|
for (const plugin of plugins) {
|
|
@@ -1581,7 +1932,7 @@ function createPluginRegistry(plugins = []) {
|
|
|
1581
1932
|
const composeTrackingSink = (sink, ctxSource) => {
|
|
1582
1933
|
if (!sink) return void 0;
|
|
1583
1934
|
const resolveCtx = () => typeof ctxSource === "function" ? ctxSource() : ctxSource;
|
|
1584
|
-
const ctxKey = (ctx) => `${ctx.courseId}\0${ctx.sessionId ?? ""}\0${ctx.attemptId ?? ""}\0${ctx.user
|
|
1935
|
+
const ctxKey = (ctx) => `${ctx.courseId}\0${ctx.sessionId ?? ""}\0${ctx.attemptId ?? ""}\0${stableUserHash(ctx.user)}`;
|
|
1585
1936
|
const layers = [];
|
|
1586
1937
|
let composed = sink;
|
|
1587
1938
|
for (const plugin of list) {
|
|
@@ -1646,6 +1997,7 @@ function createLessonkitRuntime(config, ports = {}) {
|
|
|
1646
1997
|
let courseId = configSnapshot.courseId;
|
|
1647
1998
|
let progress = createProgressController();
|
|
1648
1999
|
let pluginHost = resolvePluginHost(configSnapshot.plugins);
|
|
2000
|
+
let disposed = false;
|
|
1649
2001
|
const getPluginCtx = () => buildPluginContext({
|
|
1650
2002
|
courseId,
|
|
1651
2003
|
sessionId,
|
|
@@ -1679,28 +2031,24 @@ function createLessonkitRuntime(config, ports = {}) {
|
|
|
1679
2031
|
if (!event) return null;
|
|
1680
2032
|
return applyPluginsToEvent(event);
|
|
1681
2033
|
};
|
|
1682
|
-
const
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
if (event === null) return;
|
|
1686
|
-
const eventLessonId = "lessonId" in event ? event.lessonId : lessonId;
|
|
1687
|
-
const eventData = "data" in event ? event.data : data;
|
|
1688
|
-
emitFn(event.name, eventData, eventLessonId);
|
|
1689
|
-
};
|
|
2034
|
+
const emitLifecycleEvent = (emitFn, name, data, lessonId) => {
|
|
2035
|
+
const event = buildAndApply(name, data, lessonId);
|
|
2036
|
+
if (event) emitFn(event);
|
|
1690
2037
|
};
|
|
1691
2038
|
syncSessionFromConfig(configSnapshot);
|
|
1692
2039
|
const track = (name, data, emit, lessonId) => {
|
|
2040
|
+
if (disposed) return;
|
|
1693
2041
|
const event = buildAndApply(name, data, lessonId);
|
|
1694
2042
|
if (!event) return;
|
|
1695
2043
|
emit(event);
|
|
1696
2044
|
};
|
|
1697
2045
|
const emitLessonCompletedEvents = (lessonId, durationMs, emitFn) => {
|
|
1698
|
-
|
|
1699
|
-
wrapped("lesson_completed", { lessonId, durationMs }, lessonId);
|
|
2046
|
+
emitLifecycleEvent(emitFn, "lesson_completed", { lessonId, durationMs }, lessonId);
|
|
1700
2047
|
if (durationMs !== void 0) {
|
|
1701
|
-
|
|
2048
|
+
emitLifecycleEvent(emitFn, "lesson_time_on_task", { lessonId, durationMs }, lessonId);
|
|
1702
2049
|
}
|
|
1703
2050
|
};
|
|
2051
|
+
const autoCompleteOnLessonSwitch = () => configSnapshot.autoCompleteOnLessonSwitch ?? true;
|
|
1704
2052
|
return {
|
|
1705
2053
|
get config() {
|
|
1706
2054
|
return configSnapshot;
|
|
@@ -1713,7 +2061,12 @@ function createLessonkitRuntime(config, ports = {}) {
|
|
|
1713
2061
|
},
|
|
1714
2062
|
getProgressState: () => progress.getState(),
|
|
1715
2063
|
getSession,
|
|
2064
|
+
migrateSessionMarks(fromSessionId, toSessionId) {
|
|
2065
|
+
if (disposed) return;
|
|
2066
|
+
migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId);
|
|
2067
|
+
},
|
|
1716
2068
|
updateConfig(next) {
|
|
2069
|
+
if (disposed) return;
|
|
1717
2070
|
const previousCourseId = courseId;
|
|
1718
2071
|
const sessionKeyBefore = JSON.stringify({ sessionId, attemptId, user });
|
|
1719
2072
|
if (next.courseId !== void 0) configSnapshot.courseId = next.courseId;
|
|
@@ -1721,6 +2074,9 @@ function createLessonkitRuntime(config, ports = {}) {
|
|
|
1721
2074
|
if (next.runtimeVersion === "v1") warnRuntimeV1Deprecated();
|
|
1722
2075
|
configSnapshot.runtimeVersion = next.runtimeVersion;
|
|
1723
2076
|
}
|
|
2077
|
+
if (next.autoCompleteOnLessonSwitch !== void 0) {
|
|
2078
|
+
configSnapshot.autoCompleteOnLessonSwitch = next.autoCompleteOnLessonSwitch;
|
|
2079
|
+
}
|
|
1724
2080
|
if (next.session !== void 0) {
|
|
1725
2081
|
configSnapshot.session = { ...configSnapshot.session, ...next.session };
|
|
1726
2082
|
}
|
|
@@ -1742,14 +2098,14 @@ function createLessonkitRuntime(config, ports = {}) {
|
|
|
1742
2098
|
}
|
|
1743
2099
|
},
|
|
1744
2100
|
setActiveLesson(lessonId, emitFn) {
|
|
1745
|
-
|
|
2101
|
+
if (disposed) return;
|
|
1746
2102
|
const current = progress.getState();
|
|
1747
2103
|
if (current.activeLessonId === lessonId) return;
|
|
1748
2104
|
const previous = current.activeLessonId;
|
|
1749
|
-
if (previous && previous !== lessonId && !current.completedLessonIds.has(previous)) {
|
|
2105
|
+
if (autoCompleteOnLessonSwitch() && previous && previous !== lessonId && !current.completedLessonIds.has(previous)) {
|
|
1750
2106
|
const completed = progress.completeLesson(previous, clock.nowMs());
|
|
1751
2107
|
if (completed.didComplete) {
|
|
1752
|
-
emitLessonCompletedEvents(previous, completed.durationMs,
|
|
2108
|
+
emitLessonCompletedEvents(previous, completed.durationMs, emitFn);
|
|
1753
2109
|
}
|
|
1754
2110
|
}
|
|
1755
2111
|
if (current.completedLessonIds.has(lessonId)) {
|
|
@@ -1757,38 +2113,47 @@ function createLessonkitRuntime(config, ports = {}) {
|
|
|
1757
2113
|
return;
|
|
1758
2114
|
}
|
|
1759
2115
|
progress.setActiveLesson(lessonId, clock.nowMs());
|
|
1760
|
-
|
|
2116
|
+
emitLifecycleEvent(emitFn, "lesson_started", { lessonId }, lessonId);
|
|
1761
2117
|
},
|
|
1762
2118
|
completeLesson(lessonId, emitFn) {
|
|
2119
|
+
if (disposed) return;
|
|
1763
2120
|
completeLessonWithTelemetry({
|
|
1764
2121
|
progress,
|
|
1765
2122
|
lessonId,
|
|
1766
2123
|
nowMs: clock.nowMs(),
|
|
1767
|
-
emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs,
|
|
2124
|
+
emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs, emitFn)
|
|
1768
2125
|
});
|
|
1769
2126
|
},
|
|
1770
2127
|
completeCourse(emitFn) {
|
|
2128
|
+
if (disposed) return;
|
|
1771
2129
|
completeCourseWithTelemetry({
|
|
1772
2130
|
progress,
|
|
1773
2131
|
nowMs: clock.nowMs(),
|
|
1774
|
-
emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs,
|
|
1775
|
-
emitCourseCompleted: () =>
|
|
2132
|
+
emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs, emitFn),
|
|
2133
|
+
emitCourseCompleted: () => emitLifecycleEvent(emitFn, "course_completed")
|
|
1776
2134
|
});
|
|
1777
2135
|
},
|
|
1778
2136
|
track,
|
|
1779
2137
|
scoreAssessment(input, lessonId) {
|
|
1780
|
-
if (!pluginHost) return null;
|
|
2138
|
+
if (disposed || !pluginHost) return null;
|
|
1781
2139
|
return pluginHost.scoreAssessment(
|
|
1782
2140
|
{ ...input, lessonId: input.lessonId ?? lessonId },
|
|
1783
2141
|
getPluginCtx()
|
|
1784
2142
|
);
|
|
1785
2143
|
},
|
|
1786
2144
|
resetForCourseChange(nextCourseId) {
|
|
2145
|
+
if (disposed) return;
|
|
1787
2146
|
configSnapshot.courseId = nextCourseId;
|
|
1788
2147
|
courseId = nextCourseId;
|
|
1789
2148
|
progress = createProgressController();
|
|
2149
|
+
pluginHost?.disposeAll();
|
|
2150
|
+
if (!configSnapshot.deferPluginSetup) {
|
|
2151
|
+
pluginHost?.setupAll(getPluginCtx());
|
|
2152
|
+
}
|
|
1790
2153
|
},
|
|
1791
2154
|
dispose() {
|
|
2155
|
+
if (disposed) return;
|
|
2156
|
+
disposed = true;
|
|
1792
2157
|
pluginHost?.disposeAll();
|
|
1793
2158
|
}
|
|
1794
2159
|
};
|
|
@@ -1809,6 +2174,8 @@ function defineLifecyclePlugin(plugin) {
|
|
|
1809
2174
|
ACCORDION_FORBIDDEN_CHILD_TYPES,
|
|
1810
2175
|
ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES,
|
|
1811
2176
|
BLOCKS_14_PAGE_SLIDE,
|
|
2177
|
+
BRANCHING_SCENARIO_ALLOWED_CHILD_TYPES,
|
|
2178
|
+
BRANCH_NODE_ALLOWED_CHILD_TYPES,
|
|
1812
2179
|
COMPOUND_MAX_NESTING_DEPTH,
|
|
1813
2180
|
COMPOUND_RESUME_SCHEMA_VERSION,
|
|
1814
2181
|
ID_MAX_LENGTH,
|
|
@@ -1858,11 +2225,14 @@ function defineLifecyclePlugin(plugin) {
|
|
|
1858
2225
|
hasCourseStarted,
|
|
1859
2226
|
hasCourseStartedEmittedToTracking,
|
|
1860
2227
|
hasCourseStartedPipelineDelivered,
|
|
2228
|
+
hasCourseStartedXapiSent,
|
|
1861
2229
|
isChildTypeAllowed,
|
|
2230
|
+
isLifecycleTelemetryEvent,
|
|
1862
2231
|
loadCompoundState,
|
|
1863
2232
|
markCourseStarted,
|
|
1864
2233
|
markCourseStartedEmittedToTracking,
|
|
1865
2234
|
markCourseStartedPipelineDelivered,
|
|
2235
|
+
markCourseStartedXapiSent,
|
|
1866
2236
|
migrateCourseStartedMark,
|
|
1867
2237
|
nowIso,
|
|
1868
2238
|
parseBlockId,
|
|
@@ -1881,5 +2251,6 @@ function defineLifecyclePlugin(plugin) {
|
|
|
1881
2251
|
telemetryCatalogVersion,
|
|
1882
2252
|
tryBuildTelemetryEvent,
|
|
1883
2253
|
tryEmitCourseStarted,
|
|
2254
|
+
validateBranchGraph,
|
|
1884
2255
|
validateId
|
|
1885
2256
|
});
|