@lessonkit/core 1.4.0 → 1.6.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
@@ -6,12 +6,22 @@
6
6
 
7
7
  Headless types, identity helpers, telemetry pipeline, and runtime primitives shared across LessonKit.
8
8
 
9
+ ## When to install
10
+
11
+ - Custom headless runtime (no React UI)
12
+ - Telemetry plugins, batch pipelines, or custom tracking clients
13
+ - Validating IDs, URNs, and manifest fields in your own tooling
14
+
15
+ Most course authors only need `@lessonkit/react`, which re-exports common APIs.
16
+
9
17
  ## Install
10
18
 
11
19
  ```bash
12
20
  npm install @lessonkit/core
13
21
  ```
14
22
 
23
+ Requires Node.js **18+** minimum.
24
+
15
25
  ## Usage
16
26
 
17
27
  ```typescript
@@ -21,7 +31,16 @@ import {
21
31
  createTelemetryPipeline,
22
32
  createPluginRegistry,
23
33
  buildLessonkitUrn,
34
+ validateId,
24
35
  } from "@lessonkit/core";
36
+
37
+ const event = buildTelemetryEvent({
38
+ name: "quiz_answered",
39
+ courseId: "my-course",
40
+ lessonId: "lesson-1",
41
+ checkId: "check-1",
42
+ data: { correct: true, score: 1 },
43
+ });
25
44
  ```
26
45
 
27
46
  ## Exports
