@lessonkit/core 1.4.0 → 1.5.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,75 @@
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
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
64
+ }
65
+ throw new Error(
66
+ "[lessonkit] createSessionId requires crypto.randomUUID or crypto.getRandomValues"
67
+ );
68
+ }
2
69
  function createSessionId() {
3
70
  const g = globalThis;
4
71
  if (g.crypto?.randomUUID) return g.crypto.randomUUID();
5
- return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 11)}`;
72
+ return randomSessionIdFallback();
6
73
  }
7
74
 
8
75
  // src/time.ts
@@ -291,6 +358,34 @@ var TELEMETRY_EVENT_REGISTRY = {
291
358
  data: opts.data
292
359
  };
293
360
  }
361
+ },
362
+ branch_node_viewed: {
363
+ requiresLessonId: true,
364
+ build: (opts, base) => {
365
+ if (opts.name !== "branch_node_viewed") throw new Error("unexpected event");
366
+ const lessonId = opts.lessonId;
367
+ if (!lessonId) throw new Error("branch_node_viewed requires active lessonId");
368
+ return {
369
+ name: "branch_node_viewed",
370
+ ...base,
371
+ lessonId,
372
+ data: opts.data
373
+ };
374
+ }
375
+ },
376
+ branch_selected: {
377
+ requiresLessonId: true,
378
+ build: (opts, base) => {
379
+ if (opts.name !== "branch_selected") throw new Error("unexpected event");
380
+ const lessonId = opts.lessonId;
381
+ if (!lessonId) throw new Error("branch_selected requires active lessonId");
382
+ return {
383
+ name: "branch_selected",
384
+ ...base,
385
+ lessonId,
386
+ data: opts.data
387
+ };
388
+ }
294
389
  }
295
390
  };
296
391
  function buildTelemetryEventFromRegistry(opts) {
@@ -323,8 +418,8 @@ function buildTelemetryEvent(opts) {
323
418
  }
324
419
  function tryBuildTelemetryEvent(opts) {
325
420
  const entry = getTelemetryEventRegistryEntry(opts.name);
326
- if (entry.requiresLessonId && !opts.lessonId && entry.tryBuildMissingLessonWarning) {
327
- if (isDevEnvironment()) {
421
+ if (entry.requiresLessonId && !opts.lessonId) {
422
+ if (isDevEnvironment() && entry.tryBuildMissingLessonWarning) {
328
423
  if (entry.tryBuildMissingLessonWarning === "quiz" && !warnedMissingQuizLesson) {
329
424
  warnedMissingQuizLesson = true;
330
425
  console.warn(
@@ -351,14 +446,44 @@ function createDefaultClock() {
351
446
  };
352
447
  }
353
448
  function createNoopStorage() {
449
+ const memory = /* @__PURE__ */ new Map();
354
450
  return {
355
- getItem: () => null,
356
- setItem: () => true
451
+ getItem: (key) => memory.get(key) ?? null,
452
+ setItem: (key, value) => {
453
+ memory.set(key, value);
454
+ return true;
455
+ },
456
+ removeItem: (key) => {
457
+ memory.delete(key);
458
+ },
459
+ resetForTests: () => {
460
+ memory.clear();
461
+ }
357
462
  };
358
463
  }
359
464
  function createMemoryBackedSessionStorage(session) {
360
465
  const memory = /* @__PURE__ */ new Map();
466
+ const tombstones = /* @__PURE__ */ new Set();
361
467
  let warnedPersistFailure = false;
468
+ const syncFromStorageEvent = (key, newValue) => {
469
+ if (key === null) {
470
+ memory.clear();
471
+ tombstones.clear();
472
+ return;
473
+ }
474
+ tombstones.delete(key);
475
+ if (newValue === null) {
476
+ memory.delete(key);
477
+ } else {
478
+ memory.set(key, newValue);
479
+ }
480
+ };
481
+ if (typeof window !== "undefined") {
482
+ window.addEventListener("storage", (event) => {
483
+ if (event.storageArea !== sessionStorage) return;
484
+ syncFromStorageEvent(event.key, event.newValue);
485
+ });
486
+ }
362
487
  const warnPersistFailure = () => {
363
488
  if (warnedPersistFailure) return;
364
489
  warnedPersistFailure = true;
@@ -371,6 +496,7 @@ function createMemoryBackedSessionStorage(session) {
371
496
  };
372
497
  return {
373
498
  getItem: (key) => {
499
+ if (tombstones.has(key)) return null;
374
500
  if (memory.has(key)) return memory.get(key);
375
501
  try {
376
502
  const value = session.getItem(key);
@@ -381,6 +507,7 @@ function createMemoryBackedSessionStorage(session) {
381
507
  }
382
508
  },
383
509
  setItem: (key, value) => {
510
+ tombstones.delete(key);
384
511
  memory.set(key, value);
385
512
  try {
386
513
  session.setItem(key, value);
@@ -394,12 +521,15 @@ function createMemoryBackedSessionStorage(session) {
394
521
  memory.delete(key);
395
522
  try {
396
523
  session.removeItem(key);
524
+ tombstones.delete(key);
397
525
  } catch {
398
526
  warnPersistFailure();
527
+ tombstones.add(key);
399
528
  }
400
529
  },
401
530
  resetForTests: () => {
402
531
  memory.clear();
532
+ tombstones.clear();
403
533
  }
404
534
  };
405
535
  }
@@ -449,7 +579,6 @@ function createGlobalTimer() {
449
579
  // src/session.ts
450
580
  var SESSION_STORAGE_KEY = "lessonkit:sessionId";
451
581
  var volatileSessionIds = /* @__PURE__ */ new WeakMap();
452
- var sharedVolatileSessionId = null;
453
582
  function isDevEnvironment2() {
454
583
  const g = globalThis;
455
584
  return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
@@ -460,10 +589,23 @@ function getTabSessionId(storage) {
460
589
  var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
461
590
  var COURSE_STARTED_TRACKING_PREFIX = "lessonkit:course_started_tracking:";
462
591
  var COURSE_STARTED_PIPELINE_PREFIX = "lessonkit:course_started_pipeline:";
592
+ var COURSE_STARTED_XAPI_PREFIX = "lessonkit:course_started_xapi:";
593
+ function sessionKeySegment(sessionId) {
594
+ const validated = validateId(sessionId);
595
+ return validated.ok ? validated.id : encodeURIComponent(sessionId);
596
+ }
463
597
  function resolveSessionId(storage, provided) {
464
598
  if (provided !== void 0) {
465
599
  const trimmed = provided.trim();
466
- if (trimmed.length > 0) return trimmed;
600
+ if (trimmed.length > 0) {
601
+ const validated = validateId(trimmed);
602
+ if (validated.ok) return validated.id;
603
+ if (isDevEnvironment2()) {
604
+ console.warn(
605
+ `[lessonkit] Invalid sessionId "${trimmed}"; falling back to tab or generated id.`
606
+ );
607
+ }
608
+ }
467
609
  }
468
610
  const existing = storage.getItem(SESSION_STORAGE_KEY);
469
611
  if (existing) return existing;
@@ -472,27 +614,27 @@ function resolveSessionId(storage, provided) {
472
614
  const id = createSessionId();
473
615
  const persisted = storage.setItem(SESSION_STORAGE_KEY, id);
474
616
  if (!persisted) {
475
- if (!sharedVolatileSessionId) {
476
- sharedVolatileSessionId = id;
477
- }
478
- volatileSessionIds.set(storage, sharedVolatileSessionId);
617
+ volatileSessionIds.set(storage, id);
479
618
  if (isDevEnvironment2()) {
480
619
  console.warn(
481
- "[lessonkit] session id could not be persisted; reusing in-memory id for this tab."
620
+ "[lessonkit] session id could not be persisted; using in-memory id for this storage."
482
621
  );
483
622
  }
484
- return sharedVolatileSessionId;
623
+ return id;
485
624
  }
486
625
  return id;
487
626
  }
488
627
  function courseStartedStorageKey(sessionId, courseId) {
489
- return `${COURSE_STARTED_PREFIX}${sessionId}:${courseId ?? ""}`;
628
+ return `${COURSE_STARTED_PREFIX}${sessionKeySegment(sessionId)}:${courseId ?? ""}`;
490
629
  }
491
630
  function courseStartedTrackingStorageKey(sessionId, courseId) {
492
- return `${COURSE_STARTED_TRACKING_PREFIX}${sessionId}:${courseId ?? ""}`;
631
+ return `${COURSE_STARTED_TRACKING_PREFIX}${sessionKeySegment(sessionId)}:${courseId ?? ""}`;
493
632
  }
494
633
  function courseStartedPipelineStorageKey(sessionId, courseId) {
495
- return `${COURSE_STARTED_PIPELINE_PREFIX}${sessionId}:${courseId ?? ""}`;
634
+ return `${COURSE_STARTED_PIPELINE_PREFIX}${sessionKeySegment(sessionId)}:${courseId ?? ""}`;
635
+ }
636
+ function courseStartedXapiStorageKey(sessionId, courseId) {
637
+ return `${COURSE_STARTED_XAPI_PREFIX}${sessionKeySegment(sessionId)}:${courseId ?? ""}`;
496
638
  }
497
639
  function hasCourseStarted(storage, sessionId, courseId) {
498
640
  if (!courseId) return false;
@@ -518,27 +660,52 @@ function markCourseStartedPipelineDelivered(storage, sessionId, courseId) {
518
660
  if (!courseId) return false;
519
661
  return storage.setItem(courseStartedPipelineStorageKey(sessionId, courseId), "1");
520
662
  }
663
+ function hasCourseStartedXapiSent(storage, sessionId, courseId) {
664
+ if (!courseId) return false;
665
+ return storage.getItem(courseStartedXapiStorageKey(sessionId, courseId)) === "1";
666
+ }
667
+ function markCourseStartedXapiSent(storage, sessionId, courseId) {
668
+ if (!courseId) return false;
669
+ return storage.setItem(courseStartedXapiStorageKey(sessionId, courseId), "1");
670
+ }
521
671
  function resetSharedVolatileSessionIdForTests() {
522
- sharedVolatileSessionId = null;
672
+ }
673
+ function migrateStorageMark(storage, fromKey, toKey, hasMark) {
674
+ if (!hasMark) return;
675
+ if (storage.setItem(toKey, "1")) {
676
+ storage.removeItem?.(fromKey);
677
+ }
523
678
  }
524
679
  function migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId) {
525
680
  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
- }
681
+ migrateStorageMark(
682
+ storage,
683
+ courseStartedStorageKey(fromSessionId, courseId),
684
+ courseStartedStorageKey(toSessionId, courseId),
685
+ hasCourseStarted(storage, fromSessionId, courseId)
686
+ );
687
+ migrateStorageMark(
688
+ storage,
689
+ courseStartedTrackingStorageKey(fromSessionId, courseId),
690
+ courseStartedTrackingStorageKey(toSessionId, courseId),
691
+ hasCourseStartedEmittedToTracking(storage, fromSessionId, courseId)
692
+ );
693
+ migrateStorageMark(
694
+ storage,
695
+ courseStartedPipelineStorageKey(fromSessionId, courseId),
696
+ courseStartedPipelineStorageKey(toSessionId, courseId),
697
+ hasCourseStartedPipelineDelivered(storage, fromSessionId, courseId)
698
+ );
699
+ migrateStorageMark(
700
+ storage,
701
+ courseStartedXapiStorageKey(fromSessionId, courseId),
702
+ courseStartedXapiStorageKey(toSessionId, courseId),
703
+ hasCourseStartedXapiSent(storage, fromSessionId, courseId)
704
+ );
538
705
  }
539
706
 
540
707
  // src/runtime/courseLifecycle.ts
541
- var courseStartedEmitFlights = /* @__PURE__ */ new Set();
708
+ var courseStartedEmitFlights = /* @__PURE__ */ new Map();
542
709
  function resetCourseStartedEmitFlightForTests() {
543
710
  courseStartedEmitFlights.clear();
544
711
  }
@@ -546,24 +713,32 @@ function tryEmitCourseStarted(ctx, deps, alreadyEmittedToSink) {
546
713
  const flightKey = `${ctx.sessionId}:${ctx.courseId}`;
547
714
  const marked = hasCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
548
715
  if (alreadyEmittedToSink) {
549
- return { emitted: true, marked };
716
+ return Promise.resolve({ emitted: true, marked });
550
717
  }
551
- if (courseStartedEmitFlights.has(flightKey)) {
552
- return { emitted: false, marked };
718
+ const existing = courseStartedEmitFlights.get(flightKey);
719
+ if (existing) {
720
+ return existing;
553
721
  }
554
- courseStartedEmitFlights.add(flightKey);
555
- try {
556
- const emitted = deps.emitCourseStartedEvent(ctx);
557
- if (emitted && !marked) {
558
- markCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
722
+ const flight = Promise.resolve().then(() => {
723
+ try {
724
+ const emitted = deps.emitCourseStartedEvent(ctx);
725
+ if (emitted && !marked) {
726
+ markCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
727
+ }
728
+ return {
729
+ emitted,
730
+ marked: hasCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId)
731
+ };
732
+ } catch {
733
+ return { emitted: false, marked };
734
+ } finally {
735
+ if (courseStartedEmitFlights.get(flightKey) === flight) {
736
+ courseStartedEmitFlights.delete(flightKey);
737
+ }
559
738
  }
560
- return {
561
- emitted,
562
- marked: hasCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId)
563
- };
564
- } finally {
565
- courseStartedEmitFlights.delete(flightKey);
566
- }
739
+ });
740
+ courseStartedEmitFlights.set(flightKey, flight);
741
+ return flight;
567
742
  }
568
743
  function buildCourseStartedTelemetryEvent(ctx) {
569
744
  return buildTelemetryEvent({
@@ -597,6 +772,14 @@ function completeCourseWithTelemetry(opts) {
597
772
  }
598
773
 
599
774
  export {
775
+ ID_PATTERN,
776
+ ID_MAX_LENGTH,
777
+ validateId,
778
+ parseCourseId,
779
+ parseLessonId,
780
+ parseCheckId,
781
+ parseBlockId,
782
+ assertValidId,
600
783
  isDevEnvironment,
601
784
  warnDev,
602
785
  createSessionId,
@@ -618,6 +801,8 @@ export {
618
801
  markCourseStartedEmittedToTracking,
619
802
  hasCourseStartedPipelineDelivered,
620
803
  markCourseStartedPipelineDelivered,
804
+ hasCourseStartedXapiSent,
805
+ markCourseStartedXapiSent,
621
806
  resetSharedVolatileSessionIdForTests,
622
807
  migrateCourseStartedMark,
623
808
  resetCourseStartedEmitFlightForTests,