@lessonkit/core 1.2.0 → 1.3.1

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
@@ -29,6 +29,8 @@ __export(index_exports, {
29
29
  INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES: () => INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES,
30
30
  PAGE_ALLOWED_CHILD_TYPES: () => PAGE_ALLOWED_CHILD_TYPES,
31
31
  SESSION_STORAGE_KEY: () => SESSION_STORAGE_KEY,
32
+ SLIDE_ALLOWED_CHILD_TYPES: () => SLIDE_ALLOWED_CHILD_TYPES,
33
+ SLIDE_DECK_ALLOWED_CHILD_TYPES: () => SLIDE_DECK_ALLOWED_CHILD_TYPES,
32
34
  TELEMETRY_EVENT_CATALOG: () => TELEMETRY_EVENT_CATALOG,
33
35
  TELEMETRY_EVENT_CATALOG_V2: () => TELEMETRY_EVENT_CATALOG_V2,
34
36
  TELEMETRY_EVENT_CATALOG_V3: () => TELEMETRY_EVENT_CATALOG_V3,
@@ -79,6 +81,7 @@ __export(index_exports, {
79
81
  parseCompoundResumeState: () => parseCompoundResumeState,
80
82
  parseCourseId: () => parseCourseId,
81
83
  parseLessonId: () => parseLessonId,
84
+ resetSharedVolatileSessionIdForTests: () => resetSharedVolatileSessionIdForTests,
82
85
  resetStoragePortForTests: () => resetStoragePortForTests,
83
86
  resetTelemetryBuilderWarningsForTests: () => resetTelemetryBuilderWarningsForTests,
84
87
  resolveSessionId: () => resolveSessionId,
@@ -155,21 +158,40 @@ function assertValidId(input, path = "id") {
155
158
  }
156
159
 
157
160
  // src/slugify.ts
161
+ function shortHash(input) {
162
+ let h = 0;
163
+ for (let i = 0; i < input.length; i++) {
164
+ h = Math.imul(31, h) + input.charCodeAt(i) >>> 0;
165
+ }
166
+ return h.toString(36);
167
+ }
168
+ function uniqueFallbackId(input, usedIds) {
169
+ const hash = shortHash(input);
170
+ for (let n = 0; n < 100; n++) {
171
+ const candidate = (n === 0 ? `id-${hash}` : `id-${hash}-${n}`).slice(0, 64);
172
+ const validated2 = validateId(candidate);
173
+ if (validated2.ok && !usedIds.has(validated2.id)) return validated2.id;
174
+ }
175
+ const timed = `id-${hash}-${Date.now().toString(36)}`.slice(0, 64);
176
+ const validated = validateId(timed);
177
+ return validated.ok ? validated.id : `id-${hash}`;
178
+ }
158
179
  function slugifyId(input) {
159
180
  const slug = input.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-").slice(0, 64);
160
- if (!slug.length) return "id";
161
- const candidate = /^[a-z]/.test(slug) ? slug : `id-${slug}`;
181
+ if (!slug.length) return uniqueFallbackId(input, /* @__PURE__ */ new Set());
182
+ const candidate = /^[a-z]/.test(slug) ? slug : `id-${slug}`.slice(0, 64);
162
183
  const validated = validateId(candidate);
163
- return validated.ok ? validated.id : "id";
184
+ return validated.ok ? validated.id : uniqueFallbackId(input, /* @__PURE__ */ new Set());
164
185
  }
165
186
  function deriveId(title, usedIds = /* @__PURE__ */ new Set()) {
166
187
  const base = slugifyId(title);
167
- if (!usedIds.has(base)) return base;
188
+ if (!usedIds.has(base) && validateId(base).ok) return base;
168
189
  for (let n = 2; n < 1e3; n++) {
169
- const candidate = `${base}-${n}`;
170
- if (!usedIds.has(candidate)) return candidate;
190
+ const candidate = `${base}-${n}`.slice(0, 64);
191
+ const validated = validateId(candidate);
192
+ if (validated.ok && !usedIds.has(validated.id)) return validated.id;
171
193
  }
172
- return `${base}-${Date.now()}`;
194
+ return uniqueFallbackId(`${title}-${Date.now()}`, usedIds);
173
195
  }
174
196
 
175
197
  // src/urn.ts
@@ -217,6 +239,27 @@ function clampCompoundPageIndex(index, pageCount) {
217
239
  if (pageCount < 1) return 0;
218
240
  return Math.min(Math.max(0, Math.floor(index)), pageCount - 1);
219
241
  }
242
+ function isJsonPrimitive(value) {
243
+ return value === null || typeof value === "boolean" || typeof value === "string" || typeof value === "number" && Number.isFinite(value);
244
+ }
245
+ function isPlainStringKeyMap(value) {
246
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
247
+ return Object.entries(value).every(
248
+ ([key, entry]) => typeof key === "string" && isJsonPrimitive(entry)
249
+ );
250
+ }
251
+ function isValidChildResumeValue(value) {
252
+ if (isJsonPrimitive(value)) return true;
253
+ if (Array.isArray(value)) return value.every((item) => isJsonPrimitive(item));
254
+ if (isPlainStringKeyMap(value)) return true;
255
+ return false;
256
+ }
257
+ function isPlainSerializableChildState(value) {
258
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
259
+ return Object.values(value).every(
260
+ (entry) => isValidChildResumeValue(entry)
261
+ );
262
+ }
220
263
  function parseCompoundResumeState(raw) {
221
264
  if (!raw || typeof raw !== "object") return null;
222
265
  const obj = raw;
@@ -225,7 +268,7 @@ function parseCompoundResumeState(raw) {
225
268
  const childStates = {};
226
269
  if (obj.childStates && typeof obj.childStates === "object" && !Array.isArray(obj.childStates)) {
227
270
  for (const [key, value] of Object.entries(obj.childStates)) {
228
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
271
+ if (isPlainSerializableChildState(value)) {
229
272
  childStates[key] = value;
230
273
  }
231
274
  }
@@ -239,22 +282,40 @@ function parseCompoundResumeState(raw) {
239
282
  };
240
283
  }
241
284
 
285
+ // src/internal/env.ts
286
+ function isDevEnvironment() {
287
+ const g = globalThis;
288
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
289
+ }
290
+ function warnDev(message, err) {
291
+ if (!isDevEnvironment()) return;
292
+ console.warn(message, err instanceof Error ? err.message : err);
293
+ }
294
+
242
295
  // src/compoundState.ts
243
296
  var COMPOUND_STATE_PREFIX = "lessonkit:compound:";
244
297
  function compoundStateStorageKey(courseId, compoundId) {
245
298
  return `${COMPOUND_STATE_PREFIX}${courseId}:${compoundId}`;
246
299
  }
247
300
  function loadCompoundState(storage, courseId, compoundId) {
248
- const raw = storage.getItem(compoundStateStorageKey(courseId, compoundId));
301
+ const key = compoundStateStorageKey(courseId, compoundId);
302
+ const raw = storage.getItem(key);
249
303
  if (!raw) return null;
250
304
  try {
251
- return parseCompoundResumeState(JSON.parse(raw));
305
+ const parsed = parseCompoundResumeState(JSON.parse(raw));
306
+ if (parsed === null && isDevEnvironment()) {
307
+ console.warn(`[lessonkit] Ignoring corrupt compound resume state at ${key}`);
308
+ }
309
+ return parsed;
252
310
  } catch {
311
+ if (isDevEnvironment()) {
312
+ console.warn(`[lessonkit] Ignoring corrupt compound resume state at ${key}`);
313
+ }
253
314
  return null;
254
315
  }
255
316
  }
256
317
  function saveCompoundState(storage, courseId, compoundId, state) {
257
- storage.setItem(compoundStateStorageKey(courseId, compoundId), JSON.stringify(state));
318
+ return storage.setItem(compoundStateStorageKey(courseId, compoundId), JSON.stringify(state));
258
319
  }
259
320
  function clearCompoundState(storage, courseId, compoundId) {
260
321
  storage.removeItem?.(compoundStateStorageKey(courseId, compoundId));
@@ -284,6 +345,28 @@ var PAGE_ALLOWED_CHILD_TYPES = [
284
345
  "ProgressTracker"
285
346
  ];
286
347
  var INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES = ["Page"];
348
+ var SLIDE_ALLOWED_CHILD_TYPES = [
349
+ "Text",
350
+ "Heading",
351
+ "Image",
352
+ "Scenario",
353
+ "Reflection",
354
+ "Quiz",
355
+ "KnowledgeCheck",
356
+ "TrueFalse",
357
+ "FillInTheBlanks",
358
+ "DragAndDrop",
359
+ "DragTheWords",
360
+ "MarkTheWords",
361
+ "Accordion",
362
+ "DialogCards",
363
+ "Flashcards",
364
+ "ImageHotspots",
365
+ "FindHotspot",
366
+ "FindMultipleHotspots",
367
+ "ImageSlider"
368
+ ];
369
+ var SLIDE_DECK_ALLOWED_CHILD_TYPES = ["Slide"];
287
370
  var ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES = [
288
371
  "TrueFalse",
289
372
  "FillInTheBlanks",
@@ -298,11 +381,15 @@ var ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES = [
298
381
  var ALLOWLISTS = {
299
382
  Page: PAGE_ALLOWED_CHILD_TYPES,
300
383
  InteractiveBook: INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES,
384
+ Slide: SLIDE_ALLOWED_CHILD_TYPES,
385
+ SlideDeck: SLIDE_DECK_ALLOWED_CHILD_TYPES,
301
386
  AssessmentSequence: ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES
302
387
  };
303
388
  var COMPOUND_MAX_NESTING_DEPTH = {
304
389
  Page: 1,
305
390
  InteractiveBook: 2,
391
+ Slide: 1,
392
+ SlideDeck: 2,
306
393
  AssessmentSequence: 1
307
394
  };
308
395
  function getAllowedChildTypes(parent) {
@@ -420,6 +507,14 @@ var TELEMETRY_EVENT_CATALOG_V3 = [
420
507
  xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
421
508
  urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
422
509
  },
510
+ {
511
+ name: "slide_viewed",
512
+ description: "Learner viewed a slide in a SlideDeck (Course Presentation)",
513
+ requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
514
+ dataFields: ["blockId", "slideIndex", "slideTitle"],
515
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
516
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
517
+ },
423
518
  {
424
519
  name: "compound_page_viewed",
425
520
  description: "Learner activated a page inside a compound container",
@@ -465,16 +560,6 @@ function buildTelemetryCatalogV3() {
465
560
  return TELEMETRY_EVENT_CATALOG_V3.map((entry) => ({ ...entry }));
466
561
  }
467
562
 
468
- // src/internal/env.ts
469
- function isDevEnvironment() {
470
- const g = globalThis;
471
- return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
472
- }
473
- function warnDev(message, err) {
474
- if (!isDevEnvironment()) return;
475
- console.warn(message, err instanceof Error ? err.message : err);
476
- }
477
-
478
563
  // src/internal/sinkInvoke.ts
479
564
  function invokeTrackingSink(sink, event) {
480
565
  let result;
@@ -535,45 +620,66 @@ function createTrackingClient(opts) {
535
620
  }
536
621
  const buffer = [];
537
622
  let flushInFlight = null;
623
+ let inflightExitBatch = null;
538
624
  let disposed = false;
539
625
  let disposing = false;
540
626
  let intervalId;
541
627
  const runFlush = () => {
542
- if (!buffer.length) return Promise.resolve();
628
+ if (!buffer.length) return Promise.resolve(true);
543
629
  const events = buffer.splice(0, buffer.length);
544
- let sent = 0;
630
+ inflightExitBatch = events;
545
631
  let succeeded = false;
546
632
  return Promise.resolve().then(async () => {
547
633
  if (batchSink) {
548
634
  await batchSink(events);
549
635
  } else {
550
- for (const e of events) {
551
- await sink?.(e);
552
- sent += 1;
636
+ for (let i = 0; i < events.length; i++) {
637
+ try {
638
+ await sink?.(events[i]);
639
+ } catch {
640
+ buffer.unshift(...events.slice(i));
641
+ return;
642
+ }
553
643
  }
554
644
  }
555
645
  succeeded = true;
556
646
  }).catch(() => {
557
- buffer.unshift(...events.slice(sent));
558
- }).then(() => {
647
+ if (batchSink) {
648
+ buffer.unshift(...events);
649
+ }
650
+ }).then(async () => {
559
651
  if (succeeded && buffer.length > 0 && !disposed) {
560
652
  return runFlush();
561
653
  }
654
+ return succeeded;
655
+ }).finally(() => {
656
+ inflightExitBatch = null;
562
657
  });
563
658
  };
564
659
  const flush = () => {
565
- if (disposed) return Promise.resolve();
660
+ if (disposed) return Promise.resolve(true);
566
661
  if (flushInFlight) return flushInFlight;
567
- if (!buffer.length) return Promise.resolve();
662
+ if (!buffer.length) return Promise.resolve(true);
568
663
  flushInFlight = runFlush().finally(() => {
569
664
  flushInFlight = null;
570
665
  });
571
666
  return flushInFlight;
572
667
  };
668
+ const MAX_DISPOSE_FLUSH_ATTEMPTS = 10;
573
669
  const drainAll = async () => {
574
- await flush();
575
- while (buffer.length > 0) {
576
- await flush();
670
+ let attempts = 0;
671
+ while (buffer.length > 0 && attempts < MAX_DISPOSE_FLUSH_ATTEMPTS) {
672
+ const delivered = await flush();
673
+ attempts += 1;
674
+ if (!delivered) break;
675
+ }
676
+ if (buffer.length > 0) {
677
+ if (isDevEnvironment()) {
678
+ console.warn(
679
+ `[lessonkit] dropped ${buffer.length} buffered telemetry event(s) after dispose flush cap`
680
+ );
681
+ }
682
+ buffer.length = 0;
577
683
  }
578
684
  };
579
685
  intervalId = flushIntervalMs > 0 ? globalThis.setInterval(() => void flush(), flushIntervalMs) : void 0;
@@ -582,18 +688,35 @@ function createTrackingClient(opts) {
582
688
  track: (event) => {
583
689
  if (disposed || disposing) return;
584
690
  if (buffer.length >= maxBufferSize) {
585
- buffer.shift();
691
+ opts?.onBufferDrop?.();
586
692
  if (!warnedBufferCap && isDevEnvironment()) {
587
693
  warnedBufferCap = true;
588
694
  console.warn(
589
- `[lessonkit] telemetry batch buffer capped at ${maxBufferSize} events; oldest events are dropped while the sink is unavailable.`
695
+ `[lessonkit] telemetry batch buffer capped at ${maxBufferSize} events; new events are dropped until the buffer drains.`
590
696
  );
591
697
  }
698
+ return;
592
699
  }
593
700
  buffer.push(event);
594
701
  if (buffer.length >= maxBatchSize) void flush();
595
702
  },
596
703
  flush,
704
+ flushOnExit: opts?.exitBatchSink ? () => {
705
+ const fromBuffer = buffer.splice(0, buffer.length);
706
+ const fromInflight = inflightExitBatch ? [...inflightExitBatch] : [];
707
+ const events = [...fromInflight, ...fromBuffer];
708
+ if (!events.length) return;
709
+ try {
710
+ const result = opts.exitBatchSink(events);
711
+ if (result != null && typeof result.catch === "function") {
712
+ void result.catch(() => {
713
+ buffer.unshift(...events);
714
+ });
715
+ }
716
+ } catch {
717
+ buffer.unshift(...events);
718
+ }
719
+ } : void 0,
597
720
  dispose: () => {
598
721
  if (disposed || disposing) return Promise.resolve();
599
722
  disposing = true;
@@ -746,6 +869,20 @@ var TELEMETRY_EVENT_REGISTRY = {
746
869
  };
747
870
  }
748
871
  },
872
+ slide_viewed: {
873
+ requiresLessonId: true,
874
+ build: (opts, base) => {
875
+ if (opts.name !== "slide_viewed") throw new Error("unexpected event");
876
+ const lessonId = opts.lessonId;
877
+ if (!lessonId) throw new Error("slide_viewed requires active lessonId");
878
+ return {
879
+ name: "slide_viewed",
880
+ ...base,
881
+ lessonId,
882
+ data: opts.data
883
+ };
884
+ }
885
+ },
749
886
  compound_page_viewed: {
750
887
  requiresLessonId: true,
751
888
  build: (opts, base) => {
@@ -894,8 +1031,7 @@ function createDefaultClock() {
894
1031
  function createNoopStorage() {
895
1032
  return {
896
1033
  getItem: () => null,
897
- setItem: () => {
898
- }
1034
+ setItem: () => true
899
1035
  };
900
1036
  }
901
1037
  function createMemoryBackedSessionStorage(session) {
@@ -926,8 +1062,10 @@ function createMemoryBackedSessionStorage(session) {
926
1062
  memory.set(key, value);
927
1063
  try {
928
1064
  session.setItem(key, value);
1065
+ return true;
929
1066
  } catch {
930
1067
  warnPersistFailure();
1068
+ return false;
931
1069
  }
932
1070
  },
933
1071
  removeItem: (key) => {
@@ -952,6 +1090,7 @@ function createInMemorySessionStoragePort() {
952
1090
  getItem: (key) => memory.get(key) ?? null,
953
1091
  setItem: (key, value) => {
954
1092
  memory.set(key, value);
1093
+ return true;
955
1094
  },
956
1095
  removeItem: (key) => {
957
1096
  memory.delete(key);
@@ -1004,7 +1143,12 @@ function createProgressController() {
1004
1143
  return { previousLessonId };
1005
1144
  },
1006
1145
  completeLesson: (lessonId, completedAtMs) => {
1007
- if (completedLessonIds.has(lessonId)) return { didComplete: false };
1146
+ if (completedLessonIds.has(lessonId)) {
1147
+ if (activeLessonId === lessonId) {
1148
+ activeLessonId = void 0;
1149
+ }
1150
+ return { didComplete: false };
1151
+ }
1008
1152
  completedLessonIds = new Set(completedLessonIds).add(lessonId);
1009
1153
  if (activeLessonId === lessonId) {
1010
1154
  activeLessonId = void 0;
@@ -1024,6 +1168,12 @@ function createProgressController() {
1024
1168
 
1025
1169
  // src/session.ts
1026
1170
  var SESSION_STORAGE_KEY = "lessonkit:sessionId";
1171
+ var volatileSessionIds = /* @__PURE__ */ new WeakMap();
1172
+ var sharedVolatileSessionId = null;
1173
+ function isDevEnvironment2() {
1174
+ const g = globalThis;
1175
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
1176
+ }
1027
1177
  function getTabSessionId(storage) {
1028
1178
  return storage.getItem(SESSION_STORAGE_KEY);
1029
1179
  }
@@ -1031,11 +1181,28 @@ var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
1031
1181
  var COURSE_STARTED_TRACKING_PREFIX = "lessonkit:course_started_tracking:";
1032
1182
  var COURSE_STARTED_PIPELINE_PREFIX = "lessonkit:course_started_pipeline:";
1033
1183
  function resolveSessionId(storage, provided) {
1034
- if (provided) return provided;
1184
+ if (provided !== void 0) {
1185
+ const trimmed = provided.trim();
1186
+ if (trimmed.length > 0) return trimmed;
1187
+ }
1035
1188
  const existing = storage.getItem(SESSION_STORAGE_KEY);
1036
1189
  if (existing) return existing;
1190
+ const volatile = volatileSessionIds.get(storage);
1191
+ if (volatile) return volatile;
1037
1192
  const id = createSessionId();
1038
- storage.setItem(SESSION_STORAGE_KEY, id);
1193
+ const persisted = storage.setItem(SESSION_STORAGE_KEY, id);
1194
+ if (!persisted) {
1195
+ if (!sharedVolatileSessionId) {
1196
+ sharedVolatileSessionId = id;
1197
+ }
1198
+ volatileSessionIds.set(storage, sharedVolatileSessionId);
1199
+ if (isDevEnvironment2()) {
1200
+ console.warn(
1201
+ "[lessonkit] session id could not be persisted; reusing in-memory id for this tab."
1202
+ );
1203
+ }
1204
+ return sharedVolatileSessionId;
1205
+ }
1039
1206
  return id;
1040
1207
  }
1041
1208
  function courseStartedStorageKey(sessionId, courseId) {
@@ -1052,24 +1219,27 @@ function hasCourseStarted(storage, sessionId, courseId) {
1052
1219
  return storage.getItem(courseStartedStorageKey(sessionId, courseId)) === "1";
1053
1220
  }
1054
1221
  function markCourseStarted(storage, sessionId, courseId) {
1055
- if (!courseId) return;
1056
- storage.setItem(courseStartedStorageKey(sessionId, courseId), "1");
1222
+ if (!courseId) return false;
1223
+ return storage.setItem(courseStartedStorageKey(sessionId, courseId), "1");
1057
1224
  }
1058
1225
  function hasCourseStartedEmittedToTracking(storage, sessionId, courseId) {
1059
1226
  if (!courseId) return false;
1060
1227
  return storage.getItem(courseStartedTrackingStorageKey(sessionId, courseId)) === "1";
1061
1228
  }
1062
1229
  function markCourseStartedEmittedToTracking(storage, sessionId, courseId) {
1063
- if (!courseId) return;
1064
- storage.setItem(courseStartedTrackingStorageKey(sessionId, courseId), "1");
1230
+ if (!courseId) return false;
1231
+ return storage.setItem(courseStartedTrackingStorageKey(sessionId, courseId), "1");
1065
1232
  }
1066
1233
  function hasCourseStartedPipelineDelivered(storage, sessionId, courseId) {
1067
1234
  if (!courseId) return false;
1068
1235
  return storage.getItem(courseStartedPipelineStorageKey(sessionId, courseId)) === "1";
1069
1236
  }
1070
1237
  function markCourseStartedPipelineDelivered(storage, sessionId, courseId) {
1071
- if (!courseId) return;
1072
- storage.setItem(courseStartedPipelineStorageKey(sessionId, courseId), "1");
1238
+ if (!courseId) return false;
1239
+ return storage.setItem(courseStartedPipelineStorageKey(sessionId, courseId), "1");
1240
+ }
1241
+ function resetSharedVolatileSessionIdForTests() {
1242
+ sharedVolatileSessionId = null;
1073
1243
  }
1074
1244
  function migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId) {
1075
1245
  if (!courseId || fromSessionId === toSessionId) return;
@@ -1088,19 +1258,29 @@ function migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId)
1088
1258
  }
1089
1259
 
1090
1260
  // src/runtime/courseLifecycle.ts
1261
+ var courseStartedEmitFlights = /* @__PURE__ */ new Set();
1091
1262
  function tryEmitCourseStarted(ctx, deps, alreadyEmittedToSink) {
1263
+ const flightKey = `${ctx.sessionId}:${ctx.courseId}`;
1092
1264
  const marked = hasCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
1093
1265
  if (alreadyEmittedToSink) {
1094
1266
  return { emitted: true, marked };
1095
1267
  }
1096
- if (marked) {
1097
- return { emitted: false, marked: true };
1268
+ if (courseStartedEmitFlights.has(flightKey)) {
1269
+ return { emitted: false, marked };
1098
1270
  }
1099
- const emitted = deps.emitCourseStartedEvent(ctx);
1100
- if (emitted) {
1101
- markCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
1271
+ courseStartedEmitFlights.add(flightKey);
1272
+ try {
1273
+ const emitted = deps.emitCourseStartedEvent(ctx);
1274
+ if (emitted && !marked) {
1275
+ markCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
1276
+ }
1277
+ return {
1278
+ emitted,
1279
+ marked: hasCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId)
1280
+ };
1281
+ } finally {
1282
+ courseStartedEmitFlights.delete(flightKey);
1102
1283
  }
1103
- return { emitted, marked: emitted };
1104
1284
  }
1105
1285
  function buildCourseStartedTelemetryEvent(ctx) {
1106
1286
  return buildTelemetryEvent({
@@ -1190,7 +1370,7 @@ function createPluginRegistry(plugins = []) {
1190
1370
  const composeTrackingSink = (sink, ctxSource) => {
1191
1371
  if (!sink) return void 0;
1192
1372
  const resolveCtx = () => typeof ctxSource === "function" ? ctxSource() : ctxSource;
1193
- const ctxKey = (ctx) => `${ctx.courseId}\0${ctx.sessionId ?? ""}\0${ctx.attemptId ?? ""}`;
1373
+ const ctxKey = (ctx) => `${ctx.courseId}\0${ctx.sessionId ?? ""}\0${ctx.attemptId ?? ""}\0${ctx.user?.id ?? ""}`;
1194
1374
  const layers = [];
1195
1375
  let composed = sink;
1196
1376
  for (const plugin of list) {
@@ -1261,6 +1441,9 @@ function createLessonkitRuntime(config, ports = {}) {
1261
1441
  attemptId,
1262
1442
  user
1263
1443
  });
1444
+ if (!configSnapshot.deferPluginSetup) {
1445
+ pluginHost?.setupAll(getPluginCtx());
1446
+ }
1264
1447
  const getSession = () => ({ sessionId, attemptId, user });
1265
1448
  const syncSessionFromConfig = (next) => {
1266
1449
  sessionId = resolveSessionId(storage, next.session?.sessionId);
@@ -1320,11 +1503,8 @@ function createLessonkitRuntime(config, ports = {}) {
1320
1503
  getProgressState: () => progress.getState(),
1321
1504
  getSession,
1322
1505
  updateConfig(next) {
1323
- if (next.plugins !== void 0 && next.plugins !== pluginHost) {
1324
- pluginHost?.disposeAll();
1325
- configSnapshot.plugins = next.plugins;
1326
- pluginHost = resolvePluginHost(configSnapshot.plugins);
1327
- }
1506
+ const previousCourseId = courseId;
1507
+ const sessionKeyBefore = JSON.stringify({ sessionId, attemptId, user });
1328
1508
  if (next.courseId !== void 0) configSnapshot.courseId = next.courseId;
1329
1509
  if (next.runtimeVersion !== void 0) {
1330
1510
  if (next.runtimeVersion === "v1") warnRuntimeV1Deprecated();
@@ -1334,6 +1514,19 @@ function createLessonkitRuntime(config, ports = {}) {
1334
1514
  configSnapshot.session = { ...configSnapshot.session, ...next.session };
1335
1515
  }
1336
1516
  syncSessionFromConfig(configSnapshot);
1517
+ const sessionKeyAfter = JSON.stringify({ sessionId, attemptId, user });
1518
+ if (next.courseId !== void 0 && next.courseId !== previousCourseId) {
1519
+ progress = createProgressController();
1520
+ }
1521
+ if (next.plugins !== void 0 && next.plugins !== pluginHost) {
1522
+ pluginHost?.disposeAll();
1523
+ configSnapshot.plugins = next.plugins;
1524
+ pluginHost = resolvePluginHost(configSnapshot.plugins);
1525
+ pluginHost?.setupAll(getPluginCtx());
1526
+ } else if (next.session !== void 0 && sessionKeyBefore !== sessionKeyAfter && pluginHost && !configSnapshot.deferPluginSetup) {
1527
+ pluginHost.disposeAll();
1528
+ pluginHost.setupAll(getPluginCtx());
1529
+ }
1337
1530
  },
1338
1531
  setActiveLesson(lessonId, emitFn) {
1339
1532
  const wrapped = wrapEmitFn(emitFn);
@@ -1406,6 +1599,8 @@ function defineLifecyclePlugin(plugin) {
1406
1599
  INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES,
1407
1600
  PAGE_ALLOWED_CHILD_TYPES,
1408
1601
  SESSION_STORAGE_KEY,
1602
+ SLIDE_ALLOWED_CHILD_TYPES,
1603
+ SLIDE_DECK_ALLOWED_CHILD_TYPES,
1409
1604
  TELEMETRY_EVENT_CATALOG,
1410
1605
  TELEMETRY_EVENT_CATALOG_V2,
1411
1606
  TELEMETRY_EVENT_CATALOG_V3,
@@ -1456,6 +1651,7 @@ function defineLifecyclePlugin(plugin) {
1456
1651
  parseCompoundResumeState,
1457
1652
  parseCourseId,
1458
1653
  parseLessonId,
1654
+ resetSharedVolatileSessionIdForTests,
1459
1655
  resetStoragePortForTests,
1460
1656
  resetTelemetryBuilderWarningsForTests,
1461
1657
  resolveSessionId,