@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/README.md +31 -12
- package/dist/index.cjs +574 -12
- package/dist/index.d.cts +185 -20
- package/dist/index.d.ts +185 -20
- package/dist/index.js +546 -10
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -154,18 +154,48 @@ function buildTelemetryCatalog() {
|
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
// src/trackingClient.ts
|
|
157
|
+
function isDevEnvironment() {
|
|
158
|
+
const g = globalThis;
|
|
159
|
+
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
160
|
+
}
|
|
161
|
+
function invokeTrackingSink(sink, event) {
|
|
162
|
+
let result;
|
|
163
|
+
try {
|
|
164
|
+
result = sink(event);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
if (isDevEnvironment()) {
|
|
167
|
+
console.warn(
|
|
168
|
+
"[lessonkit] tracking sink failed:",
|
|
169
|
+
err instanceof Error ? err.message : err
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
throw err;
|
|
173
|
+
}
|
|
174
|
+
if (result != null && typeof result.catch === "function") {
|
|
175
|
+
void result.catch((err) => {
|
|
176
|
+
if (isDevEnvironment()) {
|
|
177
|
+
console.warn(
|
|
178
|
+
"[lessonkit] tracking sink failed:",
|
|
179
|
+
err instanceof Error ? err.message : err
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
157
185
|
function createTrackingClient(opts) {
|
|
158
186
|
const sink = opts?.sink;
|
|
159
187
|
const batchSink = opts?.batchSink;
|
|
160
188
|
const batchEnabled = opts?.batch?.enabled ?? Boolean(batchSink);
|
|
161
189
|
const flushIntervalMs = opts?.batch?.flushIntervalMs ?? 5e3;
|
|
162
190
|
const maxBatchSize = opts?.batch?.maxBatchSize ?? 25;
|
|
191
|
+
const maxBufferSize = 1e3;
|
|
192
|
+
let warnedBufferCap = false;
|
|
163
193
|
if (!batchEnabled) {
|
|
164
194
|
let disposed2 = false;
|
|
165
195
|
return {
|
|
166
196
|
track: (event) => {
|
|
167
197
|
if (disposed2) return;
|
|
168
|
-
|
|
198
|
+
if (sink) invokeTrackingSink(sink, event);
|
|
169
199
|
},
|
|
170
200
|
dispose: () => {
|
|
171
201
|
disposed2 = true;
|
|
@@ -224,6 +254,15 @@ function createTrackingClient(opts) {
|
|
|
224
254
|
return {
|
|
225
255
|
track: (event) => {
|
|
226
256
|
if (disposed || disposing) return;
|
|
257
|
+
if (buffer.length >= maxBufferSize) {
|
|
258
|
+
buffer.shift();
|
|
259
|
+
if (!warnedBufferCap && typeof process !== "undefined" && process.env?.NODE_ENV === "development") {
|
|
260
|
+
warnedBufferCap = true;
|
|
261
|
+
console.warn(
|
|
262
|
+
`[lessonkit] telemetry batch buffer capped at ${maxBufferSize} events; oldest events are dropped while the sink is unavailable.`
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
227
266
|
buffer.push(event);
|
|
228
267
|
if (buffer.length >= maxBatchSize) void flush();
|
|
229
268
|
},
|
|
@@ -255,16 +294,461 @@ function nowIso() {
|
|
|
255
294
|
return (/* @__PURE__ */ new Date()).toISOString();
|
|
256
295
|
}
|
|
257
296
|
|
|
258
|
-
// src/
|
|
259
|
-
|
|
260
|
-
|
|
297
|
+
// src/telemetryBuilder.ts
|
|
298
|
+
var warnedMissingQuizLesson = false;
|
|
299
|
+
function isDevEnvironment2() {
|
|
300
|
+
const g = globalThis;
|
|
301
|
+
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
302
|
+
}
|
|
303
|
+
function resetTelemetryBuilderWarningsForTests() {
|
|
304
|
+
warnedMissingQuizLesson = false;
|
|
305
|
+
}
|
|
306
|
+
function buildTelemetryEvent(opts) {
|
|
307
|
+
const base = {
|
|
308
|
+
timestamp: opts.timestamp ?? nowIso(),
|
|
309
|
+
courseId: opts.courseId,
|
|
310
|
+
sessionId: opts.sessionId,
|
|
311
|
+
attemptId: opts.attemptId,
|
|
312
|
+
user: opts.user
|
|
313
|
+
};
|
|
314
|
+
switch (opts.name) {
|
|
315
|
+
case "course_started":
|
|
316
|
+
return { name: "course_started", ...base };
|
|
317
|
+
case "course_completed":
|
|
318
|
+
return { name: "course_completed", ...base };
|
|
319
|
+
case "lesson_started": {
|
|
320
|
+
const data = opts.data;
|
|
321
|
+
const lessonId = opts.lessonId ?? data?.lessonId;
|
|
322
|
+
if (!lessonId) throw new Error("lesson_started requires lessonId");
|
|
323
|
+
return {
|
|
324
|
+
name: "lesson_started",
|
|
325
|
+
...base,
|
|
326
|
+
lessonId,
|
|
327
|
+
data: { ...data, lessonId }
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
case "lesson_completed":
|
|
331
|
+
case "lesson_time_on_task": {
|
|
332
|
+
const data = opts.data;
|
|
333
|
+
const lessonId = opts.lessonId ?? data?.lessonId;
|
|
334
|
+
if (!lessonId) throw new Error(`${opts.name} requires lessonId`);
|
|
335
|
+
return {
|
|
336
|
+
name: opts.name,
|
|
337
|
+
...base,
|
|
338
|
+
lessonId,
|
|
339
|
+
data: { ...data, lessonId }
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
case "quiz_answered": {
|
|
343
|
+
const data = opts.data;
|
|
344
|
+
const lessonId = opts.lessonId;
|
|
345
|
+
if (!lessonId) throw new Error("quiz_answered requires active lessonId");
|
|
346
|
+
return { name: "quiz_answered", ...base, lessonId, data };
|
|
347
|
+
}
|
|
348
|
+
case "quiz_completed": {
|
|
349
|
+
const data = opts.data;
|
|
350
|
+
const lessonId = opts.lessonId;
|
|
351
|
+
if (!lessonId) throw new Error("quiz_completed requires active lessonId");
|
|
352
|
+
return { name: "quiz_completed", ...base, lessonId, data };
|
|
353
|
+
}
|
|
354
|
+
case "interaction":
|
|
355
|
+
return {
|
|
356
|
+
name: "interaction",
|
|
357
|
+
...base,
|
|
358
|
+
lessonId: opts.lessonId,
|
|
359
|
+
data: opts.data
|
|
360
|
+
};
|
|
361
|
+
default:
|
|
362
|
+
return { name: opts.name, ...base };
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
function tryBuildTelemetryEvent(opts) {
|
|
366
|
+
const isQuiz = opts.name === "quiz_answered" || opts.name === "quiz_completed";
|
|
367
|
+
if (isQuiz && !opts.lessonId) {
|
|
368
|
+
if (isDevEnvironment2() && !warnedMissingQuizLesson) {
|
|
369
|
+
warnedMissingQuizLesson = true;
|
|
370
|
+
console.warn(
|
|
371
|
+
`[lessonkit] ${opts.name} skipped: wrap <Quiz> in <Lesson> so an active lessonId is available`
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
return buildTelemetryEvent(opts);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// src/telemetryPipeline.ts
|
|
380
|
+
function isDevEnvironment3() {
|
|
381
|
+
const g = globalThis;
|
|
382
|
+
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
383
|
+
}
|
|
384
|
+
function warnSinkFailure(sinkId, err) {
|
|
385
|
+
if (isDevEnvironment3()) {
|
|
386
|
+
console.warn(
|
|
387
|
+
`[lessonkit] telemetry sink "${sinkId}" failed:`,
|
|
388
|
+
err instanceof Error ? err.message : err
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
function invokeSink(sink, event, emitCtx) {
|
|
393
|
+
let result;
|
|
394
|
+
try {
|
|
395
|
+
result = sink.emit(event, emitCtx);
|
|
396
|
+
} catch (err) {
|
|
397
|
+
warnSinkFailure(sink.id, err);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (result != null && typeof result.catch === "function") {
|
|
401
|
+
void result.catch((err) => warnSinkFailure(sink.id, err));
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
function createTelemetryPipeline(sinks) {
|
|
405
|
+
const list = [...sinks];
|
|
406
|
+
return {
|
|
407
|
+
sinks: list,
|
|
408
|
+
emit(event, ctx) {
|
|
409
|
+
const emitCtx = ctx ?? {
|
|
410
|
+
courseId: event.courseId,
|
|
411
|
+
sessionId: event.sessionId,
|
|
412
|
+
attemptId: event.attemptId
|
|
413
|
+
};
|
|
414
|
+
for (const sink of list) {
|
|
415
|
+
invokeSink(sink, event, emitCtx);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
function createTrackingPipelineSink(id, track) {
|
|
421
|
+
return {
|
|
422
|
+
id,
|
|
423
|
+
emit(event) {
|
|
424
|
+
track(event);
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// src/ports.ts
|
|
430
|
+
function createDefaultClock() {
|
|
431
|
+
return {
|
|
432
|
+
nowMs: () => Date.now(),
|
|
433
|
+
nowIso: () => (/* @__PURE__ */ new Date()).toISOString()
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
function createNoopStorage() {
|
|
437
|
+
return {
|
|
438
|
+
getItem: () => null,
|
|
439
|
+
setItem: () => {
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
function createMemoryBackedSessionStorage(session) {
|
|
444
|
+
const memory = /* @__PURE__ */ new Map();
|
|
445
|
+
let warnedPersistFailure = false;
|
|
446
|
+
const warnPersistFailure = () => {
|
|
447
|
+
if (warnedPersistFailure) return;
|
|
448
|
+
warnedPersistFailure = true;
|
|
449
|
+
if (typeof process !== "undefined" && process.env?.NODE_ENV === "development") {
|
|
450
|
+
console.warn(
|
|
451
|
+
"[lessonkit] sessionStorage is unavailable or failed; using in-memory session dedupe for this tab (may reset on full reload)."
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
return {
|
|
456
|
+
getItem: (key) => {
|
|
457
|
+
if (memory.has(key)) return memory.get(key);
|
|
458
|
+
try {
|
|
459
|
+
const value = session.getItem(key);
|
|
460
|
+
if (value !== null) memory.set(key, value);
|
|
461
|
+
return value;
|
|
462
|
+
} catch {
|
|
463
|
+
return memory.get(key) ?? null;
|
|
464
|
+
}
|
|
465
|
+
},
|
|
466
|
+
setItem: (key, value) => {
|
|
467
|
+
memory.set(key, value);
|
|
468
|
+
try {
|
|
469
|
+
session.setItem(key, value);
|
|
470
|
+
} catch {
|
|
471
|
+
warnPersistFailure();
|
|
472
|
+
}
|
|
473
|
+
},
|
|
474
|
+
removeItem: (key) => {
|
|
475
|
+
memory.delete(key);
|
|
476
|
+
try {
|
|
477
|
+
session.removeItem(key);
|
|
478
|
+
} catch {
|
|
479
|
+
warnPersistFailure();
|
|
480
|
+
}
|
|
481
|
+
},
|
|
482
|
+
resetForTests: () => {
|
|
483
|
+
memory.clear();
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
function resetStoragePortForTests(storage) {
|
|
488
|
+
storage.resetForTests?.();
|
|
489
|
+
}
|
|
490
|
+
function createSessionStoragePort() {
|
|
491
|
+
if (typeof sessionStorage === "undefined") {
|
|
492
|
+
const memory = /* @__PURE__ */ new Map();
|
|
493
|
+
return {
|
|
494
|
+
getItem: (key) => memory.get(key) ?? null,
|
|
495
|
+
setItem: (key, value) => {
|
|
496
|
+
memory.set(key, value);
|
|
497
|
+
},
|
|
498
|
+
removeItem: (key) => {
|
|
499
|
+
memory.delete(key);
|
|
500
|
+
},
|
|
501
|
+
resetForTests: () => {
|
|
502
|
+
memory.clear();
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
return createMemoryBackedSessionStorage(sessionStorage);
|
|
507
|
+
}
|
|
508
|
+
function createGlobalTimer() {
|
|
509
|
+
return {
|
|
510
|
+
setInterval: (fn, ms) => globalThis.setInterval(fn, ms),
|
|
511
|
+
clearInterval: (id) => globalThis.clearInterval(id)
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// src/progress.ts
|
|
516
|
+
function createProgressController() {
|
|
517
|
+
let activeLessonId;
|
|
518
|
+
let completedLessonIds = /* @__PURE__ */ new Set();
|
|
519
|
+
let courseCompleted = false;
|
|
520
|
+
const lessonStartTimes = /* @__PURE__ */ new Map();
|
|
521
|
+
return {
|
|
522
|
+
getState: () => ({
|
|
523
|
+
activeLessonId,
|
|
524
|
+
completedLessonIds: new Set(completedLessonIds),
|
|
525
|
+
courseCompleted
|
|
526
|
+
}),
|
|
527
|
+
setActiveLesson: (lessonId, startedAtMs) => {
|
|
528
|
+
const previousLessonId = activeLessonId;
|
|
529
|
+
activeLessonId = lessonId;
|
|
530
|
+
lessonStartTimes.set(lessonId, startedAtMs);
|
|
531
|
+
return { previousLessonId };
|
|
532
|
+
},
|
|
533
|
+
completeLesson: (lessonId, completedAtMs) => {
|
|
534
|
+
if (completedLessonIds.has(lessonId)) return { didComplete: false };
|
|
535
|
+
completedLessonIds = new Set(completedLessonIds).add(lessonId);
|
|
536
|
+
if (activeLessonId === lessonId) {
|
|
537
|
+
activeLessonId = void 0;
|
|
538
|
+
}
|
|
539
|
+
const startedAt = lessonStartTimes.get(lessonId);
|
|
540
|
+
lessonStartTimes.delete(lessonId);
|
|
541
|
+
const durationMs = typeof startedAt === "number" ? Math.max(0, completedAtMs - startedAt) : void 0;
|
|
542
|
+
return { durationMs, didComplete: true };
|
|
543
|
+
},
|
|
544
|
+
completeCourse: () => {
|
|
545
|
+
if (courseCompleted) return { didComplete: false };
|
|
546
|
+
courseCompleted = true;
|
|
547
|
+
return { didComplete: true };
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// src/session.ts
|
|
553
|
+
var SESSION_STORAGE_KEY = "lessonkit:sessionId";
|
|
554
|
+
function getTabSessionId(storage) {
|
|
555
|
+
return storage.getItem(SESSION_STORAGE_KEY);
|
|
556
|
+
}
|
|
557
|
+
var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
|
|
558
|
+
var COURSE_STARTED_TRACKING_PREFIX = "lessonkit:course_started_tracking:";
|
|
559
|
+
function resolveSessionId(storage, provided) {
|
|
560
|
+
if (provided) return provided;
|
|
561
|
+
const existing = storage.getItem(SESSION_STORAGE_KEY);
|
|
562
|
+
if (existing) return existing;
|
|
563
|
+
const id = createSessionId();
|
|
564
|
+
storage.setItem(SESSION_STORAGE_KEY, id);
|
|
565
|
+
return id;
|
|
566
|
+
}
|
|
567
|
+
function courseStartedStorageKey(sessionId, courseId) {
|
|
568
|
+
return `${COURSE_STARTED_PREFIX}${sessionId}:${courseId ?? ""}`;
|
|
261
569
|
}
|
|
570
|
+
function courseStartedTrackingStorageKey(sessionId, courseId) {
|
|
571
|
+
return `${COURSE_STARTED_TRACKING_PREFIX}${sessionId}:${courseId ?? ""}`;
|
|
572
|
+
}
|
|
573
|
+
function hasCourseStarted(storage, sessionId, courseId) {
|
|
574
|
+
if (!courseId) return false;
|
|
575
|
+
return storage.getItem(courseStartedStorageKey(sessionId, courseId)) === "1";
|
|
576
|
+
}
|
|
577
|
+
function markCourseStarted(storage, sessionId, courseId) {
|
|
578
|
+
if (!courseId) return;
|
|
579
|
+
storage.setItem(courseStartedStorageKey(sessionId, courseId), "1");
|
|
580
|
+
}
|
|
581
|
+
function hasCourseStartedEmittedToTracking(storage, sessionId, courseId) {
|
|
582
|
+
if (!courseId) return false;
|
|
583
|
+
return storage.getItem(courseStartedTrackingStorageKey(sessionId, courseId)) === "1";
|
|
584
|
+
}
|
|
585
|
+
function markCourseStartedEmittedToTracking(storage, sessionId, courseId) {
|
|
586
|
+
if (!courseId) return;
|
|
587
|
+
storage.setItem(courseStartedTrackingStorageKey(sessionId, courseId), "1");
|
|
588
|
+
}
|
|
589
|
+
function migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId) {
|
|
590
|
+
if (!courseId || fromSessionId === toSessionId) return;
|
|
591
|
+
if (hasCourseStarted(storage, fromSessionId, courseId)) {
|
|
592
|
+
markCourseStarted(storage, toSessionId, courseId);
|
|
593
|
+
storage.removeItem?.(courseStartedStorageKey(fromSessionId, courseId));
|
|
594
|
+
}
|
|
595
|
+
if (hasCourseStartedEmittedToTracking(storage, fromSessionId, courseId)) {
|
|
596
|
+
markCourseStartedEmittedToTracking(storage, toSessionId, courseId);
|
|
597
|
+
storage.removeItem?.(courseStartedTrackingStorageKey(fromSessionId, courseId));
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// src/runtime/courseLifecycle.ts
|
|
602
|
+
function tryEmitCourseStarted(ctx, deps, alreadyEmittedToSink) {
|
|
603
|
+
const marked = hasCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
|
|
604
|
+
if (alreadyEmittedToSink) {
|
|
605
|
+
return { emitted: true, marked };
|
|
606
|
+
}
|
|
607
|
+
if (marked) {
|
|
608
|
+
return { emitted: false, marked: true };
|
|
609
|
+
}
|
|
610
|
+
const emitted = deps.emitCourseStartedEvent(ctx);
|
|
611
|
+
if (emitted) {
|
|
612
|
+
markCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
|
|
613
|
+
}
|
|
614
|
+
return { emitted, marked: emitted };
|
|
615
|
+
}
|
|
616
|
+
function buildCourseStartedTelemetryEvent(ctx) {
|
|
617
|
+
return buildTelemetryEvent({
|
|
618
|
+
name: "course_started",
|
|
619
|
+
courseId: ctx.courseId,
|
|
620
|
+
sessionId: ctx.sessionId,
|
|
621
|
+
attemptId: ctx.attemptId,
|
|
622
|
+
user: ctx.user
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
function completeLessonWithTelemetry(opts) {
|
|
626
|
+
const result = opts.progress.completeLesson(opts.lessonId, opts.nowMs);
|
|
627
|
+
if (!result.didComplete) return false;
|
|
628
|
+
opts.emitLessonCompleted(opts.lessonId, result.durationMs);
|
|
629
|
+
return true;
|
|
630
|
+
}
|
|
631
|
+
function completeCourseWithTelemetry(opts) {
|
|
632
|
+
const current = opts.progress.getState();
|
|
633
|
+
if (current.activeLessonId) {
|
|
634
|
+
completeLessonWithTelemetry({
|
|
635
|
+
progress: opts.progress,
|
|
636
|
+
lessonId: current.activeLessonId,
|
|
637
|
+
nowMs: opts.nowMs,
|
|
638
|
+
emitLessonCompleted: opts.emitLessonCompleted
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
const result = opts.progress.completeCourse();
|
|
642
|
+
if (!result.didComplete) return false;
|
|
643
|
+
opts.emitCourseCompleted();
|
|
644
|
+
return true;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// src/runtime/createLessonkitRuntime.ts
|
|
648
|
+
function createLessonkitRuntime(config, ports = {}) {
|
|
649
|
+
const storage = ports.storage ?? createSessionStoragePort();
|
|
650
|
+
const clock = ports.clock ?? createDefaultClock();
|
|
651
|
+
const configSnapshot = { ...config };
|
|
652
|
+
let sessionId = resolveSessionId(storage, configSnapshot.session?.sessionId);
|
|
653
|
+
let attemptId = configSnapshot.session?.attemptId;
|
|
654
|
+
let user = configSnapshot.session?.user;
|
|
655
|
+
let courseId = configSnapshot.courseId;
|
|
656
|
+
let progress = createProgressController();
|
|
657
|
+
const getSession = () => ({ sessionId, attemptId, user });
|
|
658
|
+
const syncSessionFromConfig = (next) => {
|
|
659
|
+
sessionId = resolveSessionId(storage, next.session?.sessionId);
|
|
660
|
+
attemptId = next.session?.attemptId;
|
|
661
|
+
user = next.session?.user;
|
|
662
|
+
courseId = next.courseId;
|
|
663
|
+
};
|
|
664
|
+
syncSessionFromConfig(configSnapshot);
|
|
665
|
+
const track = (name, data, emit, lessonId) => {
|
|
666
|
+
const event = tryBuildTelemetryEvent({
|
|
667
|
+
name,
|
|
668
|
+
courseId,
|
|
669
|
+
lessonId: lessonId ?? progress.getState().activeLessonId,
|
|
670
|
+
sessionId,
|
|
671
|
+
attemptId,
|
|
672
|
+
user,
|
|
673
|
+
data
|
|
674
|
+
});
|
|
675
|
+
if (!event) return;
|
|
676
|
+
emit(event);
|
|
677
|
+
};
|
|
678
|
+
const emitLessonCompleted = (lessonId, durationMs, emitFn) => {
|
|
679
|
+
emitFn("lesson_completed", { lessonId, durationMs }, lessonId);
|
|
680
|
+
if (durationMs !== void 0) {
|
|
681
|
+
emitFn("lesson_time_on_task", { lessonId, durationMs }, lessonId);
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
return {
|
|
685
|
+
get config() {
|
|
686
|
+
return configSnapshot;
|
|
687
|
+
},
|
|
688
|
+
get progress() {
|
|
689
|
+
return progress;
|
|
690
|
+
},
|
|
691
|
+
getProgressState: () => progress.getState(),
|
|
692
|
+
getSession,
|
|
693
|
+
updateConfig(next) {
|
|
694
|
+
if (next.courseId !== void 0) configSnapshot.courseId = next.courseId;
|
|
695
|
+
if (next.runtimeVersion !== void 0) configSnapshot.runtimeVersion = next.runtimeVersion;
|
|
696
|
+
if (next.plugins !== void 0) configSnapshot.plugins = next.plugins;
|
|
697
|
+
if (next.session !== void 0) {
|
|
698
|
+
configSnapshot.session = { ...configSnapshot.session, ...next.session };
|
|
699
|
+
}
|
|
700
|
+
syncSessionFromConfig(configSnapshot);
|
|
701
|
+
},
|
|
702
|
+
setActiveLesson(lessonId, emitFn) {
|
|
703
|
+
const current = progress.getState();
|
|
704
|
+
if (current.activeLessonId === lessonId) return;
|
|
705
|
+
if (current.completedLessonIds.has(lessonId)) {
|
|
706
|
+
progress.setActiveLesson(lessonId, clock.nowMs());
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
const previous = current.activeLessonId;
|
|
710
|
+
if (previous && previous !== lessonId) {
|
|
711
|
+
const completed = progress.completeLesson(previous, clock.nowMs());
|
|
712
|
+
if (completed.didComplete) {
|
|
713
|
+
emitLessonCompleted(previous, completed.durationMs, emitFn);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
progress.setActiveLesson(lessonId, clock.nowMs());
|
|
717
|
+
emitFn("lesson_started", { lessonId }, lessonId);
|
|
718
|
+
},
|
|
719
|
+
completeLesson(lessonId, emitFn) {
|
|
720
|
+
const result = progress.completeLesson(lessonId, clock.nowMs());
|
|
721
|
+
if (!result.didComplete) return;
|
|
722
|
+
emitLessonCompleted(lessonId, result.durationMs, emitFn);
|
|
723
|
+
},
|
|
724
|
+
completeCourse(emitFn) {
|
|
725
|
+
const current = progress.getState();
|
|
726
|
+
if (current.activeLessonId) {
|
|
727
|
+
const lessonResult = progress.completeLesson(current.activeLessonId, clock.nowMs());
|
|
728
|
+
if (lessonResult.didComplete) {
|
|
729
|
+
emitLessonCompleted(current.activeLessonId, lessonResult.durationMs, emitFn);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
const result = progress.completeCourse();
|
|
733
|
+
if (!result.didComplete) return;
|
|
734
|
+
emitFn("course_completed");
|
|
735
|
+
},
|
|
736
|
+
track,
|
|
737
|
+
resetForCourseChange(nextCourseId) {
|
|
738
|
+
configSnapshot.courseId = nextCourseId;
|
|
739
|
+
courseId = nextCourseId;
|
|
740
|
+
progress = createProgressController();
|
|
741
|
+
}
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// src/plugins/registry.ts
|
|
262
746
|
function warnDuplicatePlugin(id) {
|
|
263
747
|
const g = globalThis;
|
|
264
748
|
if (typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production") return;
|
|
265
749
|
console.warn(`[lessonkit] plugin id "${id}" was registered more than once; using the latest definition`);
|
|
266
750
|
}
|
|
267
|
-
function
|
|
751
|
+
function createPluginRegistry(plugins = []) {
|
|
268
752
|
const registry = /* @__PURE__ */ new Map();
|
|
269
753
|
for (const plugin of plugins) {
|
|
270
754
|
if (registry.has(plugin.id)) warnDuplicatePlugin(plugin.id);
|
|
@@ -302,11 +786,26 @@ function createPluginHost(plugins = []) {
|
|
|
302
786
|
}
|
|
303
787
|
return events;
|
|
304
788
|
};
|
|
305
|
-
const composeTrackingSink = (sink,
|
|
789
|
+
const composeTrackingSink = (sink, ctxSource) => {
|
|
790
|
+
if (!sink) return void 0;
|
|
791
|
+
const resolveCtx = () => typeof ctxSource === "function" ? ctxSource() : ctxSource;
|
|
792
|
+
const ctxKey = (ctx) => `${ctx.courseId}\0${ctx.sessionId ?? ""}\0${ctx.attemptId ?? ""}`;
|
|
793
|
+
const layers = [];
|
|
306
794
|
let composed = sink;
|
|
307
795
|
for (const plugin of list) {
|
|
308
|
-
if (!plugin.wrapTrackingSink
|
|
309
|
-
|
|
796
|
+
if (!plugin.wrapTrackingSink) continue;
|
|
797
|
+
const inner = composed;
|
|
798
|
+
const layer = { plugin, inner, wrapped: null, lastCtxKey: "" };
|
|
799
|
+
layers.push(layer);
|
|
800
|
+
composed = (event) => {
|
|
801
|
+
const ctx = resolveCtx();
|
|
802
|
+
const key = ctxKey(ctx);
|
|
803
|
+
if (!layer.wrapped || layer.lastCtxKey !== key) {
|
|
804
|
+
layer.wrapped = layer.plugin.wrapTrackingSink(layer.inner, ctx) ?? layer.inner;
|
|
805
|
+
layer.lastCtxKey = key;
|
|
806
|
+
}
|
|
807
|
+
return layer.wrapped(event);
|
|
808
|
+
};
|
|
310
809
|
}
|
|
311
810
|
return composed;
|
|
312
811
|
};
|
|
@@ -329,20 +828,57 @@ function createPluginHost(plugins = []) {
|
|
|
329
828
|
scoreAssessment
|
|
330
829
|
};
|
|
331
830
|
}
|
|
831
|
+
|
|
832
|
+
// src/plugins/define.ts
|
|
833
|
+
function defineTelemetryPlugin(plugin) {
|
|
834
|
+
return plugin;
|
|
835
|
+
}
|
|
836
|
+
function defineAssessmentPlugin(plugin) {
|
|
837
|
+
return plugin;
|
|
838
|
+
}
|
|
839
|
+
function defineLifecyclePlugin(plugin) {
|
|
840
|
+
return plugin;
|
|
841
|
+
}
|
|
332
842
|
export {
|
|
333
843
|
ID_MAX_LENGTH,
|
|
334
844
|
ID_PATTERN,
|
|
845
|
+
SESSION_STORAGE_KEY,
|
|
335
846
|
TELEMETRY_EVENT_CATALOG,
|
|
336
847
|
assertValidId,
|
|
848
|
+
buildCourseStartedTelemetryEvent,
|
|
337
849
|
buildLessonkitUrn,
|
|
338
850
|
buildTelemetryCatalog,
|
|
339
|
-
|
|
851
|
+
buildTelemetryEvent,
|
|
852
|
+
completeCourseWithTelemetry,
|
|
853
|
+
completeLessonWithTelemetry,
|
|
854
|
+
createDefaultClock,
|
|
855
|
+
createGlobalTimer,
|
|
856
|
+
createLessonkitRuntime,
|
|
857
|
+
createNoopStorage,
|
|
858
|
+
createPluginRegistry,
|
|
859
|
+
createProgressController,
|
|
340
860
|
createSessionId,
|
|
861
|
+
createSessionStoragePort,
|
|
862
|
+
createTelemetryPipeline,
|
|
341
863
|
createTrackingClient,
|
|
342
|
-
|
|
864
|
+
createTrackingPipelineSink,
|
|
865
|
+
defineAssessmentPlugin,
|
|
866
|
+
defineLifecyclePlugin,
|
|
867
|
+
defineTelemetryPlugin,
|
|
343
868
|
deriveId,
|
|
869
|
+
getTabSessionId,
|
|
870
|
+
hasCourseStarted,
|
|
871
|
+
hasCourseStartedEmittedToTracking,
|
|
872
|
+
markCourseStarted,
|
|
873
|
+
markCourseStartedEmittedToTracking,
|
|
874
|
+
migrateCourseStartedMark,
|
|
344
875
|
nowIso,
|
|
876
|
+
resetStoragePortForTests,
|
|
877
|
+
resetTelemetryBuilderWarningsForTests,
|
|
878
|
+
resolveSessionId,
|
|
345
879
|
slugifyId,
|
|
346
880
|
telemetryCatalogVersion,
|
|
881
|
+
tryBuildTelemetryEvent,
|
|
882
|
+
tryEmitCourseStarted,
|
|
347
883
|
validateId
|
|
348
884
|
};
|