@lessonkit/react 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.
@@ -52,103 +52,14 @@ function AssessmentLessonGuard(props) {
52
52
  return /* @__PURE__ */ jsx(Fragment, { children: props.children(enclosingLessonId) });
53
53
  }
54
54
 
55
- // src/runtime/productionGuard.ts
56
- function isProductionEnvironment() {
57
- try {
58
- if (import.meta.env?.PROD === true) return true;
59
- } catch {
60
- }
61
- const g = globalThis;
62
- return typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production";
63
- }
64
- function shouldEnforceProductionGuard() {
65
- try {
66
- if (import.meta.env?.MODE === "test") return false;
67
- } catch {
68
- }
69
- return isProductionEnvironment();
70
- }
71
- function looksLikeConsoleSink(fn) {
72
- if (typeof fn !== "function") return false;
73
- const src = Function.prototype.toString.call(fn);
74
- return /console\.(log|debug|info)\s*\(/.test(src);
75
- }
76
- function isTrackingDeliveryConfigured(tracking) {
77
- if (!tracking || tracking.enabled === false) return false;
78
- return Boolean(tracking.sink || tracking.batchSink);
79
- }
80
- function isXapiDeliveryConfigured(xapi) {
81
- if (!xapi || xapi.enabled === false) return false;
82
- if (xapi.client) return true;
83
- return typeof xapi.transport === "function";
84
- }
85
- function trackingUsesConsole(config) {
86
- const tracking = config.tracking;
87
- if (!tracking || tracking.enabled === false) return false;
88
- if (tracking.batchSink && looksLikeConsoleSink(tracking.batchSink)) return true;
89
- if (tracking.sink && looksLikeConsoleSink(tracking.sink)) return true;
90
- return false;
91
- }
92
- function xapiUsesConsole(config) {
93
- const xapi = config.xapi;
94
- if (!xapi || xapi.enabled === false || xapi.client) return false;
95
- return typeof xapi.transport === "function" && looksLikeConsoleSink(xapi.transport);
96
- }
97
- function observabilityIncomplete(observability, opts) {
98
- if (!opts.trackingEnabled && !opts.xapiEnabled) return false;
99
- const required = [observability?.onLxpackBridgeMiss];
100
- if (opts.trackingEnabled) {
101
- required.push(observability?.onTelemetrySinkError, observability?.onTelemetryBufferDrop);
102
- }
103
- if (opts.xapiEnabled) {
104
- required.push(
105
- observability?.onXapiQueueDepth,
106
- observability?.onXapiQueueCap,
107
- observability?.onXapiTransportError
108
- );
109
- }
110
- return required.some((hook) => !hook);
111
- }
112
- function requiredObservabilityHookCount(opts) {
113
- let count = 1;
114
- if (opts.trackingEnabled) count += 2;
115
- if (opts.xapiEnabled) count += 3;
116
- return count;
117
- }
118
- function assertProductionCourseConfig(config) {
119
- if (!isProductionEnvironment()) return;
120
- if (config.tracking && config.tracking.enabled !== false && !isTrackingDeliveryConfigured(config.tracking)) {
121
- throw new Error(
122
- "[lessonkit] Production build has tracking enabled but no sink or batchSink configured."
123
- );
124
- }
125
- const trackingEnabled = isTrackingDeliveryConfigured(config.tracking);
126
- const xapiEnabled = isXapiDeliveryConfigured(config.xapi);
127
- if (trackingUsesConsole(config)) {
128
- throw new Error(
129
- "[lessonkit] Production build uses console telemetry sinks. Wire createFetchBatchSink or a real sink. See production checklist."
130
- );
131
- }
132
- if (xapiUsesConsole(config)) {
133
- throw new Error(
134
- "[lessonkit] Production build uses console xAPI transport. Wire createFetchTransport to your LRS proxy. See production checklist."
135
- );
136
- }
137
- if (observabilityIncomplete(config.observability, { trackingEnabled, xapiEnabled })) {
138
- const hookCount = requiredObservabilityHookCount({ trackingEnabled, xapiEnabled });
139
- throw new Error(
140
- `[lessonkit] Production build missing observability hooks. Wire all ${hookCount} config.observability callbacks before go-live.`
141
- );
142
- }
143
- }
144
-
145
55
  // src/runtime/emitTelemetry.ts
146
56
  import { buildTelemetryEvent, tryBuildTelemetryEvent } from "@lessonkit/core";
147
57
 
148
58
  // src/runtime/telemetryPipeline.ts
149
59
  import {
150
60
  createTelemetryPipeline,
151
- createTrackingPipelineSink
61
+ createTrackingPipelineSink,
62
+ isLifecycleTelemetryEvent
152
63
  } from "@lessonkit/core";
153
64
  import { telemetryEventToXAPIStatement } from "@lessonkit/xapi";
154
65
 
@@ -161,16 +72,22 @@ import {
161
72
  telemetryEventToLessonkit
162
73
  } from "@lessonkit/lxpack/bridge";
163
74
  var BRIDGE_MISS_EVENT_NAMES = /* @__PURE__ */ new Set([
75
+ "course_started",
164
76
  "course_completed",
165
77
  "lesson_completed",
78
+ "assessment_answered",
166
79
  "assessment_completed",
167
80
  "quiz_completed"
168
81
  ]);
169
82
  function forwardTelemetryToLxpack(event, mode = "auto", opts) {
170
- if (mode === "auto" && opts?.onBridgeMiss && BRIDGE_MISS_EVENT_NAMES.has(event.name) && !getLxpackBridge()) {
83
+ const bridgeOpts = { allowedParentOrigins: opts?.allowedParentOrigins, mode };
84
+ if (mode === "auto" && opts?.onBridgeMiss && BRIDGE_MISS_EVENT_NAMES.has(event.name) && !getLxpackBridge(void 0, bridgeOpts)) {
171
85
  opts.onBridgeMiss(event);
172
86
  }
173
- forwardTelemetryToBridge(event, mode);
87
+ forwardTelemetryToBridge(event, mode, void 0, {
88
+ allowedParentOrigins: opts?.allowedParentOrigins,
89
+ onBridgeError: opts?.onBridgeError
90
+ });
174
91
  }
175
92
 
176
93
  // src/runtime/telemetryPipeline.ts
@@ -183,17 +100,34 @@ function createLegacyPipeline(opts, extraSinks = []) {
183
100
  createTrackingPipelineSink("tracking", (event) => opts.tracking.track(event)),
184
101
  {
185
102
  id: "xapi",
186
- emit(event) {
103
+ async emit(event) {
104
+ let statement;
187
105
  try {
188
- const statement = telemetryEventToXAPIStatement(event);
189
- if (statement) opts.xapi?.send(statement);
106
+ statement = telemetryEventToXAPIStatement(event);
190
107
  } catch (err) {
108
+ opts.onXapiMappingError?.(err);
191
109
  if (isDevEnvironment2()) {
192
110
  console.warn(
193
111
  "[lessonkit] xAPI mapping skipped:",
194
112
  err instanceof Error ? err.message : err
195
113
  );
196
114
  }
115
+ return;
116
+ }
117
+ if (!statement || !opts.xapi) return;
118
+ try {
119
+ opts.xapi.send(statement);
120
+ if (isLifecycleTelemetryEvent(event.name)) {
121
+ await opts.xapi.flush();
122
+ }
123
+ } catch (err) {
124
+ opts.onXapiTransportError?.(err);
125
+ if (isDevEnvironment2()) {
126
+ console.warn(
127
+ "[lessonkit] xAPI delivery failed:",
128
+ err instanceof Error ? err.message : err
129
+ );
130
+ }
197
131
  }
198
132
  }
199
133
  },
@@ -201,7 +135,9 @@ function createLegacyPipeline(opts, extraSinks = []) {
201
135
  id: "lxpack-bridge",
202
136
  emit(event) {
203
137
  forwardTelemetryToLxpack(event, opts.lxpackBridge, {
204
- onBridgeMiss: opts.onLxpackBridgeMiss
138
+ onBridgeMiss: opts.onLxpackBridgeMiss,
139
+ onBridgeError: opts.onLxpackBridgeError,
140
+ allowedParentOrigins: opts.allowedParentOrigins
205
141
  });
206
142
  }
207
143
  },
@@ -209,7 +145,7 @@ function createLegacyPipeline(opts, extraSinks = []) {
209
145
  ]);
210
146
  }
211
147
  function emitThroughPipeline(event, opts, extraSinks) {
212
- createLegacyPipeline(opts, extraSinks).emit(event);
148
+ return createLegacyPipeline(opts, extraSinks).emit(event);
213
149
  }
214
150
 
215
151
  // src/runtime/emitTelemetry.ts
@@ -230,9 +166,13 @@ function emitTelemetry(tracking, xapi, event, opts) {
230
166
  tracking,
231
167
  xapi,
232
168
  lxpackBridge: opts?.lxpackBridge ?? "auto",
233
- onLxpackBridgeMiss: opts?.onLxpackBridgeMiss
169
+ allowedParentOrigins: opts?.allowedParentOrigins,
170
+ onLxpackBridgeMiss: opts?.onLxpackBridgeMiss,
171
+ onLxpackBridgeError: opts?.onLxpackBridgeError,
172
+ onXapiMappingError: opts?.onXapiMappingError,
173
+ onXapiTransportError: opts?.onXapiTransportError
234
174
  };
235
- emitThroughPipeline(event, legacy, opts?.extraSinks);
175
+ return emitThroughPipeline(event, legacy, opts?.extraSinks);
236
176
  }
237
177
 
238
178
  // src/runtime/courseStartedPipeline.ts
@@ -273,16 +213,34 @@ async function emitExtraSinks(sinks, event, emitCtx) {
273
213
  async function emitCourseStartedNonTrackingPipeline(opts) {
274
214
  let xapiStatementSent = false;
275
215
  if (!opts.skipXapi && opts.xapi) {
276
- const statement = telemetryEventToXAPIStatement2(opts.event);
216
+ let statement;
217
+ try {
218
+ statement = telemetryEventToXAPIStatement2(opts.event);
219
+ } catch (err) {
220
+ opts.onXapiMappingError?.(err);
221
+ if (isDevEnvironment4()) {
222
+ console.warn(
223
+ "[lessonkit] course_started xAPI mapping skipped:",
224
+ err instanceof Error ? err.message : err
225
+ );
226
+ }
227
+ statement = null;
228
+ }
277
229
  if (statement) {
278
230
  opts.xapi.send(statement);
279
231
  await opts.xapi.flush();
280
232
  xapiStatementSent = true;
233
+ opts.onXapiDelivered?.();
281
234
  }
282
235
  }
283
236
  forwardTelemetryToLxpack(opts.event, opts.lxpackBridge, {
284
- onBridgeMiss: opts.onLxpackBridgeMiss
237
+ onBridgeMiss: opts.onLxpackBridgeMiss,
238
+ onBridgeError: opts.onLxpackBridgeError,
239
+ allowedParentOrigins: opts.allowedParentOrigins
285
240
  });
241
+ if (opts.onBeforeExtraSinks) {
242
+ await opts.onBeforeExtraSinks();
243
+ }
286
244
  const emitCtx = {
287
245
  courseId: opts.event.courseId,
288
246
  sessionId: opts.event.sessionId,
@@ -306,8 +264,12 @@ function emitTelemetryWithPlugins(opts) {
306
264
  if (next === null) return;
307
265
  emitTelemetry(opts.tracking, opts.xapi, next, {
308
266
  lxpackBridge: opts.lxpackBridge ?? "auto",
267
+ allowedParentOrigins: opts.allowedParentOrigins,
309
268
  extraSinks: opts.extraSinks,
310
- onLxpackBridgeMiss: opts.onLxpackBridgeMiss
269
+ onLxpackBridgeMiss: opts.onLxpackBridgeMiss,
270
+ onLxpackBridgeError: opts.onLxpackBridgeError,
271
+ onXapiMappingError: opts.onXapiMappingError,
272
+ onXapiTransportError: opts.onXapiTransportError
311
273
  });
312
274
  }
313
275
 
@@ -322,34 +284,48 @@ import {
322
284
  markCourseStartedEmittedToTracking,
323
285
  hasCourseStartedPipelineDelivered,
324
286
  markCourseStartedPipelineDelivered,
287
+ hasCourseStartedXapiSent,
288
+ markCourseStartedXapiSent,
325
289
  migrateCourseStartedMark
326
290
  } from "@lessonkit/core";
327
291
 
328
292
  // src/provider/courseStarted/emit.ts
293
+ function createCourseStartedFlightScope() {
294
+ return {
295
+ trackingFlights: /* @__PURE__ */ new Map(),
296
+ emitFlights: /* @__PURE__ */ new Map()
297
+ };
298
+ }
329
299
  function resolveTrackingClient(source) {
330
300
  return typeof source === "function" ? source() : source;
331
301
  }
332
- var courseStartedTrackingFlights = /* @__PURE__ */ new Map();
333
- var courseStartedEmitFlights = /* @__PURE__ */ new Map();
302
+ var defaultFlightScope = createCourseStartedFlightScope();
334
303
  function resetCourseStartedTrackingFlightForTests() {
335
- courseStartedTrackingFlights.clear();
336
- courseStartedEmitFlights.clear();
304
+ defaultFlightScope.trackingFlights.clear();
305
+ defaultFlightScope.emitFlights.clear();
306
+ }
307
+ function resetCourseStartedTrackingFlights(scope) {
308
+ const target = scope ?? defaultFlightScope;
309
+ target.trackingFlights.clear();
310
+ target.emitFlights.clear();
311
+ }
312
+ function resolveFlightScope(scope) {
313
+ return scope ?? defaultFlightScope;
337
314
  }
338
315
  function isTrackingActive(tracking) {
339
316
  return tracking?.enabled !== false;
340
317
  }
341
318
  function isCourseStartedSinkSettled(result) {
342
- return result === "emitted" || result === "filtered";
319
+ return result === "emitted";
343
320
  }
344
321
  async function deliverToTracking(client, event) {
345
322
  if (client.deliver) {
346
323
  return client.deliver(event);
347
324
  }
348
325
  client.track(event);
349
- const flushed = await client.flush?.();
350
- if (flushed === false) return false;
351
- if (flushed === true) return true;
352
- return false;
326
+ if (!client.flush) return true;
327
+ const flushed = await client.flush();
328
+ return flushed !== false;
353
329
  }
354
330
  function buildCourseStartedEvent(opts) {
355
331
  const pluginCtx = buildPluginContext({
@@ -365,14 +341,19 @@ function buildCourseStartedEvent(opts) {
365
341
  attemptId: opts.attemptId,
366
342
  user: opts.user
367
343
  });
368
- return opts.pluginHost ? opts.pluginHost.runTelemetry(built, pluginCtx) : built;
344
+ const withId = {
345
+ ...built,
346
+ id: `${opts.sessionId}:${opts.courseId}:course_started`
347
+ };
348
+ return opts.pluginHost ? opts.pluginHost.runTelemetry(withId, pluginCtx) : withId;
369
349
  }
370
- async function emitCourseStartedToTracking(tracking, storage, sessionId, courseId, event, shouldCommit) {
350
+ async function emitCourseStartedToTracking(tracking, storage, sessionId, courseId, event, shouldCommit, flightScope) {
351
+ const scope = resolveFlightScope(flightScope);
371
352
  const flightKey = `${sessionId}:${courseId}`;
372
353
  if (hasCourseStartedEmittedToTracking(storage, sessionId, courseId)) {
373
354
  return true;
374
355
  }
375
- const existing = courseStartedTrackingFlights.get(flightKey);
356
+ const existing = scope.trackingFlights.get(flightKey);
376
357
  if (existing) {
377
358
  return existing;
378
359
  }
@@ -380,7 +361,7 @@ async function emitCourseStartedToTracking(tracking, storage, sessionId, courseI
380
361
  const flight = new Promise((resolve) => {
381
362
  resolveFlight = resolve;
382
363
  });
383
- courseStartedTrackingFlights.set(flightKey, flight);
364
+ scope.trackingFlights.set(flightKey, flight);
384
365
  void (async () => {
385
366
  try {
386
367
  if (shouldCommit && !shouldCommit()) {
@@ -398,6 +379,10 @@ async function emitCourseStartedToTracking(tracking, storage, sessionId, courseI
398
379
  return;
399
380
  }
400
381
  if (markCourseStartedEmittedToTracking(storage, sessionId, courseId) === false) {
382
+ if (hasCourseStartedEmittedToTracking(storage, sessionId, courseId)) {
383
+ resolveFlight(true);
384
+ return;
385
+ }
401
386
  resolveFlight(false);
402
387
  return;
403
388
  }
@@ -405,30 +390,50 @@ async function emitCourseStartedToTracking(tracking, storage, sessionId, courseI
405
390
  } catch {
406
391
  resolveFlight(false);
407
392
  } finally {
408
- if (courseStartedTrackingFlights.get(flightKey) === flight) {
409
- courseStartedTrackingFlights.delete(flightKey);
393
+ if (scope.trackingFlights.get(flightKey) === flight) {
394
+ scope.trackingFlights.delete(flightKey);
410
395
  }
411
396
  }
412
397
  })();
413
398
  return flight;
414
399
  }
400
+ function resolveSkipXapi(storage, sessionId, courseId, skipXapi) {
401
+ return Boolean(skipXapi || hasCourseStartedXapiSent(storage, sessionId, courseId));
402
+ }
415
403
  async function emitCourseStartedPipelineOnly(opts) {
416
404
  try {
417
405
  if (opts.shouldCommit && !opts.shouldCommit()) return "failed";
406
+ const skipXapi = resolveSkipXapi(opts.storage, opts.sessionId, opts.courseId, opts.skipXapi);
418
407
  const { xapiStatementSent } = await emitCourseStartedNonTrackingPipeline({
419
408
  event: opts.event,
420
409
  xapi: opts.xapi,
421
410
  lxpackBridge: opts.lxpackBridge,
411
+ allowedParentOrigins: opts.allowedParentOrigins,
422
412
  onLxpackBridgeMiss: opts.onLxpackBridgeMiss,
413
+ onLxpackBridgeError: opts.onLxpackBridgeError,
414
+ onXapiMappingError: opts.onXapiMappingError,
423
415
  extraSinks: opts.extraSinks,
424
- skipXapi: opts.skipXapi
416
+ skipXapi,
417
+ onXapiDelivered: () => {
418
+ markCourseStartedXapiSent(opts.storage, opts.sessionId, opts.courseId);
419
+ opts.onXapiStatementSent?.();
420
+ },
421
+ onBeforeExtraSinks: async () => {
422
+ if (opts.shouldCommit && !opts.shouldCommit()) throw new Error("course_started commit aborted");
423
+ if (markCourseStarted(opts.storage, opts.sessionId, opts.courseId) === false && !hasCourseStarted(opts.storage, opts.sessionId, opts.courseId)) {
424
+ throw new Error("course_started mark failed");
425
+ }
426
+ }
425
427
  });
426
428
  if (opts.shouldCommit && !opts.shouldCommit()) return "failed";
427
- if (markCourseStarted(opts.storage, opts.sessionId, opts.courseId) === false) return "failed";
428
- if (markCourseStartedPipelineDelivered(opts.storage, opts.sessionId, opts.courseId) === false) {
429
+ if (markCourseStarted(opts.storage, opts.sessionId, opts.courseId) === false && !hasCourseStarted(opts.storage, opts.sessionId, opts.courseId)) {
430
+ return "failed";
431
+ }
432
+ if (markCourseStartedPipelineDelivered(opts.storage, opts.sessionId, opts.courseId) === false && !hasCourseStartedPipelineDelivered(opts.storage, opts.sessionId, opts.courseId)) {
429
433
  return "failed";
430
434
  }
431
- if (xapiStatementSent) {
435
+ if (xapiStatementSent && !hasCourseStartedXapiSent(opts.storage, opts.sessionId, opts.courseId)) {
436
+ markCourseStartedXapiSent(opts.storage, opts.sessionId, opts.courseId);
432
437
  opts.onXapiStatementSent?.();
433
438
  }
434
439
  return "emitted";
@@ -445,7 +450,8 @@ async function emitCourseStarted(opts) {
445
450
  opts.sessionId,
446
451
  opts.courseId,
447
452
  event,
448
- opts.shouldCommit
453
+ opts.shouldCommit,
454
+ opts.flightScope
449
455
  );
450
456
  if (!tracked) return "failed";
451
457
  return emitCourseStartedPipelineOnly({
@@ -465,7 +471,8 @@ async function emitCourseStartedToTrackingOnly(opts) {
465
471
  opts.sessionId,
466
472
  opts.courseId,
467
473
  event,
468
- opts.shouldCommit
474
+ opts.shouldCommit,
475
+ opts.flightScope
469
476
  );
470
477
  if (!tracked) return "failed";
471
478
  try {
@@ -474,7 +481,10 @@ async function emitCourseStartedToTrackingOnly(opts) {
474
481
  event,
475
482
  xapi: null,
476
483
  lxpackBridge: opts.lxpackBridge,
484
+ allowedParentOrigins: opts.allowedParentOrigins,
477
485
  onLxpackBridgeMiss: opts.onLxpackBridgeMiss,
486
+ onLxpackBridgeError: opts.onLxpackBridgeError,
487
+ onXapiMappingError: opts.onXapiMappingError,
478
488
  extraSinks: opts.extraSinks,
479
489
  skipXapi: true
480
490
  });
@@ -487,10 +497,11 @@ async function emitCourseStartedToTrackingOnly(opts) {
487
497
  }
488
498
  }
489
499
  async function emitPendingCourseStarted(opts) {
500
+ const scope = resolveFlightScope(opts.flightScope);
490
501
  const flightKey = `${opts.sessionId}:${opts.courseId}`;
491
502
  for (let attempt = 0; attempt < 2; attempt += 1) {
492
- const existing = courseStartedEmitFlights.get(flightKey);
493
- const flight = existing ?? startPendingCourseStartedFlight(opts, flightKey);
503
+ const existing = scope.emitFlights.get(flightKey);
504
+ const flight = existing ?? startPendingCourseStartedFlight(opts, flightKey, scope);
494
505
  const result = await flight;
495
506
  if (result !== "failed") return result;
496
507
  const sessionStarted = hasCourseStarted(opts.storage, opts.sessionId, opts.courseId);
@@ -511,12 +522,12 @@ async function emitPendingCourseStarted(opts) {
511
522
  }
512
523
  return "failed";
513
524
  }
514
- function startPendingCourseStartedFlight(opts, flightKey) {
525
+ function startPendingCourseStartedFlight(opts, flightKey, scope) {
515
526
  const flight = emitPendingCourseStartedInner(opts);
516
- courseStartedEmitFlights.set(flightKey, flight);
527
+ scope.emitFlights.set(flightKey, flight);
517
528
  void flight.finally(() => {
518
- if (courseStartedEmitFlights.get(flightKey) === flight) {
519
- courseStartedEmitFlights.delete(flightKey);
529
+ if (scope.emitFlights.get(flightKey) === flight) {
530
+ scope.emitFlights.delete(flightKey);
520
531
  }
521
532
  });
522
533
  return flight;
@@ -536,16 +547,17 @@ async function emitPendingCourseStartedInner(opts) {
536
547
  if (sessionStarted && trackingEmitted && pipelineDelivered) {
537
548
  return "emitted";
538
549
  }
550
+ const skipXapi = resolveSkipXapi(opts.storage, opts.sessionId, opts.courseId, opts.skipXapi);
539
551
  if (sessionStarted && !trackingEmitted) {
540
552
  return emitCourseStartedToTrackingOnly(opts);
541
553
  }
542
554
  if (trackingEmitted && !sessionStarted) {
543
555
  const event = buildCourseStartedEvent(opts);
544
556
  if (event === null) return "filtered";
545
- return emitCourseStartedPipelineOnly({ ...opts, event });
557
+ return emitCourseStartedPipelineOnly({ ...opts, event, skipXapi });
546
558
  }
547
559
  if (!trackingEmitted && !sessionStarted) {
548
- return emitCourseStarted(opts);
560
+ return emitCourseStarted({ ...opts, skipXapi });
549
561
  }
550
562
  if (sessionStarted && trackingEmitted && !pipelineDelivered) {
551
563
  const event = buildCourseStartedEvent(opts);
@@ -553,7 +565,7 @@ async function emitPendingCourseStartedInner(opts) {
553
565
  return emitCourseStartedPipelineOnly({
554
566
  ...opts,
555
567
  event,
556
- skipXapi: opts.skipXapi,
568
+ skipXapi,
557
569
  onXapiStatementSent: opts.onXapiStatementSent
558
570
  });
559
571
  }
@@ -566,6 +578,124 @@ function assertTrackingSinkConfig(tracking) {
566
578
  );
567
579
  }
568
580
 
581
+ // src/runtime/productionGuard.ts
582
+ function isProductionEnvironment() {
583
+ try {
584
+ if (import.meta.env?.PROD === true) return true;
585
+ } catch {
586
+ }
587
+ const g = globalThis;
588
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production";
589
+ }
590
+ function shouldEnforceProductionGuard() {
591
+ try {
592
+ if (import.meta.env?.MODE === "test") return false;
593
+ } catch {
594
+ }
595
+ return isProductionEnvironment();
596
+ }
597
+ function looksLikeConsoleSink(fn) {
598
+ if (typeof fn !== "function") return false;
599
+ const src = Function.prototype.toString.call(fn);
600
+ return /console\.(log|debug|info)\s*\(/.test(src);
601
+ }
602
+ function isTrackingDeliveryConfigured(tracking) {
603
+ if (!tracking || tracking.enabled === false) return false;
604
+ return Boolean(tracking.sink || tracking.batchSink || tracking.createClient);
605
+ }
606
+ function isXapiDeliveryConfigured(xapi) {
607
+ if (!xapi || xapi.enabled === false) return false;
608
+ if (xapi.client) return true;
609
+ return typeof xapi.transport === "function";
610
+ }
611
+ function trackingUsesConsole(config) {
612
+ const tracking = config.tracking;
613
+ if (!tracking || tracking.enabled === false) return false;
614
+ if (tracking.consoleSink === true) return true;
615
+ if (tracking.batchSink && looksLikeConsoleSink(tracking.batchSink)) return true;
616
+ if (tracking.sink && looksLikeConsoleSink(tracking.sink)) return true;
617
+ return false;
618
+ }
619
+ function xapiUsesConsole(config) {
620
+ const xapi = config.xapi;
621
+ if (!xapi || xapi.enabled === false || xapi.client) return false;
622
+ if (xapi.consoleTransport === true) return true;
623
+ return typeof xapi.transport === "function" && looksLikeConsoleSink(xapi.transport);
624
+ }
625
+ function observabilityIncomplete(observability, opts) {
626
+ if (!opts.trackingEnabled && !opts.xapiEnabled) return false;
627
+ const required = [observability?.onLxpackBridgeMiss];
628
+ if (opts.trackingEnabled) {
629
+ required.push(observability?.onTelemetrySinkError, observability?.onTelemetryBufferDrop);
630
+ }
631
+ if (opts.xapiEnabled) {
632
+ required.push(
633
+ observability?.onXapiQueueDepth,
634
+ observability?.onXapiQueueCap,
635
+ observability?.onXapiTransportError,
636
+ observability?.onXapiMappingError
637
+ );
638
+ }
639
+ return required.some((hook) => !hook);
640
+ }
641
+ function requiredObservabilityHookCount(opts) {
642
+ let count = 1;
643
+ if (opts.trackingEnabled) count += 2;
644
+ if (opts.xapiEnabled) count += 4;
645
+ return count;
646
+ }
647
+ function warnConsoleSinkHeuristic(config) {
648
+ if (!isDevEnvironment()) return;
649
+ if (config.preview?.allowConsoleTelemetry) return;
650
+ if (looksLikeConsoleSink(config.tracking?.sink) || looksLikeConsoleSink(config.tracking?.batchSink)) {
651
+ console.warn(
652
+ "[lessonkit] Telemetry sink looks like console.log; use preview.allowConsoleTelemetry for docs or wire a real sink."
653
+ );
654
+ }
655
+ if (looksLikeConsoleSink(config.xapi?.transport)) {
656
+ console.warn(
657
+ "[lessonkit] xAPI transport looks like console.log; use preview.allowConsoleTelemetry for docs or wire createFetchTransport."
658
+ );
659
+ }
660
+ }
661
+ function assertProductionCourseConfig(config) {
662
+ if (!isProductionEnvironment()) {
663
+ warnConsoleSinkHeuristic(config);
664
+ return;
665
+ }
666
+ assertTrackingSinkConfig(config.tracking);
667
+ const trackingImplicitlyEnabled = config.tracking === void 0 || config.tracking.enabled !== false;
668
+ if (trackingImplicitlyEnabled && !isTrackingDeliveryConfigured(config.tracking)) {
669
+ throw new Error(
670
+ "[lessonkit] Production build has tracking enabled but no sink or batchSink configured."
671
+ );
672
+ }
673
+ if (config.xapi?.enabled === true && !isXapiDeliveryConfigured(config.xapi)) {
674
+ throw new Error(
675
+ "[lessonkit] Production build has xAPI enabled but no transport or client configured."
676
+ );
677
+ }
678
+ const allowConsole = config.preview?.allowConsoleTelemetry === true;
679
+ const trackingEnabled = isTrackingDeliveryConfigured(config.tracking);
680
+ const xapiEnabled = isXapiDeliveryConfigured(config.xapi);
681
+ if (!allowConsole && trackingUsesConsole(config)) {
682
+ throw new Error(
683
+ "[lessonkit] Production build uses console telemetry sinks. Wire createFetchBatchSink or a real sink. See production checklist."
684
+ );
685
+ }
686
+ if (!allowConsole && xapiUsesConsole(config)) {
687
+ throw new Error(
688
+ "[lessonkit] Production build uses console xAPI transport. Wire createFetchTransport to your LRS proxy. See production checklist."
689
+ );
690
+ }
691
+ if (observabilityIncomplete(config.observability, { trackingEnabled, xapiEnabled })) {
692
+ const hookCount = requiredObservabilityHookCount({ trackingEnabled, xapiEnabled });
693
+ throw new Error(
694
+ `[lessonkit] Production build missing observability hooks. Wire all ${hookCount} config.observability callbacks before go-live.`
695
+ );
696
+ }
697
+ }
698
+
569
699
  // src/provider/useLessonkitProviderRuntime.ts
570
700
  import {
571
701
  useCallback,
@@ -598,6 +728,39 @@ function wrapBatchSink(batchSink, observability) {
598
728
  }
599
729
  };
600
730
  }
731
+ function warnMissingProductionObservability(observability, opts) {
732
+ let isProduction = false;
733
+ try {
734
+ const env = import.meta.env;
735
+ if (env?.MODE === "test") return;
736
+ isProduction = env?.PROD === true;
737
+ } catch {
738
+ }
739
+ if (!isProduction) {
740
+ const g = globalThis;
741
+ isProduction = typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production";
742
+ }
743
+ if (!isProduction) return;
744
+ if (!opts.trackingEnabled && !opts.xapiEnabled) return;
745
+ const required = [observability?.onLxpackBridgeMiss];
746
+ if (opts.trackingEnabled) {
747
+ required.push(observability?.onTelemetrySinkError, observability?.onTelemetryBufferDrop);
748
+ }
749
+ if (opts.xapiEnabled) {
750
+ required.push(
751
+ observability?.onXapiQueueDepth,
752
+ observability?.onXapiQueueCap,
753
+ observability?.onXapiTransportError,
754
+ observability?.onXapiMappingError
755
+ );
756
+ }
757
+ if (!required.some((hook) => !hook)) return;
758
+ if (typeof console !== "undefined") {
759
+ console.warn(
760
+ "[lessonkit] Production deployment without observability hooks \u2014 telemetry/xAPI failures and buffer drops will be silent. See https://lessonkit.readthedocs.io/en/latest/guides/react-developers/production-checklist.html"
761
+ );
762
+ }
763
+ }
601
764
  function wrapTrackingSink(sink, observability) {
602
765
  if (!sink || !observability?.onTelemetrySinkError) return sink;
603
766
  const onError = observability.onTelemetrySinkError;
@@ -650,7 +813,8 @@ function createXapiClientFromConfig(config, queue, observability) {
650
813
  exitTransport: config.xapi?.exitTransport,
651
814
  abortInFlight: config.xapi?.abortInFlight,
652
815
  queue,
653
- onTransportError: observability?.onXapiTransportError
816
+ onTransportError: observability?.onXapiTransportError,
817
+ onMappingError: observability?.onXapiMappingError
654
818
  });
655
819
  }
656
820
 
@@ -683,9 +847,12 @@ var useIsoLayoutEffect = (
683
847
  /* v8 ignore next -- SSR uses useEffect when window is unavailable */
684
848
  typeof window !== "undefined" ? useLayoutEffect : useEffect2
685
849
  );
686
- var defaultStorage = createSessionStoragePort();
850
+ var providerStoragesForTests = /* @__PURE__ */ new Set();
687
851
  function resetLessonkitProviderStorageForTests() {
688
- resetStoragePortForTests(defaultStorage);
852
+ for (const storage of providerStoragesForTests) {
853
+ resetStoragePortForTests(storage);
854
+ }
855
+ providerStoragesForTests.clear();
689
856
  resetSharedVolatileSessionIdForTests();
690
857
  }
691
858
  function useLessonkitProviderRuntime(config) {
@@ -699,6 +866,11 @@ function useLessonkitProviderRuntime(config) {
699
866
  );
700
867
  if (shouldEnforceProductionGuard()) {
701
868
  assertProductionCourseConfig(normalizedConfig);
869
+ } else {
870
+ warnMissingProductionObservability(normalizedConfig.observability, {
871
+ trackingEnabled: isTrackingDeliveryConfigured(normalizedConfig.tracking),
872
+ xapiEnabled: isXapiDeliveryConfigured(normalizedConfig.xapi)
873
+ });
702
874
  }
703
875
  const useV2Runtime = normalizedConfig.runtimeVersion !== "v1";
704
876
  useEffect2(() => {
@@ -712,13 +884,17 @@ function useLessonkitProviderRuntime(config) {
712
884
  const extraSinksRef = useRef(normalizedConfig.sinks);
713
885
  extraSinksRef.current = normalizedConfig.sinks;
714
886
  const headlessRef = useRef(null);
715
- const sessionIdRef = useRef(resolveSessionId(defaultStorage, normalizedConfig.session?.sessionId));
716
- const prevConfiguredSessionIdRef = useRef(normalizedConfig.session?.sessionId);
717
- if (normalizedConfig.session?.sessionId) {
718
- sessionIdRef.current = normalizedConfig.session.sessionId;
719
- } else if (prevConfiguredSessionIdRef.current) {
720
- sessionIdRef.current = resolveSessionId(defaultStorage, void 0);
887
+ const providerStorageRef = useRef(null);
888
+ if (!providerStorageRef.current) {
889
+ providerStorageRef.current = normalizedConfig.storage ?? createSessionStoragePort();
890
+ providerStoragesForTests.add(providerStorageRef.current);
721
891
  }
892
+ const providerStorage = providerStorageRef.current;
893
+ const sessionIdRef = useRef(
894
+ resolveSessionId(providerStorage, normalizedConfig.session?.sessionId)
895
+ );
896
+ const [sessionId, setSessionId] = useState(() => sessionIdRef.current);
897
+ const prevConfiguredSessionIdRef = useRef(normalizedConfig.session?.sessionId);
722
898
  const attemptIdRef = useRef(normalizedConfig.session?.attemptId);
723
899
  const userRef = useRef(normalizedConfig.session?.user);
724
900
  attemptIdRef.current = normalizedConfig.session?.attemptId;
@@ -727,11 +903,16 @@ function useLessonkitProviderRuntime(config) {
727
903
  courseIdRef.current = normalizedCourseId;
728
904
  const lxpackBridgeModeRef = useRef(normalizedConfig.lxpack?.bridge ?? "auto");
729
905
  lxpackBridgeModeRef.current = normalizedConfig.lxpack?.bridge ?? "auto";
906
+ const allowedParentOriginsRef = useRef(normalizedConfig.lxpack?.allowedParentOrigins);
907
+ allowedParentOriginsRef.current = normalizedConfig.lxpack?.allowedParentOrigins;
730
908
  const observabilityRef = useRef(normalizedConfig.observability);
731
909
  observabilityRef.current = normalizedConfig.observability;
732
910
  const onLxpackBridgeMiss = useCallback((event) => {
733
911
  observabilityRef.current?.onLxpackBridgeMiss?.(event);
734
912
  }, []);
913
+ const onLxpackBridgeError = useCallback((err) => {
914
+ observabilityRef.current?.onLxpackBridgeError?.(err);
915
+ }, []);
735
916
  const pluginsFingerprint = normalizedConfig.plugins?.map((p) => `${p.id}\0${p.version}`).join("|") ?? "";
736
917
  const pluginHost = useMemo(
737
918
  () => createReactPluginHost(normalizedConfig.plugins),
@@ -742,23 +923,38 @@ function useLessonkitProviderRuntime(config) {
742
923
  const progressRef = useRef(createProgressController());
743
924
  const courseStartedEmittedToSinkRef = useRef(false);
744
925
  const courseStartedEmitGenerationRef = useRef(0);
926
+ const courseStartedFlightScopeRef = useRef(createCourseStartedFlightScope());
927
+ const pendingSessionReEmitRef = useRef(false);
928
+ const prevPluginsFingerprintRef = useRef(pluginsFingerprint);
929
+ if (prevPluginsFingerprintRef.current !== pluginsFingerprint) {
930
+ prevPluginsFingerprintRef.current = pluginsFingerprint;
931
+ courseStartedEmitGenerationRef.current += 1;
932
+ courseStartedEmittedToSinkRef.current = false;
933
+ resetCourseStartedTrackingFlights(courseStartedFlightScopeRef.current);
934
+ }
745
935
  const prevCourseIdForProgressRef = useRef(normalizedCourseId);
746
936
  const pendingCourseIdResetRef = useRef(false);
747
937
  const prevUseV2RuntimeRef = useRef(useV2Runtime);
748
938
  const xapiCourseStartedSentOnClientRef = useRef(false);
749
939
  const xapiBootstrapSendRef = useRef(false);
940
+ const xapiBootstrapQueuedRef = useRef(false);
941
+ const xapiBootstrapInFlightRef = useRef(false);
750
942
  if (prevUseV2RuntimeRef.current !== useV2Runtime) {
751
943
  prevUseV2RuntimeRef.current = useV2Runtime;
752
944
  if (useV2Runtime) {
753
- headlessRef.current = createLessonkitRuntime({
754
- courseId: normalizedCourseId,
755
- runtimeVersion: "v2",
756
- session: normalizedConfig.session,
757
- plugins: pluginHostRef.current ?? normalizedConfig.plugins,
758
- deferPluginSetup: true
759
- });
945
+ headlessRef.current = createLessonkitRuntime(
946
+ {
947
+ courseId: normalizedCourseId,
948
+ runtimeVersion: "v2",
949
+ session: normalizedConfig.session,
950
+ plugins: pluginHostRef.current ?? normalizedConfig.plugins,
951
+ deferPluginSetup: true
952
+ },
953
+ { storage: providerStorage }
954
+ );
760
955
  progressRef.current = headlessRef.current.progress;
761
956
  } else {
957
+ headlessRef.current?.dispose();
762
958
  headlessRef.current = null;
763
959
  progressRef.current = createProgressController();
764
960
  }
@@ -766,13 +962,16 @@ function useLessonkitProviderRuntime(config) {
766
962
  courseStartedEmittedToSinkRef.current = false;
767
963
  courseStartedEmitGenerationRef.current += 1;
768
964
  } else if (useV2Runtime && !headlessRef.current) {
769
- headlessRef.current = createLessonkitRuntime({
770
- courseId: normalizedCourseId,
771
- runtimeVersion: "v2",
772
- session: normalizedConfig.session,
773
- plugins: pluginHostRef.current ?? normalizedConfig.plugins,
774
- deferPluginSetup: true
775
- });
965
+ headlessRef.current = createLessonkitRuntime(
966
+ {
967
+ courseId: normalizedCourseId,
968
+ runtimeVersion: "v2",
969
+ session: normalizedConfig.session,
970
+ plugins: pluginHostRef.current ?? normalizedConfig.plugins,
971
+ deferPluginSetup: true
972
+ },
973
+ { storage: providerStorage }
974
+ );
776
975
  }
777
976
  if (prevCourseIdForProgressRef.current !== normalizedCourseId) {
778
977
  prevCourseIdForProgressRef.current = normalizedCourseId;
@@ -820,6 +1019,8 @@ function useLessonkitProviderRuntime(config) {
820
1019
  prevXapiCourseIdRef.current = courseId;
821
1020
  xapiCourseStartedSentOnClientRef.current = false;
822
1021
  xapiBootstrapSendRef.current = false;
1022
+ xapiBootstrapQueuedRef.current = false;
1023
+ xapiBootstrapInFlightRef.current = false;
823
1024
  }
824
1025
  const prev = xapiRef.current;
825
1026
  const next = createXapiClientFromConfig(
@@ -832,32 +1033,36 @@ function useLessonkitProviderRuntime(config) {
832
1033
  let bootstrapSent = false;
833
1034
  let bootstrapAlreadyStarted = false;
834
1035
  if (next) {
835
- const sessionId = sessionIdRef.current;
1036
+ const sessionId2 = sessionIdRef.current;
836
1037
  const cid = courseIdRef.current;
837
1038
  const trackingActive = isTrackingActive(normalizedConfig.tracking);
838
- bootstrapAlreadyStarted = hasCourseStarted(defaultStorage, sessionId, cid);
1039
+ bootstrapAlreadyStarted = hasCourseStarted(providerStorage, sessionId2, cid);
839
1040
  const clientChanged = !prev || prev !== next;
840
1041
  const skipBootstrap = trackingActive && !bootstrapAlreadyStarted;
841
- const needsBootstrap = !skipBootstrap && !xapiCourseStartedSentOnClientRef.current && !xapiBootstrapSendRef.current && (!bootstrapAlreadyStarted || clientChanged);
1042
+ const xapiAlreadySentForSession = hasCourseStartedXapiSent(providerStorage, sessionId2, cid);
1043
+ const needsBootstrap = !skipBootstrap && !xapiCourseStartedSentOnClientRef.current && !xapiBootstrapQueuedRef.current && !xapiAlreadySentForSession && (!bootstrapAlreadyStarted || clientChanged);
842
1044
  if (needsBootstrap) {
843
1045
  try {
844
1046
  const event = buildCourseStartedEvent({
845
1047
  pluginHost: pluginHostRef.current,
846
1048
  courseId: cid,
847
- sessionId,
1049
+ sessionId: sessionId2,
848
1050
  attemptId: attemptIdRef.current,
849
1051
  user: userRef.current,
850
- lxpackBridge: lxpackBridgeModeRef.current
1052
+ lxpackBridge: lxpackBridgeModeRef.current,
1053
+ allowedParentOrigins: allowedParentOriginsRef.current
851
1054
  });
852
1055
  if (event !== null) {
853
1056
  const statement = telemetryEventToXAPIStatement3(event);
854
1057
  if (statement) {
855
1058
  next.send(statement);
856
- xapiBootstrapSendRef.current = true;
1059
+ xapiBootstrapQueuedRef.current = true;
1060
+ xapiBootstrapInFlightRef.current = true;
857
1061
  bootstrapSent = true;
858
1062
  }
859
1063
  }
860
- } catch {
1064
+ } catch (err) {
1065
+ observabilityRef.current?.onXapiMappingError?.(err);
861
1066
  }
862
1067
  }
863
1068
  }
@@ -873,12 +1078,19 @@ function useLessonkitProviderRuntime(config) {
873
1078
  try {
874
1079
  await next?.flush();
875
1080
  if (bootstrapSent && !cancelled) {
1081
+ xapiBootstrapSendRef.current = true;
1082
+ xapiBootstrapInFlightRef.current = false;
876
1083
  if (!bootstrapAlreadyStarted) {
877
- markCourseStarted(defaultStorage, sessionIdRef.current, courseIdRef.current);
1084
+ markCourseStarted(providerStorage, sessionIdRef.current, courseIdRef.current);
878
1085
  }
1086
+ markCourseStartedXapiSent(providerStorage, sessionIdRef.current, courseIdRef.current);
879
1087
  xapiCourseStartedSentOnClientRef.current = true;
880
1088
  }
881
1089
  } catch {
1090
+ if (bootstrapSent && !cancelled) {
1091
+ xapiBootstrapQueuedRef.current = false;
1092
+ xapiBootstrapInFlightRef.current = false;
1093
+ }
882
1094
  }
883
1095
  })();
884
1096
  return () => {
@@ -908,6 +1120,39 @@ function useLessonkitProviderRuntime(config) {
908
1120
  }),
909
1121
  []
910
1122
  );
1123
+ const emitCourseStartedOnce = useCallback(
1124
+ async (sid, cid) => {
1125
+ if (courseStartedEmittedToSinkRef.current) return;
1126
+ const generation = courseStartedEmitGenerationRef.current;
1127
+ const shouldCommit = () => generation === courseStartedEmitGenerationRef.current;
1128
+ if (generation !== courseStartedEmitGenerationRef.current) return;
1129
+ const result = await emitPendingCourseStarted({
1130
+ pluginHost: pluginHostRef.current,
1131
+ tracking: () => trackingRef.current,
1132
+ xapi: xapiRef.current,
1133
+ storage: providerStorage,
1134
+ sessionId: sid,
1135
+ courseId: cid,
1136
+ attemptId: attemptIdRef.current,
1137
+ user: userRef.current,
1138
+ lxpackBridge: lxpackBridgeModeRef.current,
1139
+ allowedParentOrigins: allowedParentOriginsRef.current,
1140
+ onLxpackBridgeMiss,
1141
+ onLxpackBridgeError,
1142
+ onXapiMappingError: observabilityRef.current?.onXapiMappingError,
1143
+ extraSinks: extraSinksRef.current,
1144
+ skipXapi: xapiCourseStartedSentOnClientRef.current || xapiBootstrapSendRef.current || xapiBootstrapInFlightRef.current,
1145
+ onXapiStatementSent: () => {
1146
+ xapiCourseStartedSentOnClientRef.current = true;
1147
+ },
1148
+ shouldCommit,
1149
+ flightScope: courseStartedFlightScopeRef.current
1150
+ });
1151
+ if (generation !== courseStartedEmitGenerationRef.current) return;
1152
+ courseStartedEmittedToSinkRef.current = isCourseStartedSinkSettled(result);
1153
+ },
1154
+ [onLxpackBridgeMiss, onLxpackBridgeError]
1155
+ );
911
1156
  useIsoLayoutEffect(() => {
912
1157
  const prev = trackingRef.current;
913
1158
  const baseSink = wrapTrackingSink(normalizedConfig.tracking?.sink, observabilityRef.current);
@@ -943,45 +1188,19 @@ function useLessonkitProviderRuntime(config) {
943
1188
  trackingRef.current = next;
944
1189
  trackingClientForUnmountRef.current = next;
945
1190
  setTracking(next);
946
- const sessionId = sessionIdRef.current;
1191
+ const sessionId2 = sessionIdRef.current;
947
1192
  const cid = courseIdRef.current;
948
1193
  const trackingActive = isTrackingActive(normalizedConfig.tracking);
949
- const courseStartedFullySettled = hasCourseStartedEmittedToTracking(defaultStorage, sessionId, cid) && hasCourseStarted(defaultStorage, sessionId, cid) && hasCourseStartedPipelineDelivered(defaultStorage, sessionId, cid);
1194
+ const courseStartedFullySettled = hasCourseStartedEmittedToTracking(providerStorage, sessionId2, cid) && hasCourseStarted(providerStorage, sessionId2, cid) && hasCourseStartedPipelineDelivered(providerStorage, sessionId2, cid);
950
1195
  if (!trackingActive) {
951
1196
  courseStartedEmittedToSinkRef.current = false;
952
1197
  } else if (courseStartedFullySettled) {
953
1198
  courseStartedEmittedToSinkRef.current = true;
954
- } else if (!courseStartedEmittedToSinkRef.current) {
955
- const generation = courseStartedEmitGenerationRef.current;
956
- const shouldCommit = () => generation === courseStartedEmitGenerationRef.current;
957
- void (async () => {
958
- if (generation !== courseStartedEmitGenerationRef.current) return;
959
- const result = await emitPendingCourseStarted({
960
- pluginHost: pluginHostRef.current,
961
- tracking: () => trackingRef.current,
962
- xapi: xapiRef.current,
963
- storage: defaultStorage,
964
- sessionId,
965
- courseId: cid,
966
- attemptId: attemptIdRef.current,
967
- user: userRef.current,
968
- lxpackBridge: lxpackBridgeModeRef.current,
969
- onLxpackBridgeMiss,
970
- extraSinks: extraSinksRef.current,
971
- skipXapi: xapiCourseStartedSentOnClientRef.current || xapiBootstrapSendRef.current,
972
- onXapiStatementSent: () => {
973
- xapiCourseStartedSentOnClientRef.current = true;
974
- },
975
- shouldCommit
976
- });
977
- if (generation !== courseStartedEmitGenerationRef.current) return;
978
- courseStartedEmittedToSinkRef.current = isCourseStartedSinkSettled(result);
979
- })();
1199
+ } else {
1200
+ void emitCourseStartedOnce(sessionId2, cid);
980
1201
  }
981
1202
  return () => {
982
- if (prev !== trackingRef.current) {
983
- void disposeTrackingClient(prev);
984
- }
1203
+ void disposeTrackingClient(prev);
985
1204
  };
986
1205
  }, [
987
1206
  trackingEnabled,
@@ -990,9 +1209,10 @@ function useLessonkitProviderRuntime(config) {
990
1209
  batchEnabled,
991
1210
  batchFlushIntervalMs,
992
1211
  batchMaxBatchSize,
993
- normalizedConfig.plugins,
1212
+ pluginsFingerprint,
994
1213
  normalizedCourseId,
995
- buildCurrentPluginCtx
1214
+ buildCurrentPluginCtx,
1215
+ emitCourseStartedOnce
996
1216
  ]);
997
1217
  const emitWithBridge = useCallback((trackingClient, event) => {
998
1218
  emitTelemetryWithPlugins({
@@ -1007,22 +1227,16 @@ function useLessonkitProviderRuntime(config) {
1007
1227
  user: userRef.current
1008
1228
  }),
1009
1229
  lxpackBridge: lxpackBridgeModeRef.current,
1230
+ allowedParentOrigins: allowedParentOriginsRef.current,
1010
1231
  onLxpackBridgeMiss,
1011
- extraSinks: extraSinksRef.current
1232
+ onLxpackBridgeError,
1233
+ extraSinks: extraSinksRef.current,
1234
+ onXapiMappingError: observabilityRef.current?.onXapiMappingError,
1235
+ onXapiTransportError: observabilityRef.current?.onXapiTransportError
1012
1236
  });
1013
- }, [onLxpackBridgeMiss]);
1237
+ }, [onLxpackBridgeMiss, onLxpackBridgeError]);
1014
1238
  const emitLifecycleEvent = useCallback(
1015
- (name, data, lessonId) => {
1016
- const event = tryBuildTelemetryEvent({
1017
- name,
1018
- courseId: courseIdRef.current,
1019
- lessonId: lessonId ?? activeLessonIdRef.current,
1020
- sessionId: sessionIdRef.current,
1021
- attemptId: attemptIdRef.current,
1022
- user: userRef.current,
1023
- data
1024
- });
1025
- if (!event) return;
1239
+ (event) => {
1026
1240
  emitWithBridge(trackingRef.current, event);
1027
1241
  },
1028
1242
  [emitWithBridge]
@@ -1048,39 +1262,16 @@ function useLessonkitProviderRuntime(config) {
1048
1262
  pendingCourseIdResetRef.current = false;
1049
1263
  syncProgress();
1050
1264
  if (!isTrackingActive(normalizedConfig.tracking)) return;
1051
- const sessionId = sessionIdRef.current;
1265
+ const sessionId2 = sessionIdRef.current;
1052
1266
  const cid = courseIdRef.current;
1053
1267
  void (async () => {
1054
1268
  try {
1055
1269
  await trackingRef.current?.flush?.();
1056
1270
  } catch {
1057
1271
  }
1058
- if (!courseStartedEmittedToSinkRef.current) {
1059
- const generation = courseStartedEmitGenerationRef.current;
1060
- const shouldCommit = () => generation === courseStartedEmitGenerationRef.current;
1061
- const result = await emitPendingCourseStarted({
1062
- pluginHost: pluginHostRef.current,
1063
- tracking: () => trackingRef.current,
1064
- xapi: xapiRef.current,
1065
- storage: defaultStorage,
1066
- sessionId,
1067
- courseId: cid,
1068
- attemptId: attemptIdRef.current,
1069
- user: userRef.current,
1070
- lxpackBridge: lxpackBridgeModeRef.current,
1071
- onLxpackBridgeMiss,
1072
- extraSinks: extraSinksRef.current,
1073
- skipXapi: xapiCourseStartedSentOnClientRef.current || xapiBootstrapSendRef.current,
1074
- onXapiStatementSent: () => {
1075
- xapiCourseStartedSentOnClientRef.current = true;
1076
- },
1077
- shouldCommit
1078
- });
1079
- if (generation !== courseStartedEmitGenerationRef.current) return;
1080
- courseStartedEmittedToSinkRef.current = isCourseStartedSinkSettled(result);
1081
- }
1272
+ await emitCourseStartedOnce(sessionId2, cid);
1082
1273
  })();
1083
- }, [normalizedCourseId, normalizedConfig.tracking?.enabled, syncProgress, onLxpackBridgeMiss]);
1274
+ }, [normalizedCourseId, normalizedConfig.tracking?.enabled, syncProgress, emitCourseStartedOnce]);
1084
1275
  const emitLessonCompleted = useCallback(
1085
1276
  (lessonId, durationMs) => {
1086
1277
  track("lesson_completed", { lessonId, durationMs }, { lessonId });
@@ -1130,23 +1321,13 @@ function useLessonkitProviderRuntime(config) {
1130
1321
  };
1131
1322
  }, []);
1132
1323
  useEffect2(() => {
1133
- if (typeof document === "undefined") return;
1324
+ if (typeof window === "undefined") return;
1134
1325
  const flushOnPageExit = () => {
1135
- try {
1136
- xapiRef.current?.flushOnExit?.();
1137
- trackingRef.current?.flushOnExit?.();
1138
- } finally {
1139
- void xapiRef.current?.flush();
1140
- void trackingRef.current?.flush?.();
1141
- }
1142
- };
1143
- const onVisibilityChange = () => {
1144
- if (document.visibilityState === "hidden") flushOnPageExit();
1326
+ xapiRef.current?.flushOnExit?.();
1327
+ trackingRef.current?.flushOnExit?.();
1145
1328
  };
1146
- document.addEventListener("visibilitychange", onVisibilityChange);
1147
1329
  window.addEventListener("pagehide", flushOnPageExit);
1148
1330
  return () => {
1149
- document.removeEventListener("visibilitychange", onVisibilityChange);
1150
1331
  window.removeEventListener("pagehide", flushOnPageExit);
1151
1332
  };
1152
1333
  }, []);
@@ -1241,36 +1422,56 @@ function useLessonkitProviderRuntime(config) {
1241
1422
  host.disposeAll();
1242
1423
  };
1243
1424
  }, [pluginHost, useV2Runtime, normalizedCourseId, sessionAttemptId, sessionConfiguredId, sessionUserKey]);
1244
- useEffect2(() => {
1425
+ useIsoLayoutEffect(() => {
1245
1426
  const nextConfigured = normalizedConfig.session?.sessionId;
1246
1427
  const prevConfigured = prevConfiguredSessionIdRef.current;
1247
1428
  if (nextConfigured === prevConfigured) return;
1248
1429
  prevConfiguredSessionIdRef.current = nextConfigured;
1249
1430
  const cid = courseIdRef.current;
1250
- if (nextConfigured) {
1251
- const fromIds = /* @__PURE__ */ new Set();
1252
- if (prevConfigured) fromIds.add(prevConfigured);
1253
- const tabId = getTabSessionId(defaultStorage);
1254
- if (tabId) fromIds.add(tabId);
1255
- for (const fromId of fromIds) {
1256
- if (fromId !== nextConfigured) {
1257
- migrateCourseStartedMark(defaultStorage, fromId, nextConfigured, cid);
1258
- }
1431
+ if (nextConfigured !== void 0) {
1432
+ const resolved = resolveSessionId(providerStorage, nextConfigured);
1433
+ const tabId = getTabSessionId(providerStorage);
1434
+ const isExplicitLearnerSwap = prevConfigured !== void 0 && prevConfigured !== nextConfigured;
1435
+ if (isExplicitLearnerSwap) {
1436
+ courseStartedEmittedToSinkRef.current = false;
1437
+ courseStartedEmitGenerationRef.current += 1;
1438
+ pendingSessionReEmitRef.current = true;
1439
+ } else if (tabId && tabId !== resolved) {
1440
+ migrateCourseStartedMark(providerStorage, tabId, resolved, cid);
1259
1441
  }
1260
- sessionIdRef.current = nextConfigured;
1442
+ sessionIdRef.current = resolved;
1443
+ setSessionId(resolved);
1261
1444
  } else if (prevConfigured) {
1262
- const nextAuto = resolveSessionId(defaultStorage, void 0);
1263
- migrateCourseStartedMark(defaultStorage, prevConfigured, nextAuto, cid);
1445
+ const nextAuto = resolveSessionId(providerStorage, void 0);
1446
+ migrateCourseStartedMark(providerStorage, prevConfigured, nextAuto, cid);
1264
1447
  sessionIdRef.current = nextAuto;
1448
+ setSessionId(nextAuto);
1265
1449
  }
1266
1450
  }, [sessionConfiguredId, normalizedCourseId]);
1451
+ useEffect2(() => {
1452
+ if (!pendingSessionReEmitRef.current) return;
1453
+ pendingSessionReEmitRef.current = false;
1454
+ if (!isTrackingActive(normalizedConfig.tracking)) return;
1455
+ void emitCourseStartedOnce(sessionIdRef.current, courseIdRef.current);
1456
+ }, [sessionConfiguredId, emitCourseStartedOnce, normalizedConfig.tracking]);
1457
+ useLayoutEffect(() => {
1458
+ if (sessionIdRef.current !== sessionId) {
1459
+ setSessionId(sessionIdRef.current);
1460
+ }
1461
+ }, [sessionConfiguredId, sessionId]);
1462
+ useEffect2(() => {
1463
+ return () => {
1464
+ headlessRef.current?.dispose();
1465
+ headlessRef.current = null;
1466
+ };
1467
+ }, []);
1267
1468
  const runtime = useMemo(
1268
1469
  () => ({
1269
1470
  config: normalizedConfig,
1270
1471
  tracking,
1271
1472
  xapi,
1272
- storage: defaultStorage,
1273
- session: { sessionId: sessionIdRef.current, attemptId: attemptIdRef.current, user: userRef.current },
1473
+ storage: providerStorage,
1474
+ session: { sessionId, attemptId: attemptIdRef.current, user: userRef.current },
1274
1475
  progress,
1275
1476
  setActiveLesson,
1276
1477
  completeLesson,
@@ -1290,7 +1491,8 @@ function useLessonkitProviderRuntime(config) {
1290
1491
  pluginHost,
1291
1492
  sessionUser,
1292
1493
  sessionAttemptId,
1293
- sessionConfiguredId
1494
+ sessionConfiguredId,
1495
+ sessionId
1294
1496
  ]
1295
1497
  );
1296
1498
  return runtime;
@@ -1394,7 +1596,9 @@ var COMPOUND_CONTAINER_TYPES = /* @__PURE__ */ new Set([
1394
1596
  "SlideDeck",
1395
1597
  "TimedCue",
1396
1598
  "InteractiveVideo",
1397
- "AssessmentSequence"
1599
+ "AssessmentSequence",
1600
+ "BranchingScenario",
1601
+ "BranchNode"
1398
1602
  ]);
1399
1603
  function warnOrThrow(msg, strict) {
1400
1604
  if (strict) throw new Error(msg);
@@ -1466,14 +1670,14 @@ function validateSubtreeForForbidden(node, forbidden, strict) {
1466
1670
  });
1467
1671
  }
1468
1672
  function validateAccordionSections(sections, strict) {
1469
- if (!isDevEnvironment() && !strict) return;
1673
+ const enforceStrict = strict ?? !isDevEnvironment();
1470
1674
  for (const section of sections) {
1471
- validateSubtreeForForbidden(section.content, ACCORDION_FORBIDDEN_CHILD_TYPES, strict);
1675
+ validateSubtreeForForbidden(section.content, ACCORDION_FORBIDDEN_CHILD_TYPES, enforceStrict);
1472
1676
  }
1473
1677
  }
1474
1678
  function validateCompoundChildren(parent, children, strict) {
1475
- if (!isDevEnvironment() && !strict) return;
1476
- validateNode(parent, children, 0, strict);
1679
+ const enforceStrict = strict ?? !isDevEnvironment();
1680
+ validateNode(parent, children, 0, enforceStrict);
1477
1681
  }
1478
1682
  function resetCompoundValidationWarningsForTests() {
1479
1683
  warnedPairs.clear();
@@ -1688,14 +1892,15 @@ function meetsPassingThreshold(score, maxScore, passingScore) {
1688
1892
  }
1689
1893
  function scoreFromCustom(custom, fallbackCorrect, fallbackMax = 1, passingScore) {
1690
1894
  const maxScore = custom?.maxScore ?? fallbackMax;
1895
+ const hasNumericScore = custom?.score != null && Number.isFinite(custom.score);
1896
+ if (hasNumericScore) {
1897
+ const passed2 = custom.passed !== void 0 ? custom.passed : meetsPassingThreshold(custom.score, maxScore, passingScore);
1898
+ return { score: custom.score, maxScore, passed: passed2 };
1899
+ }
1691
1900
  if (custom?.passed !== void 0) {
1692
- const score2 = custom.passed ? custom.score ?? maxScore : custom.score ?? 0;
1901
+ const score2 = custom.passed ? maxScore : 0;
1693
1902
  return { score: score2, maxScore, passed: custom.passed };
1694
1903
  }
1695
- if (custom?.maxScore != null && custom.maxScore > 0 && custom.score != null) {
1696
- const passed2 = meetsPassingThreshold(custom.score, custom.maxScore, passingScore);
1697
- return { score: custom.score, maxScore: custom.maxScore, passed: passed2 };
1698
- }
1699
1904
  const score = fallbackCorrect ? maxScore : 0;
1700
1905
  const passed = meetsPassingThreshold(score, maxScore, passingScore);
1701
1906
  return { score, maxScore, passed };
@@ -1747,6 +1952,7 @@ export {
1747
1952
  readStringField,
1748
1953
  readNumberField,
1749
1954
  readBooleanStateField,
1955
+ aggregateAssessmentScores,
1750
1956
  useCompoundHydrationBridgeRef,
1751
1957
  CompoundPageIndexProvider,
1752
1958
  CompoundProvider,
@@ -1754,9 +1960,9 @@ export {
1754
1960
  useCompoundHandlesVersion,
1755
1961
  useCompoundHandleRef,
1756
1962
  useAssessmentHandleRegistration,
1963
+ resetCourseStartedTrackingFlightForTests,
1757
1964
  shouldEnforceProductionGuard,
1758
1965
  assertProductionCourseConfig,
1759
- resetCourseStartedTrackingFlightForTests,
1760
1966
  resetLessonkitProviderStorageForTests,
1761
1967
  LessonkitContext,
1762
1968
  LessonkitProvider,
@@ -1769,6 +1975,7 @@ export {
1769
1975
  meetsPassingThreshold,
1770
1976
  usePluginScoring,
1771
1977
  setLessonkitBlockType,
1978
+ getLessonkitBlockType,
1772
1979
  validateAccordionSections,
1773
1980
  validateCompoundChildren,
1774
1981
  resetCompoundValidationWarningsForTests