@lessonkit/core 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 CHANGED
@@ -63,7 +63,7 @@ Machine-readable: `@lessonkit/core/telemetry-catalog.v3.json` (current; v1–v3
63
63
 
64
64
  ## Docs
65
65
 
66
- [Core reference](https://lessonkit.readthedocs.io/en/latest/reference/core.html) · [Identity](https://lessonkit.readthedocs.io/en/latest/reference/identity.html) · [Telemetry](https://lessonkit.readthedocs.io/en/latest/reference/telemetry.html) · [Plugins](https://lessonkit.readthedocs.io/en/latest/reference/plugins.html) · [TypeDoc API index](https://lessonkit.readthedocs.io/en/latest/reference/api.html)
66
+ [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) · [Core reference](https://lessonkit.readthedocs.io/en/latest/reference/core.html) · [TypeDoc API index](https://lessonkit.readthedocs.io/en/latest/reference/api.html)
67
67
 
68
68
  ## License
69
69
 
@@ -60,7 +60,8 @@ function randomSessionIdFallback() {
60
60
  if (g.crypto?.getRandomValues) {
61
61
  const bytes = new Uint8Array(16);
62
62
  g.crypto.getRandomValues(bytes);
63
- return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
63
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
64
+ return `s-${hex}`;
64
65
  }
65
66
  throw new Error(
66
67
  "[lessonkit] createSessionId requires crypto.randomUUID or crypto.getRandomValues"
@@ -68,7 +69,9 @@ function randomSessionIdFallback() {
68
69
  }
69
70
  function createSessionId() {
70
71
  const g = globalThis;
71
- if (g.crypto?.randomUUID) return g.crypto.randomUUID();
72
+ if (g.crypto?.randomUUID) {
73
+ return `s-${g.crypto.randomUUID().replace(/-/g, "")}`;
74
+ }
72
75
  return randomSessionIdFallback();
73
76
  }
74
77
 
@@ -386,6 +389,90 @@ var TELEMETRY_EVENT_REGISTRY = {
386
389
  data: opts.data
387
390
  };
388
391
  }
392
+ },
393
+ image_juxtaposition_changed: {
394
+ build: (opts, base) => ({
395
+ name: "image_juxtaposition_changed",
396
+ ...base,
397
+ lessonId: opts.lessonId,
398
+ data: opts.data
399
+ })
400
+ },
401
+ timeline_event_viewed: {
402
+ build: (opts, base) => ({
403
+ name: "timeline_event_viewed",
404
+ ...base,
405
+ lessonId: opts.lessonId,
406
+ data: opts.data
407
+ })
408
+ },
409
+ image_sequence_changed: {
410
+ build: (opts, base) => ({
411
+ name: "image_sequence_changed",
412
+ ...base,
413
+ lessonId: opts.lessonId,
414
+ data: opts.data
415
+ })
416
+ },
417
+ audio_recording_started: {
418
+ build: (opts, base) => ({
419
+ name: "audio_recording_started",
420
+ ...base,
421
+ lessonId: opts.lessonId,
422
+ data: opts.data
423
+ })
424
+ },
425
+ audio_recording_completed: {
426
+ build: (opts, base) => ({
427
+ name: "audio_recording_completed",
428
+ ...base,
429
+ lessonId: opts.lessonId,
430
+ data: opts.data
431
+ })
432
+ },
433
+ qr_content_revealed: {
434
+ build: (opts, base) => ({
435
+ name: "qr_content_revealed",
436
+ ...base,
437
+ lessonId: opts.lessonId,
438
+ data: opts.data
439
+ })
440
+ },
441
+ advent_door_opened: {
442
+ build: (opts, base) => ({
443
+ name: "advent_door_opened",
444
+ ...base,
445
+ lessonId: opts.lessonId,
446
+ data: opts.data
447
+ })
448
+ },
449
+ map_stage_viewed: {
450
+ requiresLessonId: true,
451
+ build: (opts, base) => {
452
+ if (opts.name !== "map_stage_viewed") throw new Error("unexpected event");
453
+ const lessonId = opts.lessonId;
454
+ if (!lessonId) throw new Error("map_stage_viewed requires active lessonId");
455
+ return {
456
+ name: "map_stage_viewed",
457
+ ...base,
458
+ lessonId,
459
+ data: opts.data
460
+ };
461
+ }
462
+ },
463
+ map_exit_selected: {
464
+ requiresLessonId: true,
465
+ build: (opts, base) => {
466
+ if (opts.name !== "map_exit_selected") throw new Error("unexpected event");
467
+ const lessonId = opts.lessonId;
468
+ if (!lessonId) throw new Error("map_exit_selected requires active lessonId");
469
+ return {
470
+ name: "map_exit_selected",
471
+ ...base,
472
+ lessonId,
473
+ data: opts.data
474
+ };
475
+ }
389
476
  }
390
477
  };
391
478
  function buildTelemetryEventFromRegistry(opts) {
@@ -494,26 +581,35 @@ function createMemoryBackedSessionStorage(session) {
494
581
  );
495
582
  }
496
583
  };
584
+ const bypassCacheForKey = (key) => key === "lessonkit:sessionId" || key.startsWith("lessonkit:course_started");
497
585
  return {
498
586
  getItem: (key) => {
499
587
  if (tombstones.has(key)) return null;
500
- if (memory.has(key)) return memory.get(key);
588
+ if (!bypassCacheForKey(key) && memory.has(key)) return memory.get(key);
501
589
  try {
502
590
  const value = session.getItem(key);
503
- if (value !== null) memory.set(key, value);
504
- return value;
591
+ if (value !== null) {
592
+ memory.set(key, value);
593
+ return value;
594
+ }
595
+ if (bypassCacheForKey(key) && memory.has(key)) {
596
+ return memory.get(key);
597
+ }
598
+ if (bypassCacheForKey(key)) memory.delete(key);
599
+ return null;
505
600
  } catch {
506
601
  return memory.get(key) ?? null;
507
602
  }
508
603
  },
509
604
  setItem: (key, value) => {
510
605
  tombstones.delete(key);
511
- memory.set(key, value);
512
606
  try {
513
607
  session.setItem(key, value);
608
+ memory.set(key, value);
514
609
  return true;
515
610
  } catch {
516
611
  warnPersistFailure();
612
+ memory.set(key, value);
517
613
  return false;
518
614
  }
519
615
  },
@@ -579,6 +675,29 @@ function createGlobalTimer() {
579
675
  // src/session.ts
580
676
  var SESSION_STORAGE_KEY = "lessonkit:sessionId";
581
677
  var volatileSessionIds = /* @__PURE__ */ new WeakMap();
678
+ var volatileStorageMarks = /* @__PURE__ */ new WeakMap();
679
+ function rememberVolatileMark(storage, key) {
680
+ let keys = volatileStorageMarks.get(storage);
681
+ if (!keys) {
682
+ keys = /* @__PURE__ */ new Set();
683
+ volatileStorageMarks.set(storage, keys);
684
+ }
685
+ keys.add(key);
686
+ }
687
+ function hasVolatileMark(storage, key) {
688
+ return volatileStorageMarks.get(storage)?.has(key) ?? false;
689
+ }
690
+ function clearVolatileMark(storage, key) {
691
+ volatileStorageMarks.get(storage)?.delete(key);
692
+ }
693
+ function storageHasMark(storage, key) {
694
+ return storage.getItem(key) === "1" || hasVolatileMark(storage, key);
695
+ }
696
+ function storageSetMark(storage, key) {
697
+ const persisted = storage.setItem(key, "1");
698
+ if (!persisted) rememberVolatileMark(storage, key);
699
+ return persisted;
700
+ }
582
701
  function isDevEnvironment2() {
583
702
  const g = globalThis;
584
703
  return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
@@ -594,21 +713,7 @@ function sessionKeySegment(sessionId) {
594
713
  const validated = validateId(sessionId);
595
714
  return validated.ok ? validated.id : encodeURIComponent(sessionId);
596
715
  }
597
- function resolveSessionId(storage, provided) {
598
- if (provided !== void 0) {
599
- const trimmed = provided.trim();
600
- if (trimmed.length > 0) {
601
- const validated = validateId(trimmed);
602
- if (validated.ok) return validated.id;
603
- if (isDevEnvironment2()) {
604
- console.warn(
605
- `[lessonkit] Invalid sessionId "${trimmed}"; falling back to tab or generated id.`
606
- );
607
- }
608
- }
609
- }
610
- const existing = storage.getItem(SESSION_STORAGE_KEY);
611
- if (existing) return existing;
716
+ function resolveGeneratedSessionId(storage) {
612
717
  const volatile = volatileSessionIds.get(storage);
613
718
  if (volatile) return volatile;
614
719
  const id = createSessionId();
@@ -624,6 +729,50 @@ function resolveSessionId(storage, provided) {
624
729
  }
625
730
  return id;
626
731
  }
732
+ function resolveFallbackSessionId(storage, options) {
733
+ const existing = storage.getItem(SESSION_STORAGE_KEY);
734
+ if (existing) {
735
+ const trimmedExisting = existing.trim();
736
+ const validatedExisting = validateId(trimmedExisting);
737
+ if (validatedExisting.ok) return validatedExisting.id;
738
+ storage.removeItem?.(SESSION_STORAGE_KEY);
739
+ if (isDevEnvironment2()) {
740
+ console.warn(
741
+ `[lessonkit] Invalid stored sessionId "${existing}"; generating a new id.`
742
+ );
743
+ }
744
+ const fallback = resolveGeneratedSessionId(storage);
745
+ options?.onInvalidSessionId?.({
746
+ invalidId: existing,
747
+ fallbackId: fallback,
748
+ source: "stored"
749
+ });
750
+ return fallback;
751
+ }
752
+ return resolveGeneratedSessionId(storage);
753
+ }
754
+ function resolveSessionId(storage, provided, options) {
755
+ if (provided !== void 0) {
756
+ const trimmed = provided.trim();
757
+ if (trimmed.length > 0) {
758
+ const validated = validateId(trimmed);
759
+ if (validated.ok) return validated.id;
760
+ if (isDevEnvironment2()) {
761
+ console.warn(
762
+ `[lessonkit] Invalid sessionId "${trimmed}"; falling back to tab or generated id.`
763
+ );
764
+ }
765
+ const fallback = resolveFallbackSessionId(storage, options);
766
+ options?.onInvalidSessionId?.({
767
+ invalidId: trimmed,
768
+ fallbackId: fallback,
769
+ source: "provided"
770
+ });
771
+ return fallback;
772
+ }
773
+ }
774
+ return resolveFallbackSessionId(storage, options);
775
+ }
627
776
  function courseStartedStorageKey(sessionId, courseId) {
628
777
  return `${COURSE_STARTED_PREFIX}${sessionKeySegment(sessionId)}:${courseId ?? ""}`;
629
778
  }
@@ -638,35 +787,35 @@ function courseStartedXapiStorageKey(sessionId, courseId) {
638
787
  }
639
788
  function hasCourseStarted(storage, sessionId, courseId) {
640
789
  if (!courseId) return false;
641
- return storage.getItem(courseStartedStorageKey(sessionId, courseId)) === "1";
790
+ return storageHasMark(storage, courseStartedStorageKey(sessionId, courseId));
642
791
  }
643
792
  function markCourseStarted(storage, sessionId, courseId) {
644
793
  if (!courseId) return false;
645
- return storage.setItem(courseStartedStorageKey(sessionId, courseId), "1");
794
+ return storageSetMark(storage, courseStartedStorageKey(sessionId, courseId));
646
795
  }
647
796
  function hasCourseStartedEmittedToTracking(storage, sessionId, courseId) {
648
797
  if (!courseId) return false;
649
- return storage.getItem(courseStartedTrackingStorageKey(sessionId, courseId)) === "1";
798
+ return storageHasMark(storage, courseStartedTrackingStorageKey(sessionId, courseId));
650
799
  }
651
800
  function markCourseStartedEmittedToTracking(storage, sessionId, courseId) {
652
801
  if (!courseId) return false;
653
- return storage.setItem(courseStartedTrackingStorageKey(sessionId, courseId), "1");
802
+ return storageSetMark(storage, courseStartedTrackingStorageKey(sessionId, courseId));
654
803
  }
655
804
  function hasCourseStartedPipelineDelivered(storage, sessionId, courseId) {
656
805
  if (!courseId) return false;
657
- return storage.getItem(courseStartedPipelineStorageKey(sessionId, courseId)) === "1";
806
+ return storageHasMark(storage, courseStartedPipelineStorageKey(sessionId, courseId));
658
807
  }
659
808
  function markCourseStartedPipelineDelivered(storage, sessionId, courseId) {
660
809
  if (!courseId) return false;
661
- return storage.setItem(courseStartedPipelineStorageKey(sessionId, courseId), "1");
810
+ return storageSetMark(storage, courseStartedPipelineStorageKey(sessionId, courseId));
662
811
  }
663
812
  function hasCourseStartedXapiSent(storage, sessionId, courseId) {
664
813
  if (!courseId) return false;
665
- return storage.getItem(courseStartedXapiStorageKey(sessionId, courseId)) === "1";
814
+ return storageHasMark(storage, courseStartedXapiStorageKey(sessionId, courseId));
666
815
  }
667
816
  function markCourseStartedXapiSent(storage, sessionId, courseId) {
668
817
  if (!courseId) return false;
669
- return storage.setItem(courseStartedXapiStorageKey(sessionId, courseId), "1");
818
+ return storageSetMark(storage, courseStartedXapiStorageKey(sessionId, courseId));
670
819
  }
671
820
  function resetSharedVolatileSessionIdForTests() {
672
821
  }
@@ -674,6 +823,7 @@ function migrateStorageMark(storage, fromKey, toKey, hasMark) {
674
823
  if (!hasMark) return;
675
824
  if (storage.setItem(toKey, "1")) {
676
825
  storage.removeItem?.(fromKey);
826
+ clearVolatileMark(storage, fromKey);
677
827
  }
678
828
  }
679
829
  function migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId) {
@@ -706,14 +856,32 @@ function migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId)
706
856
 
707
857
  // src/runtime/courseLifecycle.ts
708
858
  var courseStartedEmitFlights = /* @__PURE__ */ new Map();
859
+ var courseStartedEmittedInTab = /* @__PURE__ */ new Set();
709
860
  function resetCourseStartedEmitFlightForTests() {
710
861
  courseStartedEmitFlights.clear();
862
+ courseStartedEmittedInTab.clear();
711
863
  }
712
864
  function tryEmitCourseStarted(ctx, deps, alreadyEmittedToSink) {
713
865
  const flightKey = `${ctx.sessionId}:${ctx.courseId}`;
714
866
  const marked = hasCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
867
+ if (courseStartedEmittedInTab.has(flightKey)) {
868
+ return Promise.resolve({
869
+ emitted: true,
870
+ marked: marked || markCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId)
871
+ });
872
+ }
715
873
  if (alreadyEmittedToSink) {
716
- return Promise.resolve({ emitted: true, marked });
874
+ const markPersisted = marked ? true : markCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
875
+ return Promise.resolve({
876
+ emitted: true,
877
+ marked: markPersisted
878
+ });
879
+ }
880
+ if (marked && hasCourseStartedEmittedToTracking(ctx.storage, ctx.sessionId, ctx.courseId)) {
881
+ return Promise.resolve({
882
+ emitted: true,
883
+ marked: true
884
+ });
717
885
  }
718
886
  const existing = courseStartedEmitFlights.get(flightKey);
719
887
  if (existing) {
@@ -722,15 +890,19 @@ function tryEmitCourseStarted(ctx, deps, alreadyEmittedToSink) {
722
890
  const flight = Promise.resolve().then(() => {
723
891
  try {
724
892
  const emitted = deps.emitCourseStartedEvent(ctx);
725
- if (emitted && !marked) {
726
- markCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
893
+ if (emitted) {
894
+ courseStartedEmittedInTab.add(flightKey);
727
895
  }
896
+ const markPersisted = emitted && !marked ? markCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId) : marked;
728
897
  return {
729
898
  emitted,
730
- marked: hasCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId)
899
+ marked: markPersisted
731
900
  };
732
901
  } catch {
733
- return { emitted: false, marked };
902
+ return {
903
+ emitted: false,
904
+ marked: hasCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId)
905
+ };
734
906
  } finally {
735
907
  if (courseStartedEmitFlights.get(flightKey) === flight) {
736
908
  courseStartedEmitFlights.delete(flightKey);
@@ -766,7 +938,16 @@ function completeCourseWithTelemetry(opts) {
766
938
  });
767
939
  }
768
940
  const result = opts.progress.completeCourse();
769
- if (!result.didComplete) return false;
941
+ if (!result.didComplete) {
942
+ const after = opts.progress.getState();
943
+ if (after.activeLessonId) {
944
+ const lessonResult = opts.progress.completeLesson(after.activeLessonId, opts.nowMs);
945
+ if (lessonResult.didComplete) {
946
+ opts.emitLessonCompleted(after.activeLessonId, lessonResult.durationMs);
947
+ }
948
+ }
949
+ return false;
950
+ }
770
951
  opts.emitCourseCompleted();
771
952
  return true;
772
953
  }