@lessonkit/core 0.9.2 → 1.0.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
@@ -22,18 +22,44 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  ID_MAX_LENGTH: () => ID_MAX_LENGTH,
24
24
  ID_PATTERN: () => ID_PATTERN,
25
+ SESSION_STORAGE_KEY: () => SESSION_STORAGE_KEY,
25
26
  TELEMETRY_EVENT_CATALOG: () => TELEMETRY_EVENT_CATALOG,
26
27
  assertValidId: () => assertValidId,
28
+ buildCourseStartedTelemetryEvent: () => buildCourseStartedTelemetryEvent,
27
29
  buildLessonkitUrn: () => buildLessonkitUrn,
28
30
  buildTelemetryCatalog: () => buildTelemetryCatalog,
29
- createPluginHost: () => createPluginHost,
31
+ buildTelemetryEvent: () => buildTelemetryEvent,
32
+ completeCourseWithTelemetry: () => completeCourseWithTelemetry,
33
+ completeLessonWithTelemetry: () => completeLessonWithTelemetry,
34
+ createDefaultClock: () => createDefaultClock,
35
+ createGlobalTimer: () => createGlobalTimer,
36
+ createLessonkitRuntime: () => createLessonkitRuntime,
37
+ createNoopStorage: () => createNoopStorage,
38
+ createPluginRegistry: () => createPluginRegistry,
39
+ createProgressController: () => createProgressController,
30
40
  createSessionId: () => createSessionId,
41
+ createSessionStoragePort: () => createSessionStoragePort,
42
+ createTelemetryPipeline: () => createTelemetryPipeline,
31
43
  createTrackingClient: () => createTrackingClient,
32
- defineLessonkitPlugin: () => defineLessonkitPlugin,
44
+ createTrackingPipelineSink: () => createTrackingPipelineSink,
45
+ defineAssessmentPlugin: () => defineAssessmentPlugin,
46
+ defineLifecyclePlugin: () => defineLifecyclePlugin,
47
+ defineTelemetryPlugin: () => defineTelemetryPlugin,
33
48
  deriveId: () => deriveId,
49
+ getTabSessionId: () => getTabSessionId,
50
+ hasCourseStarted: () => hasCourseStarted,
51
+ hasCourseStartedEmittedToTracking: () => hasCourseStartedEmittedToTracking,
52
+ markCourseStarted: () => markCourseStarted,
53
+ markCourseStartedEmittedToTracking: () => markCourseStartedEmittedToTracking,
54
+ migrateCourseStartedMark: () => migrateCourseStartedMark,
34
55
  nowIso: () => nowIso,
56
+ resetStoragePortForTests: () => resetStoragePortForTests,
57
+ resetTelemetryBuilderWarningsForTests: () => resetTelemetryBuilderWarningsForTests,
58
+ resolveSessionId: () => resolveSessionId,
35
59
  slugifyId: () => slugifyId,
36
60
  telemetryCatalogVersion: () => telemetryCatalogVersion,
61
+ tryBuildTelemetryEvent: () => tryBuildTelemetryEvent,
62
+ tryEmitCourseStarted: () => tryEmitCourseStarted,
37
63
  validateId: () => validateId
38
64
  });
39
65
  module.exports = __toCommonJS(index_exports);
@@ -194,18 +220,48 @@ function buildTelemetryCatalog() {
194
220
  }
195
221
 
196
222
  // src/trackingClient.ts