@@ -33,11 +52,18 @@ import {
33
52
  | Runtime | `createLessonkitRuntime`, progress and session helpers |
34
53
  | Plugins | `createPluginRegistry`, `defineTelemetryPlugin`, `defineAssessmentPlugin` |
35
54
 
36
- Machine-readable: `@lessonkit/core/telemetry-catalog.v1.json`, `identity-contract.v1.json`
55
+ Machine-readable: `@lessonkit/core/telemetry-catalog.v3.json` (current; v1–v3 retained), `identity-contract.v1.json`
56
+
57
+ ## Common issues
58
+
59
+ | Symptom | Fix |
60
+ | --- | --- |
61
+ | `buildTelemetryEvent` validation error | Ensure `courseId`, `lessonId`, and event-specific IDs match [identity rules](https://lessonkit.readthedocs.io/en/latest/reference/identity.html) |
62
+ | Plugin not firing | Register with `createPluginRegistry` and pass plugins in `LessonkitProvider` config |
37
63
 
38
64
  ## Docs
39
65
 
40
- [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)
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)
41
67
 
42
68
  ## License
43
69
 
@@ -1,8 +1,78 @@
1
+ // src/identityTypes.ts
2
+ var ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
3
+ var ID_MAX_LENGTH = 64;
4
+
5
+ // src/validateId.ts
6
+ function validateId(input, path = "id") {
7
+ if (typeof input !== "string") {
8
+ return { ok: false, issues: [{ path, message: "id must be a string" }] };
9
+ }
10
+ const id = input.trim();
11
+ if (!id.length) {
12
+ return { ok: false, issues: [{ path, message: "id must not be empty" }] };
13
+ }
14
+ if (id.length > ID_MAX_LENGTH) {
15
+ return {
16
+ ok: false,
17
+ issues: [{ path, message: `id must be at most ${ID_MAX_LENGTH} characters` }]
18
+ };
19
+ }
20
+ if (!ID_PATTERN.test(id)) {
21
+ return {
22
+ ok: false,
23
+ issues: [
24
+ {
25
+ path,
26
+ message: "id must start with a letter and contain only letters, digits, underscores, and hyphens"
27
+ }
28
+ ]
29
+ };
30
+ }
31
+ return { ok: true, id };
32
+ }
33
+ function parseCourseId(input) {
34
+ const result = validateId(input, "courseId");
35
+ return result.ok ? result.id : null;
36
+ }
37
+ function parseLessonId(input) {
38
+ const result = validateId(input, "lessonId");
39
+ return result.ok ? result.id : null;
40
+ }
41
+ function parseCheckId(input) {
42
+ const result = validateId(input, "checkId");
43
+ return result.ok ? result.id : null;
44
+ }
45
+ function parseBlockId(input) {
46
+ const result = validateId(input, "blockId");
47
+ return result.ok ? result.id : null;
48
+ }
49
+ function assertValidId(input, path = "id") {
50
+ const result = validateId(input, path);
51
+ if (!result.ok) {
52
+ throw new Error(result.issues.map((i) => `${i.path}: ${i.message}`).join("; "));
53
+ }
54
+ return result.id;
55
+ }
56
+
1
57
  // src/ids.ts
58
+ function randomSessionIdFallback() {
59
+ const g = globalThis;
60
+ if (g.crypto?.getRandomValues) {
61
+ const bytes = new Uint8Array(16);
62
+ g.crypto.getRandomValues(bytes);
63
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
64
+ return `s-${hex}`;
65
+ }
66
+ throw new Error(
67
+ "[lessonkit] createSessionId requires crypto.randomUUID or crypto.getRandomValues"
68
+ );
69
+ }
2
70
  function createSessionId() {
3
71
  const g = globalThis;
4
- if (g.crypto?.randomUUID) return g.crypto.randomUUID();
5
- return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 11)}`;
72
+ if (g.crypto?.randomUUID) {
73
+ return `s-${g.crypto.randomUUID().replace(/-/g, "")}`;
74
+ }
75
+ return randomSessionIdFallback();
6
76
  }
7
77
 
8
78
  // src/time.ts
@@ -291,6 +361,118 @@ var TELEMETRY_EVENT_REGISTRY = {
291
361
  data: opts.data
292
362
  };
293
363
  }
364
+ },
365
+ branch_node_viewed: {
366
+ requiresLessonId: true,
367
+ build: (opts, base) => {
368
+ if (opts.name !== "branch_node_viewed") throw new Error("unexpected event");
369
+ const lessonId = opts.lessonId;
370
+ if (!lessonId) throw new Error("branch_node_viewed requires active lessonId");
371
+ return {
372
+ name: "branch_node_viewed",
373
+ ...base,
374
+ lessonId,
375
+ data: opts.data
376
+ };
377
+ }
378
+ },
379
+ branch_selected: {
380
+ requiresLessonId: true,
381
+ build: (opts, base) => {
382
+ if (opts.name !== "branch_selected") throw new Error("unexpected event");
383
+ const lessonId = opts.lessonId;
384
+ if (!lessonId) throw new Error("branch_selected requires active lessonId");
385
+ return {
386
+ name: "branch_selected",
387
+ ...base,
388
+ lessonId,
389
+ data: opts.data
390
+ };
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
+ }
294
476
  }
295
477
  };
296
478
  function buildTelemetryEventFromRegistry(opts) {
@@ -323,8 +505,8 @@ function buildTelemetryEvent(opts) {
323
505
  }
324
506
  function tryBuildTelemetryEvent(opts) {
325
507
  const entry = getTelemetryEventRegistryEntry(opts.name);
326
- if (entry.requiresLessonId && !opts.lessonId && entry.tryBuildMissingLessonWarning) {
327
- if (isDevEnvironment()) {
508
+ if (entry.requiresLessonId && !opts.lessonId) {
509
+ if (isDevEnvironment() && entry.tryBuildMissingLessonWarning) {
328
510
  if (entry.tryBuildMissingLessonWarning === "quiz" && !warnedMissingQuizLesson) {
329
511
  warnedMissingQuizLesson = true;
330
512
  console.warn(
@@ -351,14 +533,44 @@ function createDefaultClock() {
351
533
  };
352
534
  }
353
535
  function createNoopStorage() {
536
+ const memory = /* @__PURE__ */ new Map();
354
537
  return {
355
- getItem: () => null,
356
- setItem: () => true
538
+ getItem: (key) => memory.get(key) ?? null,
539
+ setItem: (key, value) => {
540
+ memory.set(key, value);
541
+ return true;
542
+ },
543
+ removeItem: (key) => {
544
+ memory.delete(key);
545
+ },
546
+ resetForTests: () => {
547
+ memory.clear();
548
+ }
357
549
  };
358
550
  }
359
551
  function createMemoryBackedSessionStorage(session) {
360
552
  const memory = /* @__PURE__ */ new Map();
553
+ const tombstones = /* @__PURE__ */ new Set();
361
554
  let warnedPersistFailure = false;
555
+ const syncFromStorageEvent = (key, newValue) => {
556
+ if (key === null) {
557
+ memory.clear();
558
+ tombstones.clear();
559
+ return;
560
+ }
561
+ tombstones.delete(key);
562
+ if (newValue === null) {
563
+ memory.delete(key);
564
+ } else {
565
+ memory.set(key, newValue);
566
+ }
567
+ };
568
+ if (typeof window !== "undefined") {
569
+ window.addEventListener("storage", (event) => {
570
+ if (event.storageArea !== sessionStorage) return;
571
+ syncFromStorageEvent(event.key, event.newValue);
572
+ });
573
+ }
362
574
  const warnPersistFailure = () => {
363
575
  if (warnedPersistFailure) return;
364
576
  warnedPersistFailure = true;
@@ -369,24 +581,31 @@ function createMemoryBackedSessionStorage(session) {
369
581
  );
370
582
  }
371
583
  };
584
+ const bypassCacheForKey = (key) => key === "lessonkit:sessionId" || key.startsWith("lessonkit:course_started");
372
585
  return {
373
586
  getItem: (key) => {
374
- if (memory.has(key)) return memory.get(key);
587
+ if (tombstones.has(key)) return null;
588
+ if (!bypassCacheForKey(key) && memory.has(key)) return memory.get(key);
375
589
  try {
376
590
  const value = session.getItem(key);
377
591
  if (value !== null) memory.set(key, value);
592
+ else if (bypassCacheForKey(key)) memory.delete(key);
378
593
  return value;
379
594
  } catch {
380
595
  return memory.get(key) ?? null;
381
596
  }
382
597
  },
383
598
  setItem: (key, value) => {
384
- memory.set(key, value);
599
+ tombstones.delete(key);
385
600
  try {
386
601
  session.setItem(key, value);
602
+ memory.set(key, value);
387
603
  return true;
388
604
  } catch {
389
605
  warnPersistFailure();
606
+ if (!bypassCacheForKey(key)) {
607
+ memory.set(key, value);
608
+ }
390
609
  return false;
391
610
  }
392
611
  },
@@ -394,12 +613,15 @@ function createMemoryBackedSessionStorage(session) {
394
613
  memory.delete(key);
395
614
  try {
396
615
  session.removeItem(key);
616
+ tombstones.delete(key);
397
617
  } catch {
398
618
  warnPersistFailure();
619
+ tombstones.add(key);
399
620
  }
400
621
  },
401
622
  resetForTests: () => {
402
623
  memory.clear();
624
+ tombstones.clear();
403
625
  }
404
626
  };
405
627
  }
@@ -449,7 +671,6 @@ function createGlobalTimer() {
449
671
  // src/session.ts
450
672
  var SESSION_STORAGE_KEY = "lessonkit:sessionId";
451
673
  var volatileSessionIds = /* @__PURE__ */ new WeakMap();
452
- var sharedVolatileSessionId = null;
453
674
  function isDevEnvironment2() {
454
675
  const g = globalThis;
455
676
  return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
@@ -460,39 +681,62 @@ function getTabSessionId(storage) {
460
681
  var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
461
682
  var COURSE_STARTED_TRACKING_PREFIX = "lessonkit:course_started_tracking:";
462
683
  var COURSE_STARTED_PIPELINE_PREFIX = "lessonkit:course_started_pipeline:";
684
+ var COURSE_STARTED_XAPI_PREFIX = "lessonkit:course_started_xapi:";
685
+ function sessionKeySegment(sessionId) {
686
+ const validated = validateId(sessionId);
687
+ return validated.ok ? validated.id : encodeURIComponent(sessionId);
688
+ }
463
689
  function resolveSessionId(storage, provided) {
464
690
  if (provided !== void 0) {
465
691
  const trimmed = provided.trim();
466
- if (trimmed.length > 0) return trimmed;
692
+ if (trimmed.length > 0) {
693
+ const validated = validateId(trimmed);
694
+ if (validated.ok) return validated.id;
695
+ if (isDevEnvironment2()) {
696
+ console.warn(
697
+ `[lessonkit] Invalid sessionId "${trimmed}"; falling back to tab or generated id.`
698
+ );
699
+ }
700
+ }
467
701
  }
468
702
  const existing = storage.getItem(SESSION_STORAGE_KEY);
469
- if (existing) return existing;
703
+ if (existing) {
704
+ const trimmedExisting = existing.trim();
705
+ const validatedExisting = validateId(trimmedExisting);
706
+ if (validatedExisting.ok) return validatedExisting.id;
707
+ storage.removeItem?.(SESSION_STORAGE_KEY);
708
+ if (isDevEnvironment2()) {
709
+ console.warn(
710
+ `[lessonkit] Invalid stored sessionId "${existing}"; generating a new id.`
711
+ );
712
+ }
713
+ }
470
714
  const volatile = volatileSessionIds.get(storage);
471
715
  if (volatile) return volatile;
472
716
  const id = createSessionId();
473
717
  const persisted = storage.setItem(SESSION_STORAGE_KEY, id);
474
718
  if (!persisted) {
475
- if (!sharedVolatileSessionId) {
476
- sharedVolatileSessionId = id;
477
- }
478
- volatileSessionIds.set(storage, sharedVolatileSessionId);
719
+ volatileSessionIds.set(storage, id);
479
720
  if (isDevEnvironment2()) {
480
721
  console.warn(
481
- "[lessonkit] session id could not be persisted; reusing in-memory id for this tab."
722
+ "[lessonkit] session id could not be persisted; using in-memory id for this storage."
482
723
  );
483
724
  }
484
- return sharedVolatileSessionId;
725
+ return id;
485
726
  }
486
727
  return id;
487
728
  }
488
729
  function courseStartedStorageKey(sessionId, courseId) {
489
- return `${COURSE_STARTED_PREFIX}${sessionId}:${courseId ?? ""}`;
730
+ return `${COURSE_STARTED_PREFIX}${sessionKeySegment(sessionId)}:${courseId ?? ""}`;
490
731
  }
491
732
  function courseStartedTrackingStorageKey(sessionId, courseId) {
492
- return `${COURSE_STARTED_TRACKING_PREFIX}${sessionId}:${courseId ?? ""}`;
733
+ return `${COURSE_STARTED_TRACKING_PREFIX}${sessionKeySegment(sessionId)}:${courseId ?? ""}`;
493
734
  }
494
735
  function courseStartedPipelineStorageKey(sessionId, courseId) {
495
- return `${COURSE_STARTED_PIPELINE_PREFIX}${sessionId}:${courseId ?? ""}`;
736
+ return `${COURSE_STARTED_PIPELINE_PREFIX}${sessionKeySegment(sessionId)}:${courseId ?? ""}`;
737
+ }
738
+ function courseStartedXapiStorageKey(sessionId, courseId) {
739
+ return `${COURSE_STARTED_XAPI_PREFIX}${sessionKeySegment(sessionId)}:${courseId ?? ""}`;
496
740
  }
497
741
  function hasCourseStarted(storage, sessionId, courseId) {
498
742
  if (!courseId) return false;
@@ -518,27 +762,52 @@ function markCourseStartedPipelineDelivered(storage, sessionId, courseId) {
518
762
  if (!courseId) return false;
519
763
  return storage.setItem(courseStartedPipelineStorageKey(sessionId, courseId), "1");
520
764
  }
765
+ function hasCourseStartedXapiSent(storage, sessionId, courseId) {
766
+ if (!courseId) return false;
767
+ return storage.getItem(courseStartedXapiStorageKey(sessionId, courseId)) === "1";
768
+ }
769
+ function markCourseStartedXapiSent(storage, sessionId, courseId) {
770
+ if (!courseId) return false;
771
+ return storage.setItem(courseStartedXapiStorageKey(sessionId, courseId), "1");
772
+ }
521
773
  function resetSharedVolatileSessionIdForTests() {
522
- sharedVolatileSessionId = null;
774
+ }
775
+ function migrateStorageMark(storage, fromKey, toKey, hasMark) {
776
+ if (!hasMark) return;
777
+ if (storage.setItem(toKey, "1")) {
778
+ storage.removeItem?.(fromKey);
779
+ }
523
780
  }
524
781
  function migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId) {
525
782
  if (!courseId || fromSessionId === toSessionId) return;
526
- if (hasCourseStarted(storage, fromSessionId, courseId)) {
527
- markCourseStarted(storage, toSessionId, courseId);
528
- storage.removeItem?.(courseStartedStorageKey(fromSessionId, courseId));
529
- }
530
- if (hasCourseStartedEmittedToTracking(storage, fromSessionId, courseId)) {
531
- markCourseStartedEmittedToTracking(storage, toSessionId, courseId);
532
- storage.removeItem?.(courseStartedTrackingStorageKey(fromSessionId, courseId));
533
- }
534
- if (hasCourseStartedPipelineDelivered(storage, fromSessionId, courseId)) {
535
- markCourseStartedPipelineDelivered(storage, toSessionId, courseId);
536
- storage.removeItem?.(courseStartedPipelineStorageKey(fromSessionId, courseId));
537
- }
783
+ migrateStorageMark(
784
+ storage,
785
+ courseStartedStorageKey(fromSessionId, courseId),
786
+ courseStartedStorageKey(toSessionId, courseId),
787
+ hasCourseStarted(storage, fromSessionId, courseId)
788
+ );
789
+ migrateStorageMark(
790
+ storage,
791
+ courseStartedTrackingStorageKey(fromSessionId, courseId),
792
+ courseStartedTrackingStorageKey(toSessionId, courseId),
793
+ hasCourseStartedEmittedToTracking(storage, fromSessionId, courseId)
794
+ );
795
+ migrateStorageMark(
796
+ storage,
797
+ courseStartedPipelineStorageKey(fromSessionId, courseId),
798
+ courseStartedPipelineStorageKey(toSessionId, courseId),
799
+ hasCourseStartedPipelineDelivered(storage, fromSessionId, courseId)
800
+ );
801
+ migrateStorageMark(
802
+ storage,
803
+ courseStartedXapiStorageKey(fromSessionId, courseId),
804
+ courseStartedXapiStorageKey(toSessionId, courseId),
805
+ hasCourseStartedXapiSent(storage, fromSessionId, courseId)
806
+ );
538
807
  }
539
808
 
540
809
  // src/runtime/courseLifecycle.ts
541
- var courseStartedEmitFlights = /* @__PURE__ */ new Set();
810
+ var courseStartedEmitFlights = /* @__PURE__ */ new Map();
542
811
  function resetCourseStartedEmitFlightForTests() {
543
812
  courseStartedEmitFlights.clear();
544
813
  }
@@ -546,24 +815,43 @@ function tryEmitCourseStarted(ctx, deps, alreadyEmittedToSink) {
546
815
  const flightKey = `${ctx.sessionId}:${ctx.courseId}`;
547
816
  const marked = hasCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
548
817
  if (alreadyEmittedToSink) {
549
- return { emitted: true, marked };
818
+ const markPersisted = marked ? true : markCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
819
+ return Promise.resolve({
820
+ emitted: true,
821
+ marked: markPersisted
822
+ });
550
823
  }
551
- if (courseStartedEmitFlights.has(flightKey)) {
552
- return { emitted: false, marked };
824
+ if (marked && hasCourseStartedEmittedToTracking(ctx.storage, ctx.sessionId, ctx.courseId)) {
825
+ return Promise.resolve({
826
+ emitted: true,
827
+ marked: true
828
+ });
553
829
  }
554
- courseStartedEmitFlights.add(flightKey);
555
- try {
556
- const emitted = deps.emitCourseStartedEvent(ctx);
557
- if (emitted && !marked) {
558
- markCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
559
- }
560
- return {
561
- emitted,
562
- marked: hasCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId)
563
- };
564
- } finally {
565
- courseStartedEmitFlights.delete(flightKey);
830
+ const existing = courseStartedEmitFlights.get(flightKey);
831
+ if (existing) {
832
+ return existing;
566
833
  }
834
+ const flight = Promise.resolve().then(() => {
835
+ try {
836
+ const emitted = deps.emitCourseStartedEvent(ctx);
837
+ const markPersisted = emitted && !marked ? markCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId) : marked;
838
+ return {
839
+ emitted,
840
+ marked: markPersisted
841
+ };
842
+ } catch {
843
+ return {
844
+ emitted: false,
845
+ marked: hasCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId)
846
+ };
847
+ } finally {
848
+ if (courseStartedEmitFlights.get(flightKey) === flight) {
849
+ courseStartedEmitFlights.delete(flightKey);
850
+ }
851
+ }
852
+ });
853
+ courseStartedEmitFlights.set(flightKey, flight);
854
+ return flight;
567
855
  }
568
856
  function buildCourseStartedTelemetryEvent(ctx) {
569
857
  return buildTelemetryEvent({
@@ -591,12 +879,29 @@ function completeCourseWithTelemetry(opts) {
591
879
  });
592
880
  }
593
881
  const result = opts.progress.completeCourse();
594
- if (!result.didComplete) return false;
882
+ if (!result.didComplete) {
883
+ const after = opts.progress.getState();
884
+ if (after.activeLessonId) {
885
+ const lessonResult = opts.progress.completeLesson(after.activeLessonId, opts.nowMs);
886
+ if (lessonResult.didComplete) {
887
+ opts.emitLessonCompleted(after.activeLessonId, lessonResult.durationMs);
888
+ }
889
+ }
890
+ return false;
891
+ }
595
892
  opts.emitCourseCompleted();
596
893
  return true;
597
894
  }
598
895
 
599
896
  export {
897
+ ID_PATTERN,
898
+ ID_MAX_LENGTH,
899
+ validateId,
900
+ parseCourseId,
901
+ parseLessonId,
902
+ parseCheckId,
903
+ parseBlockId,
904
+ assertValidId,
600
905
  isDevEnvironment,
601
906
  warnDev,
602
907
  createSessionId,
@@ -618,6 +923,8 @@ export {
618
923
  markCourseStartedEmittedToTracking,
619
924
  hasCourseStartedPipelineDelivered,
620
925
  markCourseStartedPipelineDelivered,
926
+ hasCourseStartedXapiSent,
927
+ markCourseStartedXapiSent,
621
928
  resetSharedVolatileSessionIdForTests,
622
929
  migrateCourseStartedMark,
623
930
  resetCourseStartedEmitFlightForTests,