@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/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 validated2 = validateId(candidate);
176
- if (validated2.ok && !usedIds.has(validated2.id)) return validated2.id;
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 validated = validateId(timed);
180
- return validated.ok ? validated.id : `id-${hash}`;
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 && isDevEnvironment()) {
310
- console.warn(`[lessonkit] Ignoring corrupt compound resume state at ${key}`);
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 ${buffer.length} buffered telemetry event(s) after dispose flush cap`
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
- track(event);
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 fromBuffer = buffer.splice(0, buffer.length);
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.catch(() => {
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 `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 11)}`;
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 && entry.tryBuildMissingLessonWarning) {
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
- function invokeSink(sink, event, emitCtx) {
1208
- invokePipelineSink(sink.id, () => sink.emit(event, emitCtx));
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: () => true
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) return trimmed;
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
- if (!sharedVolatileSessionId) {
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; reusing in-memory id for this tab."
1713
+ "[lessonkit] session id could not be persisted; using in-memory id for this storage."
1413
1714
  );
1414
1715
  }
1415
- return sharedVolatileSessionId;
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
- sharedVolatileSessionId = null;
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
- if (hasCourseStarted(storage, fromSessionId, courseId)) {
1458
- markCourseStarted(storage, toSessionId, courseId);
1459
- storage.removeItem?.(courseStartedStorageKey(fromSessionId, courseId));
1460
- }
1461
- if (hasCourseStartedEmittedToTracking(storage, fromSessionId, courseId)) {
1462
- markCourseStartedEmittedToTracking(storage, toSessionId, courseId);
1463
- storage.removeItem?.(courseStartedTrackingStorageKey(fromSessionId, courseId));
1464
- }
1465
- if (hasCourseStartedPipelineDelivered(storage, fromSessionId, courseId)) {
1466
- markCourseStartedPipelineDelivered(storage, toSessionId, courseId);
1467
- storage.removeItem?.(courseStartedPipelineStorageKey(fromSessionId, courseId));
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 Set();
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
- if (courseStartedEmitFlights.has(flightKey)) {
1480
- return { emitted: false, marked };
1808
+ const existing = courseStartedEmitFlights.get(flightKey);
1809
+ if (existing) {
1810
+ return existing;
1481
1811
  }
1482
- courseStartedEmitFlights.add(flightKey);
1483
- try {
1484
- const emitted = deps.emitCourseStartedEvent(ctx);
1485
- if (emitted && !marked) {
1486
- markCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
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
- return {
1489
- emitted,
1490
- marked: hasCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId)
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?.id ?? ""}`;
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 wrapEmitFn = (emitFn) => {
1683
- return (name, data, lessonId) => {
1684
- const event = buildAndApply(name, data, lessonId);
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
- const wrapped = wrapEmitFn(emitFn);
1699
- wrapped("lesson_completed", { lessonId, durationMs }, lessonId);
2046
+ emitLifecycleEvent(emitFn, "lesson_completed", { lessonId, durationMs }, lessonId);
1700
2047
  if (durationMs !== void 0) {
1701
- wrapped("lesson_time_on_task", { lessonId, durationMs }, lessonId);
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
- const wrapped = wrapEmitFn(emitFn);
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, wrapped);
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
- wrapped("lesson_started", { lessonId }, lessonId);
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, wrapEmitFn(emitFn))
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, wrapEmitFn(emitFn)),
1775
- emitCourseCompleted: () => wrapEmitFn(emitFn)("course_completed")
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
  });