223
+ function isDevEnvironment() {
224
+ const g = globalThis;
225
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
226
+ }
227
+ function invokeTrackingSink(sink, event) {
228
+ let result;
229
+ try {
230
+ result = sink(event);
231
+ } catch (err) {
232
+ if (isDevEnvironment()) {
233
+ console.warn(
234
+ "[lessonkit] tracking sink failed:",
235
+ err instanceof Error ? err.message : err
236
+ );
237
+ }
238
+ throw err;
239
+ }
240
+ if (result != null && typeof result.catch === "function") {
241
+ void result.catch((err) => {
242
+ if (isDevEnvironment()) {
243
+ console.warn(
244
+ "[lessonkit] tracking sink failed:",
245
+ err instanceof Error ? err.message : err
246
+ );
247
+ }
248
+ });
249
+ }
250
+ }
197
251
  function createTrackingClient(opts) {
198
252
  const sink = opts?.sink;
199
253
  const batchSink = opts?.batchSink;
200
254
  const batchEnabled = opts?.batch?.enabled ?? Boolean(batchSink);
201
255
  const flushIntervalMs = opts?.batch?.flushIntervalMs ?? 5e3;
202
256
  const maxBatchSize = opts?.batch?.maxBatchSize ?? 25;
257
+ const maxBufferSize = 1e3;
258
+ let warnedBufferCap = false;
203
259
  if (!batchEnabled) {
204
260
  let disposed2 = false;
205
261
  return {
206
262
  track: (event) => {
207
263
  if (disposed2) return;
208
- void sink?.(event);
264
+ if (sink) invokeTrackingSink(sink, event);
209
265
  },
210
266
  dispose: () => {
211
267
  disposed2 = true;
@@ -264,6 +320,15 @@ function createTrackingClient(opts) {
264
320
  return {
265
321
  track: (event) => {
266
322
  if (disposed || disposing) return;
323
+ if (buffer.length >= maxBufferSize) {
324
+ buffer.shift();
325
+ if (!warnedBufferCap && typeof process !== "undefined" && process.env?.NODE_ENV === "development") {
326
+ warnedBufferCap = true;
327
+ console.warn(
328
+ `[lessonkit] telemetry batch buffer capped at ${maxBufferSize} events; oldest events are dropped while the sink is unavailable.`
329
+ );
330
+ }
331
+ }
267
332
  buffer.push(event);
268
333
  if (buffer.length >= maxBatchSize) void flush();
269
334
  },
@@ -295,16 +360,461 @@ function nowIso() {
295
360
  return (/* @__PURE__ */ new Date()).toISOString();
296
361
  }
297
362
 
298
- // src/plugins.ts
299
- function defineLessonkitPlugin(plugin) {
300
- return plugin;
363
+ // src/telemetryBuilder.ts
364
+ var warnedMissingQuizLesson = false;
365
+ function isDevEnvironment2() {
366
+ const g = globalThis;
367
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
368
+ }
369
+ function resetTelemetryBuilderWarningsForTests() {
370
+ warnedMissingQuizLesson = false;
371
+ }
372
+ function buildTelemetryEvent(opts) {
373
+ const base = {
374
+ timestamp: opts.timestamp ?? nowIso(),
375
+ courseId: opts.courseId,
376
+ sessionId: opts.sessionId,
377
+ attemptId: opts.attemptId,
378
+ user: opts.user
379
+ };
380
+ switch (opts.name) {
381
+ case "course_started":
382
+ return { name: "course_started", ...base };
383
+ case "course_completed":
384
+ return { name: "course_completed", ...base };
385
+ case "lesson_started": {
386
+ const data = opts.data;
387
+ const lessonId = opts.lessonId ?? data?.lessonId;
388
+ if (!lessonId) throw new Error("lesson_started requires lessonId");
389
+ return {
390
+ name: "lesson_started",
391
+ ...base,
392
+ lessonId,
393
+ data: { ...data, lessonId }
394
+ };
395
+ }
396
+ case "lesson_completed":
397
+ case "lesson_time_on_task": {
398
+ const data = opts.data;
399
+ const lessonId = opts.lessonId ?? data?.lessonId;
400
+ if (!lessonId) throw new Error(`${opts.name} requires lessonId`);
401
+ return {
402
+ name: opts.name,
403
+ ...base,
404
+ lessonId,
405
+ data: { ...data, lessonId }
406
+ };
407
+ }
408
+ case "quiz_answered": {
409
+ const data = opts.data;
410
+ const lessonId = opts.lessonId;
411
+ if (!lessonId) throw new Error("quiz_answered requires active lessonId");
412
+ return { name: "quiz_answered", ...base, lessonId, data };
413
+ }
414
+ case "quiz_completed": {
415
+ const data = opts.data;
416
+ const lessonId = opts.lessonId;
417
+ if (!lessonId) throw new Error("quiz_completed requires active lessonId");
418
+ return { name: "quiz_completed", ...base, lessonId, data };
419
+ }
420
+ case "interaction":
421
+ return {
422
+ name: "interaction",
423
+ ...base,
424
+ lessonId: opts.lessonId,
425
+ data: opts.data
426
+ };
427
+ default:
428
+ return { name: opts.name, ...base };
429
+ }
430
+ }
431
+ function tryBuildTelemetryEvent(opts) {
432
+ const isQuiz = opts.name === "quiz_answered" || opts.name === "quiz_completed";
433
+ if (isQuiz && !opts.lessonId) {
434
+ if (isDevEnvironment2() && !warnedMissingQuizLesson) {
435
+ warnedMissingQuizLesson = true;
436
+ console.warn(
437
+ `[lessonkit] ${opts.name} skipped: wrap <Quiz> in <Lesson> so an active lessonId is available`
438
+ );
439
+ }
440
+ return null;
441
+ }
442
+ return buildTelemetryEvent(opts);
443
+ }
444
+
445
+ // src/telemetryPipeline.ts
446
+ function isDevEnvironment3() {
447
+ const g = globalThis;
448
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
449
+ }
450
+ function warnSinkFailure(sinkId, err) {
451
+ if (isDevEnvironment3()) {
452
+ console.warn(
453
+ `[lessonkit] telemetry sink "${sinkId}" failed:`,
454
+ err instanceof Error ? err.message : err
455
+ );
456
+ }
457
+ }
458
+ function invokeSink(sink, event, emitCtx) {
459
+ let result;
460
+ try {
461
+ result = sink.emit(event, emitCtx);
462
+ } catch (err) {
463
+ warnSinkFailure(sink.id, err);
464
+ return;
465
+ }
466
+ if (result != null && typeof result.catch === "function") {
467
+ void result.catch((err) => warnSinkFailure(sink.id, err));
468
+ }
469
+ }
470
+ function createTelemetryPipeline(sinks) {
471
+ const list = [...sinks];
472
+ return {
473
+ sinks: list,
474
+ emit(event, ctx) {
475
+ const emitCtx = ctx ?? {
476
+ courseId: event.courseId,
477
+ sessionId: event.sessionId,
478
+ attemptId: event.attemptId
479
+ };
480
+ for (const sink of list) {
481
+ invokeSink(sink, event, emitCtx);
482
+ }
483
+ }
484
+ };
485
+ }
486
+ function createTrackingPipelineSink(id, track) {
487
+ return {
488
+ id,
489
+ emit(event) {
490
+ track(event);
491
+ }
492
+ };
493
+ }
494
+
495
+ // src/ports.ts
496
+ function createDefaultClock() {
497
+ return {
498
+ nowMs: () => Date.now(),
499
+ nowIso: () => (/* @__PURE__ */ new Date()).toISOString()
500
+ };
501
+ }
502
+ function createNoopStorage() {
503
+ return {
504
+ getItem: () => null,
505
+ setItem: () => {
506
+ }
507
+ };
508
+ }
509
+ function createMemoryBackedSessionStorage(session) {
510
+ const memory = /* @__PURE__ */ new Map();
511
+ let warnedPersistFailure = false;
512
+ const warnPersistFailure = () => {
513
+ if (warnedPersistFailure) return;
514
+ warnedPersistFailure = true;
515
+ if (typeof process !== "undefined" && process.env?.NODE_ENV === "development") {
516
+ console.warn(
517
+ "[lessonkit] sessionStorage is unavailable or failed; using in-memory session dedupe for this tab (may reset on full reload)."
518
+ );
519
+ }
520
+ };
521
+ return {
522
+ getItem: (key) => {
523
+ if (memory.has(key)) return memory.get(key);
524
+ try {
525
+ const value = session.getItem(key);
526
+ if (value !== null) memory.set(key, value);
527
+ return value;
528
+ } catch {
529
+ return memory.get(key) ?? null;
530
+ }
531
+ },
532
+ setItem: (key, value) => {
533
+ memory.set(key, value);
534
+ try {
535
+ session.setItem(key, value);
536
+ } catch {
537
+ warnPersistFailure();
538
+ }
539
+ },
540
+ removeItem: (key) => {
541
+ memory.delete(key);
542
+ try {
543
+ session.removeItem(key);
544
+ } catch {
545
+ warnPersistFailure();
546
+ }
547
+ },
548
+ resetForTests: () => {
549
+ memory.clear();
550
+ }
551
+ };
552
+ }
553
+ function resetStoragePortForTests(storage) {
554
+ storage.resetForTests?.();
555
+ }
556
+ function createSessionStoragePort() {
557
+ if (typeof sessionStorage === "undefined") {
558
+ const memory = /* @__PURE__ */ new Map();
559
+ return {
560
+ getItem: (key) => memory.get(key) ?? null,
561
+ setItem: (key, value) => {
562
+ memory.set(key, value);
563
+ },
564
+ removeItem: (key) => {
565
+ memory.delete(key);
566
+ },
567
+ resetForTests: () => {
568
+ memory.clear();
569
+ }
570
+ };
571
+ }
572
+ return createMemoryBackedSessionStorage(sessionStorage);
573
+ }
574
+ function createGlobalTimer() {
575
+ return {
576
+ setInterval: (fn, ms) => globalThis.setInterval(fn, ms),
577
+ clearInterval: (id) => globalThis.clearInterval(id)
578
+ };
579
+ }
580
+
581
+ // src/progress.ts
582
+ function createProgressController() {
583
+ let activeLessonId;
584
+ let completedLessonIds = /* @__PURE__ */ new Set();
585
+ let courseCompleted = false;
586
+ const lessonStartTimes = /* @__PURE__ */ new Map();
587
+ return {
588
+ getState: () => ({
589
+ activeLessonId,
590
+ completedLessonIds: new Set(completedLessonIds),
591
+ courseCompleted
592
+ }),
593
+ setActiveLesson: (lessonId, startedAtMs) => {
594
+ const previousLessonId = activeLessonId;
595
+ activeLessonId = lessonId;
596
+ lessonStartTimes.set(lessonId, startedAtMs);
597
+ return { previousLessonId };
598
+ },
599
+ completeLesson: (lessonId, completedAtMs) => {
600
+ if (completedLessonIds.has(lessonId)) return { didComplete: false };
601
+ completedLessonIds = new Set(completedLessonIds).add(lessonId);
602
+ if (activeLessonId === lessonId) {
603
+ activeLessonId = void 0;
604
+ }
605
+ const startedAt = lessonStartTimes.get(lessonId);
606
+ lessonStartTimes.delete(lessonId);
607
+ const durationMs = typeof startedAt === "number" ? Math.max(0, completedAtMs - startedAt) : void 0;
608
+ return { durationMs, didComplete: true };
609
+ },
610
+ completeCourse: () => {
611
+ if (courseCompleted) return { didComplete: false };
612
+ courseCompleted = true;
613
+ return { didComplete: true };
614
+ }
615
+ };
616
+ }
617
+
618
+ // src/session.ts
619
+ var SESSION_STORAGE_KEY = "lessonkit:sessionId";
620
+ function getTabSessionId(storage) {
621
+ return storage.getItem(SESSION_STORAGE_KEY);
622
+ }
623
+ var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
624
+ var COURSE_STARTED_TRACKING_PREFIX = "lessonkit:course_started_tracking:";
625
+ function resolveSessionId(storage, provided) {
626
+ if (provided) return provided;
627
+ const existing = storage.getItem(SESSION_STORAGE_KEY);
628
+ if (existing) return existing;
629
+ const id = createSessionId();
630
+ storage.setItem(SESSION_STORAGE_KEY, id);
631
+ return id;
632
+ }
633
+ function courseStartedStorageKey(sessionId, courseId) {
634
+ return `${COURSE_STARTED_PREFIX}${sessionId}:${courseId ?? ""}`;
301
635
  }
636
+ function courseStartedTrackingStorageKey(sessionId, courseId) {
637
+ return `${COURSE_STARTED_TRACKING_PREFIX}${sessionId}:${courseId ?? ""}`;
638
+ }
639
+ function hasCourseStarted(storage, sessionId, courseId) {
640
+ if (!courseId) return false;
641
+ return storage.getItem(courseStartedStorageKey(sessionId, courseId)) === "1";
642
+ }
643
+ function markCourseStarted(storage, sessionId, courseId) {
644
+ if (!courseId) return;
645
+ storage.setItem(courseStartedStorageKey(sessionId, courseId), "1");
646
+ }
647
+ function hasCourseStartedEmittedToTracking(storage, sessionId, courseId) {
648
+ if (!courseId) return false;
649
+ return storage.getItem(courseStartedTrackingStorageKey(sessionId, courseId)) === "1";
650
+ }
651
+ function markCourseStartedEmittedToTracking(storage, sessionId, courseId) {
652
+ if (!courseId) return;
653
+ storage.setItem(courseStartedTrackingStorageKey(sessionId, courseId), "1");
654
+ }
655
+ function migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId) {
656
+ if (!courseId || fromSessionId === toSessionId) return;
657
+ if (hasCourseStarted(storage, fromSessionId, courseId)) {
658
+ markCourseStarted(storage, toSessionId, courseId);
659
+ storage.removeItem?.(courseStartedStorageKey(fromSessionId, courseId));
660
+ }
661
+ if (hasCourseStartedEmittedToTracking(storage, fromSessionId, courseId)) {
662
+ markCourseStartedEmittedToTracking(storage, toSessionId, courseId);
663
+ storage.removeItem?.(courseStartedTrackingStorageKey(fromSessionId, courseId));
664
+ }
665
+ }
666
+
667
+ // src/runtime/courseLifecycle.ts
668
+ function tryEmitCourseStarted(ctx, deps, alreadyEmittedToSink) {
669
+ const marked = hasCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
670
+ if (alreadyEmittedToSink) {
671
+ return { emitted: true, marked };
672
+ }
673
+ if (marked) {
674
+ return { emitted: false, marked: true };
675
+ }
676
+ const emitted = deps.emitCourseStartedEvent(ctx);
677
+ if (emitted) {
678
+ markCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
679
+ }
680
+ return { emitted, marked: emitted };
681
+ }
682
+ function buildCourseStartedTelemetryEvent(ctx) {
683
+ return buildTelemetryEvent({
684
+ name: "course_started",
685
+ courseId: ctx.courseId,
686
+ sessionId: ctx.sessionId,
687
+ attemptId: ctx.attemptId,
688
+ user: ctx.user
689
+ });
690
+ }
691
+ function completeLessonWithTelemetry(opts) {
692
+ const result = opts.progress.completeLesson(opts.lessonId, opts.nowMs);
693
+ if (!result.didComplete) return false;
694
+ opts.emitLessonCompleted(opts.lessonId, result.durationMs);
695
+ return true;
696
+ }
697
+ function completeCourseWithTelemetry(opts) {
698
+ const current = opts.progress.getState();
699
+ if (current.activeLessonId) {
700
+ completeLessonWithTelemetry({
701
+ progress: opts.progress,
702
+ lessonId: current.activeLessonId,
703
+ nowMs: opts.nowMs,
704
+ emitLessonCompleted: opts.emitLessonCompleted
705
+ });
706
+ }
707
+ const result = opts.progress.completeCourse();
708
+ if (!result.didComplete) return false;
709
+ opts.emitCourseCompleted();
710
+ return true;
711
+ }
712
+
713
+ // src/runtime/createLessonkitRuntime.ts
714
+ function createLessonkitRuntime(config, ports = {}) {
715
+ const storage = ports.storage ?? createSessionStoragePort();
716
+ const clock = ports.clock ?? createDefaultClock();
717
+ const configSnapshot = { ...config };
718
+ let sessionId = resolveSessionId(storage, configSnapshot.session?.sessionId);
719
+ let attemptId = configSnapshot.session?.attemptId;
720
+ let user = configSnapshot.session?.user;
721
+ let courseId = configSnapshot.courseId;
722
+ let progress = createProgressController();
723
+ const getSession = () => ({ sessionId, attemptId, user });
724
+ const syncSessionFromConfig = (next) => {
725
+ sessionId = resolveSessionId(storage, next.session?.sessionId);
726
+ attemptId = next.session?.attemptId;
727
+ user = next.session?.user;
728
+ courseId = next.courseId;
729
+ };
730
+ syncSessionFromConfig(configSnapshot);
731
+ const track = (name, data, emit, lessonId) => {
732
+ const event = tryBuildTelemetryEvent({
733
+ name,
734
+ courseId,
735
+ lessonId: lessonId ?? progress.getState().activeLessonId,
736
+ sessionId,
737
+ attemptId,
738
+ user,
739
+ data
740
+ });
741
+ if (!event) return;
742
+ emit(event);
743
+ };
744
+ const emitLessonCompleted = (lessonId, durationMs, emitFn) => {
745
+ emitFn("lesson_completed", { lessonId, durationMs }, lessonId);
746
+ if (durationMs !== void 0) {
747
+ emitFn("lesson_time_on_task", { lessonId, durationMs }, lessonId);
748
+ }
749
+ };
750
+ return {
751
+ get config() {
752
+ return configSnapshot;
753
+ },
754
+ get progress() {
755
+ return progress;
756
+ },
757
+ getProgressState: () => progress.getState(),
758
+ getSession,
759
+ updateConfig(next) {
760
+ if (next.courseId !== void 0) configSnapshot.courseId = next.courseId;
761
+ if (next.runtimeVersion !== void 0) configSnapshot.runtimeVersion = next.runtimeVersion;
762
+ if (next.plugins !== void 0) configSnapshot.plugins = next.plugins;
763
+ if (next.session !== void 0) {
764
+ configSnapshot.session = { ...configSnapshot.session, ...next.session };
765
+ }
766
+ syncSessionFromConfig(configSnapshot);
767
+ },
768
+ setActiveLesson(lessonId, emitFn) {
769
+ const current = progress.getState();
770
+ if (current.activeLessonId === lessonId) return;
771
+ if (current.completedLessonIds.has(lessonId)) {
772
+ progress.setActiveLesson(lessonId, clock.nowMs());
773
+ return;
774
+ }
775
+ const previous = current.activeLessonId;
776
+ if (previous && previous !== lessonId) {
777
+ const completed = progress.completeLesson(previous, clock.nowMs());
778
+ if (completed.didComplete) {
779
+ emitLessonCompleted(previous, completed.durationMs, emitFn);
780
+ }
781
+ }
782
+ progress.setActiveLesson(lessonId, clock.nowMs());
783
+ emitFn("lesson_started", { lessonId }, lessonId);
784
+ },
785
+ completeLesson(lessonId, emitFn) {
786
+ const result = progress.completeLesson(lessonId, clock.nowMs());
787
+ if (!result.didComplete) return;
788
+ emitLessonCompleted(lessonId, result.durationMs, emitFn);
789
+ },
790
+ completeCourse(emitFn) {
791
+ const current = progress.getState();
792
+ if (current.activeLessonId) {
793
+ const lessonResult = progress.completeLesson(current.activeLessonId, clock.nowMs());
794
+ if (lessonResult.didComplete) {
795
+ emitLessonCompleted(current.activeLessonId, lessonResult.durationMs, emitFn);
796
+ }
797
+ }
798
+ const result = progress.completeCourse();
799
+ if (!result.didComplete) return;
800
+ emitFn("course_completed");
801
+ },
802
+ track,
803
+ resetForCourseChange(nextCourseId) {
804
+ configSnapshot.courseId = nextCourseId;
805
+ courseId = nextCourseId;
806
+ progress = createProgressController();
807
+ }
808
+ };
809
+ }
810
+
811
+ // src/plugins/registry.ts
302
812
  function warnDuplicatePlugin(id) {
303
813
  const g = globalThis;
304
814
  if (typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production") return;
305
815
  console.warn(`[lessonkit] plugin id "${id}" was registered more than once; using the latest definition`);
306
816
  }
307
- function createPluginHost(plugins = []) {
817
+ function createPluginRegistry(plugins = []) {
308
818
  const registry = /* @__PURE__ */ new Map();
309
819
  for (const plugin of plugins) {
310
820
  if (registry.has(plugin.id)) warnDuplicatePlugin(plugin.id);
@@ -342,11 +852,26 @@ function createPluginHost(plugins = []) {
342
852
  }
343
853
  return events;
344
854
  };
345
- const composeTrackingSink = (sink, ctx) => {
855
+ const composeTrackingSink = (sink, ctxSource) => {
856
+ if (!sink) return void 0;
857
+ const resolveCtx = () => typeof ctxSource === "function" ? ctxSource() : ctxSource;
858
+ const ctxKey = (ctx) => `${ctx.courseId}\0${ctx.sessionId ?? ""}\0${ctx.attemptId ?? ""}`;
859
+ const layers = [];
346
860
  let composed = sink;
347
861
  for (const plugin of list) {
348
- if (!plugin.wrapTrackingSink || !composed) continue;
349
- composed = plugin.wrapTrackingSink(composed, ctx);
862
+ if (!plugin.wrapTrackingSink) continue;
863
+ const inner = composed;
864
+ const layer = { plugin, inner, wrapped: null, lastCtxKey: "" };
865
+ layers.push(layer);
866
+ composed = (event) => {
867
+ const ctx = resolveCtx();
868
+ const key = ctxKey(ctx);
869
+ if (!layer.wrapped || layer.lastCtxKey !== key) {
870
+ layer.wrapped = layer.plugin.wrapTrackingSink(layer.inner, ctx) ?? layer.inner;
871
+ layer.lastCtxKey = key;
872
+ }
873
+ return layer.wrapped(event);
874
+ };
350
875
  }
351
876
  return composed;
352
877
  };
@@ -369,21 +894,58 @@ function createPluginHost(plugins = []) {
369
894
  scoreAssessment
370
895
  };
371
896
  }
897
+
898
+ // src/plugins/define.ts
899
+ function defineTelemetryPlugin(plugin) {
900
+ return plugin;
901
+ }
902
+ function defineAssessmentPlugin(plugin) {
903
+ return plugin;
904
+ }
905
+ function defineLifecyclePlugin(plugin) {
906
+ return plugin;
907
+ }
372
908
  // Annotate the CommonJS export names for ESM import in node:
373
909
  0 && (module.exports = {
374
910
  ID_MAX_LENGTH,
375
911
  ID_PATTERN,
912
+ SESSION_STORAGE_KEY,
376
913
  TELEMETRY_EVENT_CATALOG,
377
914
  assertValidId,
915
+ buildCourseStartedTelemetryEvent,
378
916
  buildLessonkitUrn,
379
917
  buildTelemetryCatalog,
380
- createPluginHost,
918
+ buildTelemetryEvent,
919
+ completeCourseWithTelemetry,
920
+ completeLessonWithTelemetry,
921
+ createDefaultClock,
922
+ createGlobalTimer,
923
+ createLessonkitRuntime,
924
+ createNoopStorage,
925
+ createPluginRegistry,
926
+ createProgressController,
381
927
  createSessionId,
928
+ createSessionStoragePort,
929
+ createTelemetryPipeline,
382
930
  createTrackingClient,
383
- defineLessonkitPlugin,
931
+ createTrackingPipelineSink,
932
+ defineAssessmentPlugin,
933
+ defineLifecyclePlugin,
934
+ defineTelemetryPlugin,
384
935
  deriveId,
936
+ getTabSessionId,
937
+ hasCourseStarted,
938
+ hasCourseStartedEmittedToTracking,
939
+ markCourseStarted,
940
+ markCourseStartedEmittedToTracking,
941
+ migrateCourseStartedMark,
385
942
  nowIso,
943
+ resetStoragePortForTests,
944
+ resetTelemetryBuilderWarningsForTests,
945
+ resolveSessionId,
386
946
  slugifyId,
387
947
  telemetryCatalogVersion,
948
+ tryBuildTelemetryEvent,
949
+ tryEmitCourseStarted,
388
950
  validateId
389
951
  });