@lessonkit/core 1.3.1 → 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 +28 -2
- package/dist/chunk-KFXFQ6B2.js +813 -0
- package/dist/index.cjs +720 -130
- package/dist/index.d.cts +63 -488
- package/dist/index.d.ts +63 -488
- package/dist/index.js +509 -658
- package/dist/testing-BFr8oEfw.d.cts +608 -0
- package/dist/testing-BFr8oEfw.d.ts +608 -0
- package/dist/testing.cjs +58 -0
- package/dist/testing.d.cts +1 -0
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +12 -0
- package/package.json +11 -6
- package/telemetry-catalog.v3.json +207 -14
|
@@ -0,0 +1,813 @@
|
|
|
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
|
+
|
|
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
|
+
}
|
|
69
|
+
function createSessionId() {
|
|
70
|
+
const g = globalThis;
|
|
71
|
+
if (g.crypto?.randomUUID) return g.crypto.randomUUID();
|
|
72
|
+
return randomSessionIdFallback();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// src/time.ts
|
|
76
|
+
function nowIso() {
|
|
77
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// src/internal/env.ts
|
|
81
|
+
function isDevEnvironment() {
|
|
82
|
+
const g = globalThis;
|
|
83
|
+
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
84
|
+
}
|
|
85
|
+
function warnDev(message, err) {
|
|
86
|
+
if (!isDevEnvironment()) return;
|
|
87
|
+
console.warn(message, err instanceof Error ? err.message : err);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// src/telemetry/eventRegistry.ts
|
|
91
|
+
function resolveLessonId(opts, eventName) {
|
|
92
|
+
const lessonId = opts.lessonId ?? opts.data?.lessonId;
|
|
93
|
+
if (!lessonId) throw new Error(`${eventName} requires lessonId`);
|
|
94
|
+
return lessonId;
|
|
95
|
+
}
|
|
96
|
+
function withLessonScopedData(name, base, lessonId, data) {
|
|
97
|
+
return { name, ...base, lessonId, data: { ...data, lessonId } };
|
|
98
|
+
}
|
|
99
|
+
var TELEMETRY_EVENT_REGISTRY = {
|
|
100
|
+
course_started: {
|
|
101
|
+
build: (_opts, base) => ({ name: "course_started", ...base })
|
|
102
|
+
},
|
|
103
|
+
course_completed: {
|
|
104
|
+
build: (_opts, base) => ({ name: "course_completed", ...base })
|
|
105
|
+
},
|
|
106
|
+
lesson_started: {
|
|
107
|
+
requiresLessonId: true,
|
|
108
|
+
build: (opts, base) => {
|
|
109
|
+
if (opts.name !== "lesson_started") throw new Error("unexpected event");
|
|
110
|
+
const lessonId = resolveLessonId(opts, "lesson_started");
|
|
111
|
+
return withLessonScopedData("lesson_started", base, lessonId, opts.data);
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
lesson_completed: {
|
|
115
|
+
requiresLessonId: true,
|
|
116
|
+
build: (opts, base) => {
|
|
117
|
+
if (opts.name !== "lesson_completed") throw new Error("unexpected event");
|
|
118
|
+
const lessonId = resolveLessonId(opts, opts.name);
|
|
119
|
+
return withLessonScopedData(opts.name, base, lessonId, opts.data);
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
lesson_time_on_task: {
|
|
123
|
+
requiresLessonId: true,
|
|
124
|
+
build: (opts, base) => {
|
|
125
|
+
if (opts.name !== "lesson_time_on_task") throw new Error("unexpected event");
|
|
126
|
+
const lessonId = resolveLessonId(opts, opts.name);
|
|
127
|
+
return withLessonScopedData(opts.name, base, lessonId, opts.data);
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
quiz_answered: {
|
|
131
|
+
requiresLessonId: true,
|
|
132
|
+
tryBuildMissingLessonWarning: "quiz",
|
|
133
|
+
build: (opts, base) => {
|
|
134
|
+
if (opts.name !== "quiz_answered") throw new Error("unexpected event");
|
|
135
|
+
const lessonId = opts.lessonId;
|
|
136
|
+
if (!lessonId) throw new Error("quiz_answered requires active lessonId");
|
|
137
|
+
return {
|
|
138
|
+
name: "quiz_answered",
|
|
139
|
+
...base,
|
|
140
|
+
lessonId,
|
|
141
|
+
data: opts.data
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
quiz_completed: {
|
|
146
|
+
requiresLessonId: true,
|
|
147
|
+
tryBuildMissingLessonWarning: "quiz",
|
|
148
|
+
build: (opts, base) => {
|
|
149
|
+
if (opts.name !== "quiz_completed") throw new Error("unexpected event");
|
|
150
|
+
const lessonId = opts.lessonId;
|
|
151
|
+
if (!lessonId) throw new Error("quiz_completed requires active lessonId");
|
|
152
|
+
return {
|
|
153
|
+
name: "quiz_completed",
|
|
154
|
+
...base,
|
|
155
|
+
lessonId,
|
|
156
|
+
data: opts.data
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
assessment_answered: {
|
|
161
|
+
requiresLessonId: true,
|
|
162
|
+
tryBuildMissingLessonWarning: "assessment",
|
|
163
|
+
build: (opts, base) => {
|
|
164
|
+
if (opts.name !== "assessment_answered") throw new Error("unexpected event");
|
|
165
|
+
const lessonId = opts.lessonId;
|
|
166
|
+
if (!lessonId) throw new Error("assessment_answered requires active lessonId");
|
|
167
|
+
return {
|
|
168
|
+
name: "assessment_answered",
|
|
169
|
+
...base,
|
|
170
|
+
lessonId,
|
|
171
|
+
data: opts.data
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
assessment_completed: {
|
|
176
|
+
requiresLessonId: true,
|
|
177
|
+
tryBuildMissingLessonWarning: "assessment",
|
|
178
|
+
build: (opts, base) => {
|
|
179
|
+
if (opts.name !== "assessment_completed") throw new Error("unexpected event");
|
|
180
|
+
const lessonId = opts.lessonId;
|
|
181
|
+
if (!lessonId) throw new Error("assessment_completed requires active lessonId");
|
|
182
|
+
return {
|
|
183
|
+
name: "assessment_completed",
|
|
184
|
+
...base,
|
|
185
|
+
lessonId,
|
|
186
|
+
data: opts.data
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
interaction: {
|
|
191
|
+
build: (opts, base) => {
|
|
192
|
+
if (opts.name !== "interaction") throw new Error("unexpected event");
|
|
193
|
+
return {
|
|
194
|
+
name: "interaction",
|
|
195
|
+
...base,
|
|
196
|
+
lessonId: opts.lessonId,
|
|
197
|
+
data: opts.data
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
book_page_viewed: {
|
|
202
|
+
requiresLessonId: true,
|
|
203
|
+
build: (opts, base) => {
|
|
204
|
+
if (opts.name !== "book_page_viewed") throw new Error("unexpected event");
|
|
205
|
+
const lessonId = opts.lessonId;
|
|
206
|
+
if (!lessonId) throw new Error("book_page_viewed requires active lessonId");
|
|
207
|
+
return {
|
|
208
|
+
name: "book_page_viewed",
|
|
209
|
+
...base,
|
|
210
|
+
lessonId,
|
|
211
|
+
data: opts.data
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
slide_viewed: {
|
|
216
|
+
requiresLessonId: true,
|
|
217
|
+
build: (opts, base) => {
|
|
218
|
+
if (opts.name !== "slide_viewed") throw new Error("unexpected event");
|
|
219
|
+
const lessonId = opts.lessonId;
|
|
220
|
+
if (!lessonId) throw new Error("slide_viewed requires active lessonId");
|
|
221
|
+
return {
|
|
222
|
+
name: "slide_viewed",
|
|
223
|
+
...base,
|
|
224
|
+
lessonId,
|
|
225
|
+
data: opts.data
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
compound_page_viewed: {
|
|
230
|
+
requiresLessonId: true,
|
|
231
|
+
build: (opts, base) => {
|
|
232
|
+
if (opts.name !== "compound_page_viewed") throw new Error("unexpected event");
|
|
233
|
+
const lessonId = opts.lessonId;
|
|
234
|
+
if (!lessonId) throw new Error("compound_page_viewed requires active lessonId");
|
|
235
|
+
return {
|
|
236
|
+
name: "compound_page_viewed",
|
|
237
|
+
...base,
|
|
238
|
+
lessonId,
|
|
239
|
+
data: opts.data
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
hotspot_opened: {
|
|
244
|
+
build: (opts, base) => {
|
|
245
|
+
if (opts.name !== "hotspot_opened") throw new Error("unexpected event");
|
|
246
|
+
return {
|
|
247
|
+
name: "hotspot_opened",
|
|
248
|
+
...base,
|
|
249
|
+
lessonId: opts.lessonId,
|
|
250
|
+
data: opts.data
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
accordion_section_toggled: {
|
|
255
|
+
build: (opts, base) => {
|
|
256
|
+
if (opts.name !== "accordion_section_toggled") throw new Error("unexpected event");
|
|
257
|
+
return {
|
|
258
|
+
name: "accordion_section_toggled",
|
|
259
|
+
...base,
|
|
260
|
+
lessonId: opts.lessonId,
|
|
261
|
+
data: opts.data
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
flashcard_flipped: {
|
|
266
|
+
build: (opts, base) => {
|
|
267
|
+
if (opts.name !== "flashcard_flipped") throw new Error("unexpected event");
|
|
268
|
+
return {
|
|
269
|
+
name: "flashcard_flipped",
|
|
270
|
+
...base,
|
|
271
|
+
lessonId: opts.lessonId,
|
|
272
|
+
data: opts.data
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
image_slider_changed: {
|
|
277
|
+
build: (opts, base) => {
|
|
278
|
+
if (opts.name !== "image_slider_changed") throw new Error("unexpected event");
|
|
279
|
+
return {
|
|
280
|
+
name: "image_slider_changed",
|
|
281
|
+
...base,
|
|
282
|
+
lessonId: opts.lessonId,
|
|
283
|
+
data: opts.data
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
video_cue_reached: {
|
|
288
|
+
requiresLessonId: true,
|
|
289
|
+
build: (opts, base) => {
|
|
290
|
+
if (opts.name !== "video_cue_reached") throw new Error("unexpected event");
|
|
291
|
+
const lessonId = opts.lessonId;
|
|
292
|
+
if (!lessonId) throw new Error("video_cue_reached requires active lessonId");
|
|
293
|
+
return {
|
|
294
|
+
name: "video_cue_reached",
|
|
295
|
+
...base,
|
|
296
|
+
lessonId,
|
|
297
|
+
data: opts.data
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
video_segment_completed: {
|
|
302
|
+
requiresLessonId: true,
|
|
303
|
+
build: (opts, base) => {
|
|
304
|
+
if (opts.name !== "video_segment_completed") throw new Error("unexpected event");
|
|
305
|
+
const lessonId = opts.lessonId;
|
|
306
|
+
if (!lessonId) throw new Error("video_segment_completed requires active lessonId");
|
|
307
|
+
return {
|
|
308
|
+
name: "video_segment_completed",
|
|
309
|
+
...base,
|
|
310
|
+
lessonId,
|
|
311
|
+
data: opts.data
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
memory_card_flipped: {
|
|
316
|
+
build: (opts, base) => {
|
|
317
|
+
if (opts.name !== "memory_card_flipped") throw new Error("unexpected event");
|
|
318
|
+
return {
|
|
319
|
+
name: "memory_card_flipped",
|
|
320
|
+
...base,
|
|
321
|
+
lessonId: opts.lessonId,
|
|
322
|
+
data: opts.data
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
information_wall_search: {
|
|
327
|
+
build: (opts, base) => {
|
|
328
|
+
if (opts.name !== "information_wall_search") throw new Error("unexpected event");
|
|
329
|
+
return {
|
|
330
|
+
name: "information_wall_search",
|
|
331
|
+
...base,
|
|
332
|
+
lessonId: opts.lessonId,
|
|
333
|
+
data: opts.data
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
parallax_slide_viewed: {
|
|
338
|
+
build: (opts, base) => {
|
|
339
|
+
if (opts.name !== "parallax_slide_viewed") throw new Error("unexpected event");
|
|
340
|
+
return {
|
|
341
|
+
name: "parallax_slide_viewed",
|
|
342
|
+
...base,
|
|
343
|
+
lessonId: opts.lessonId,
|
|
344
|
+
data: opts.data
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
questionnaire_submitted: {
|
|
349
|
+
requiresLessonId: true,
|
|
350
|
+
build: (opts, base) => {
|
|
351
|
+
if (opts.name !== "questionnaire_submitted") throw new Error("unexpected event");
|
|
352
|
+
const lessonId = opts.lessonId;
|
|
353
|
+
if (!lessonId) throw new Error("questionnaire_submitted requires active lessonId");
|
|
354
|
+
return {
|
|
355
|
+
name: "questionnaire_submitted",
|
|
356
|
+
...base,
|
|
357
|
+
lessonId,
|
|
358
|
+
data: opts.data
|
|
359
|
+
};
|
|
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
|
+
}
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
function buildTelemetryEventFromRegistry(opts) {
|
|
392
|
+
const entry = TELEMETRY_EVENT_REGISTRY[opts.name];
|
|
393
|
+
if (!entry) {
|
|
394
|
+
throw new Error("Unexpected value");
|
|
395
|
+
}
|
|
396
|
+
const base = {
|
|
397
|
+
timestamp: opts.timestamp ?? nowIso(),
|
|
398
|
+
courseId: opts.courseId,
|
|
399
|
+
sessionId: opts.sessionId,
|
|
400
|
+
attemptId: opts.attemptId,
|
|
401
|
+
user: opts.user
|
|
402
|
+
};
|
|
403
|
+
return entry.build(opts, base);
|
|
404
|
+
}
|
|
405
|
+
function getTelemetryEventRegistryEntry(name) {
|
|
406
|
+
return TELEMETRY_EVENT_REGISTRY[name];
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// src/telemetryBuilder.ts
|
|
410
|
+
var warnedMissingQuizLesson = false;
|
|
411
|
+
var warnedMissingAssessmentLesson = false;
|
|
412
|
+
function resetTelemetryBuilderWarningsForTests() {
|
|
413
|
+
warnedMissingQuizLesson = false;
|
|
414
|
+
warnedMissingAssessmentLesson = false;
|
|
415
|
+
}
|
|
416
|
+
function buildTelemetryEvent(opts) {
|
|
417
|
+
return buildTelemetryEventFromRegistry(opts);
|
|
418
|
+
}
|
|
419
|
+
function tryBuildTelemetryEvent(opts) {
|
|
420
|
+
const entry = getTelemetryEventRegistryEntry(opts.name);
|
|
421
|
+
if (entry.requiresLessonId && !opts.lessonId) {
|
|
422
|
+
if (isDevEnvironment() && entry.tryBuildMissingLessonWarning) {
|
|
423
|
+
if (entry.tryBuildMissingLessonWarning === "quiz" && !warnedMissingQuizLesson) {
|
|
424
|
+
warnedMissingQuizLesson = true;
|
|
425
|
+
console.warn(
|
|
426
|
+
`[lessonkit] ${opts.name} skipped: wrap <Quiz> in <Lesson> so an active lessonId is available`
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
if (entry.tryBuildMissingLessonWarning === "assessment" && !warnedMissingAssessmentLesson) {
|
|
430
|
+
warnedMissingAssessmentLesson = true;
|
|
431
|
+
console.warn(
|
|
432
|
+
`[lessonkit] ${opts.name} skipped: wrap assessment blocks in <Lesson> so an active lessonId is available`
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
return buildTelemetryEvent(opts);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// src/ports.ts
|
|
442
|
+
function createDefaultClock() {
|
|
443
|
+
return {
|
|
444
|
+
nowMs: () => Date.now(),
|
|
445
|
+
nowIso: () => (/* @__PURE__ */ new Date()).toISOString()
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
function createNoopStorage() {
|
|
449
|
+
const memory = /* @__PURE__ */ new Map();
|
|
450
|
+
return {
|
|
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
|
+
}
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
function createMemoryBackedSessionStorage(session) {
|
|
465
|
+
const memory = /* @__PURE__ */ new Map();
|
|
466
|
+
const tombstones = /* @__PURE__ */ new Set();
|
|
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
|
+
}
|
|
487
|
+
const warnPersistFailure = () => {
|
|
488
|
+
if (warnedPersistFailure) return;
|
|
489
|
+
warnedPersistFailure = true;
|
|
490
|
+
const g = globalThis;
|
|
491
|
+
if (typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "development") {
|
|
492
|
+
console.warn(
|
|
493
|
+
"[lessonkit] sessionStorage is unavailable or failed; using in-memory session dedupe for this tab (may reset on full reload)."
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
return {
|
|
498
|
+
getItem: (key) => {
|
|
499
|
+
if (tombstones.has(key)) return null;
|
|
500
|
+
if (memory.has(key)) return memory.get(key);
|
|
501
|
+
try {
|
|
502
|
+
const value = session.getItem(key);
|
|
503
|
+
if (value !== null) memory.set(key, value);
|
|
504
|
+
return value;
|
|
505
|
+
} catch {
|
|
506
|
+
return memory.get(key) ?? null;
|
|
507
|
+
}
|
|
508
|
+
},
|
|
509
|
+
setItem: (key, value) => {
|
|
510
|
+
tombstones.delete(key);
|
|
511
|
+
memory.set(key, value);
|
|
512
|
+
try {
|
|
513
|
+
session.setItem(key, value);
|
|
514
|
+
return true;
|
|
515
|
+
} catch {
|
|
516
|
+
warnPersistFailure();
|
|
517
|
+
return false;
|
|
518
|
+
}
|
|
519
|
+
},
|
|
520
|
+
removeItem: (key) => {
|
|
521
|
+
memory.delete(key);
|
|
522
|
+
try {
|
|
523
|
+
session.removeItem(key);
|
|
524
|
+
tombstones.delete(key);
|
|
525
|
+
} catch {
|
|
526
|
+
warnPersistFailure();
|
|
527
|
+
tombstones.add(key);
|
|
528
|
+
}
|
|
529
|
+
},
|
|
530
|
+
resetForTests: () => {
|
|
531
|
+
memory.clear();
|
|
532
|
+
tombstones.clear();
|
|
533
|
+
}
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
function resetStoragePortForTests(storage) {
|
|
537
|
+
storage.resetForTests?.();
|
|
538
|
+
}
|
|
539
|
+
function createInMemorySessionStoragePort() {
|
|
540
|
+
const memory = /* @__PURE__ */ new Map();
|
|
541
|
+
return {
|
|
542
|
+
getItem: (key) => memory.get(key) ?? null,
|
|
543
|
+
setItem: (key, value) => {
|
|
544
|
+
memory.set(key, value);
|
|
545
|
+
return true;
|
|
546
|
+
},
|
|
547
|
+
removeItem: (key) => {
|
|
548
|
+
memory.delete(key);
|
|
549
|
+
},
|
|
550
|
+
resetForTests: () => {
|
|
551
|
+
memory.clear();
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
function resolveBrowserSessionStorage() {
|
|
556
|
+
try {
|
|
557
|
+
if (typeof sessionStorage === "undefined" || sessionStorage == null) {
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
return sessionStorage;
|
|
561
|
+
} catch {
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
function createSessionStoragePort() {
|
|
566
|
+
const session = resolveBrowserSessionStorage();
|
|
567
|
+
if (!session) {
|
|
568
|
+
return createInMemorySessionStoragePort();
|
|
569
|
+
}
|
|
570
|
+
return createMemoryBackedSessionStorage(session);
|
|
571
|
+
}
|
|
572
|
+
function createGlobalTimer() {
|
|
573
|
+
return {
|
|
574
|
+
setInterval: (fn, ms) => globalThis.setInterval(fn, ms),
|
|
575
|
+
clearInterval: (id) => globalThis.clearInterval(id)
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// src/session.ts
|
|
580
|
+
var SESSION_STORAGE_KEY = "lessonkit:sessionId";
|
|
581
|
+
var volatileSessionIds = /* @__PURE__ */ new WeakMap();
|
|
582
|
+
function isDevEnvironment2() {
|
|
583
|
+
const g = globalThis;
|
|
584
|
+
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
585
|
+
}
|
|
586
|
+
function getTabSessionId(storage) {
|
|
587
|
+
return storage.getItem(SESSION_STORAGE_KEY);
|
|
588
|
+
}
|
|
589
|
+
var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
|
|
590
|
+
var COURSE_STARTED_TRACKING_PREFIX = "lessonkit:course_started_tracking:";
|
|
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
|
+
}
|
|
597
|
+
function resolveSessionId(storage, provided) {
|
|
598
|
+
if (provided !== void 0) {
|
|
599
|
+
const trimmed = provided.trim();
|
|
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
|
+
}
|
|
609
|
+
}
|
|
610
|
+
const existing = storage.getItem(SESSION_STORAGE_KEY);
|
|
611
|
+
if (existing) return existing;
|
|
612
|
+
const volatile = volatileSessionIds.get(storage);
|
|
613
|
+
if (volatile) return volatile;
|
|
614
|
+
const id = createSessionId();
|
|
615
|
+
const persisted = storage.setItem(SESSION_STORAGE_KEY, id);
|
|
616
|
+
if (!persisted) {
|
|
617
|
+
volatileSessionIds.set(storage, id);
|
|
618
|
+
if (isDevEnvironment2()) {
|
|
619
|
+
console.warn(
|
|
620
|
+
"[lessonkit] session id could not be persisted; using in-memory id for this storage."
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
return id;
|
|
624
|
+
}
|
|
625
|
+
return id;
|
|
626
|
+
}
|
|
627
|
+
function courseStartedStorageKey(sessionId, courseId) {
|
|
628
|
+
return `${COURSE_STARTED_PREFIX}${sessionKeySegment(sessionId)}:${courseId ?? ""}`;
|
|
629
|
+
}
|
|
630
|
+
function courseStartedTrackingStorageKey(sessionId, courseId) {
|
|
631
|
+
return `${COURSE_STARTED_TRACKING_PREFIX}${sessionKeySegment(sessionId)}:${courseId ?? ""}`;
|
|
632
|
+
}
|
|
633
|
+
function courseStartedPipelineStorageKey(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 ?? ""}`;
|
|
638
|
+
}
|
|
639
|
+
function hasCourseStarted(storage, sessionId, courseId) {
|
|
640
|
+
if (!courseId) return false;
|
|
641
|
+
return storage.getItem(courseStartedStorageKey(sessionId, courseId)) === "1";
|
|
642
|
+
}
|
|
643
|
+
function markCourseStarted(storage, sessionId, courseId) {
|
|
644
|
+
if (!courseId) return false;
|
|
645
|
+
return storage.setItem(courseStartedStorageKey(sessionId, courseId), "1");
|
|
646
|
+
}
|
|
647
|
+
function hasCourseStartedEmittedToTracking(storage, sessionId, courseId) {
|
|
648
|
+
if (!courseId) return false;
|
|
649
|
+
return storage.getItem(courseStartedTrackingStorageKey(sessionId, courseId)) === "1";
|
|
650
|
+
}
|
|
651
|
+
function markCourseStartedEmittedToTracking(storage, sessionId, courseId) {
|
|
652
|
+
if (!courseId) return false;
|
|
653
|
+
return storage.setItem(courseStartedTrackingStorageKey(sessionId, courseId), "1");
|
|
654
|
+
}
|
|
655
|
+
function hasCourseStartedPipelineDelivered(storage, sessionId, courseId) {
|
|
656
|
+
if (!courseId) return false;
|
|
657
|
+
return storage.getItem(courseStartedPipelineStorageKey(sessionId, courseId)) === "1";
|
|
658
|
+
}
|
|
659
|
+
function markCourseStartedPipelineDelivered(storage, sessionId, courseId) {
|
|
660
|
+
if (!courseId) return false;
|
|
661
|
+
return storage.setItem(courseStartedPipelineStorageKey(sessionId, courseId), "1");
|
|
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
|
+
}
|
|
671
|
+
function resetSharedVolatileSessionIdForTests() {
|
|
672
|
+
}
|
|
673
|
+
function migrateStorageMark(storage, fromKey, toKey, hasMark) {
|
|
674
|
+
if (!hasMark) return;
|
|
675
|
+
if (storage.setItem(toKey, "1")) {
|
|
676
|
+
storage.removeItem?.(fromKey);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
function migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId) {
|
|
680
|
+
if (!courseId || fromSessionId === toSessionId) return;
|
|
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
|
+
);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// src/runtime/courseLifecycle.ts
|
|
708
|
+
var courseStartedEmitFlights = /* @__PURE__ */ new Map();
|
|
709
|
+
function resetCourseStartedEmitFlightForTests() {
|
|
710
|
+
courseStartedEmitFlights.clear();
|
|
711
|
+
}
|
|
712
|
+
function tryEmitCourseStarted(ctx, deps, alreadyEmittedToSink) {
|
|
713
|
+
const flightKey = `${ctx.sessionId}:${ctx.courseId}`;
|
|
714
|
+
const marked = hasCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
|
|
715
|
+
if (alreadyEmittedToSink) {
|
|
716
|
+
return Promise.resolve({ emitted: true, marked });
|
|
717
|
+
}
|
|
718
|
+
const existing = courseStartedEmitFlights.get(flightKey);
|
|
719
|
+
if (existing) {
|
|
720
|
+
return existing;
|
|
721
|
+
}
|
|
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
|
+
}
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
courseStartedEmitFlights.set(flightKey, flight);
|
|
741
|
+
return flight;
|
|
742
|
+
}
|
|
743
|
+
function buildCourseStartedTelemetryEvent(ctx) {
|
|
744
|
+
return buildTelemetryEvent({
|
|
745
|
+
name: "course_started",
|
|
746
|
+
courseId: ctx.courseId,
|
|
747
|
+
sessionId: ctx.sessionId,
|
|
748
|
+
attemptId: ctx.attemptId,
|
|
749
|
+
user: ctx.user
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
function completeLessonWithTelemetry(opts) {
|
|
753
|
+
const result = opts.progress.completeLesson(opts.lessonId, opts.nowMs);
|
|
754
|
+
if (!result.didComplete) return false;
|
|
755
|
+
opts.emitLessonCompleted(opts.lessonId, result.durationMs);
|
|
756
|
+
return true;
|
|
757
|
+
}
|
|
758
|
+
function completeCourseWithTelemetry(opts) {
|
|
759
|
+
const current = opts.progress.getState();
|
|
760
|
+
if (current.activeLessonId) {
|
|
761
|
+
completeLessonWithTelemetry({
|
|
762
|
+
progress: opts.progress,
|
|
763
|
+
lessonId: current.activeLessonId,
|
|
764
|
+
nowMs: opts.nowMs,
|
|
765
|
+
emitLessonCompleted: opts.emitLessonCompleted
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
const result = opts.progress.completeCourse();
|
|
769
|
+
if (!result.didComplete) return false;
|
|
770
|
+
opts.emitCourseCompleted();
|
|
771
|
+
return true;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
export {
|
|
775
|
+
ID_PATTERN,
|
|
776
|
+
ID_MAX_LENGTH,
|
|
777
|
+
validateId,
|
|
778
|
+
parseCourseId,
|
|
779
|
+
parseLessonId,
|
|
780
|
+
parseCheckId,
|
|
781
|
+
parseBlockId,
|
|
782
|
+
assertValidId,
|
|
783
|
+
isDevEnvironment,
|
|
784
|
+
warnDev,
|
|
785
|
+
createSessionId,
|
|
786
|
+
nowIso,
|
|
787
|
+
resetTelemetryBuilderWarningsForTests,
|
|
788
|
+
buildTelemetryEvent,
|
|
789
|
+
tryBuildTelemetryEvent,
|
|
790
|
+
createDefaultClock,
|
|
791
|
+
createNoopStorage,
|
|
792
|
+
resetStoragePortForTests,
|
|
793
|
+
createSessionStoragePort,
|
|
794
|
+
createGlobalTimer,
|
|
795
|
+
SESSION_STORAGE_KEY,
|
|
796
|
+
getTabSessionId,
|
|
797
|
+
resolveSessionId,
|
|
798
|
+
hasCourseStarted,
|
|
799
|
+
markCourseStarted,
|
|
800
|
+
hasCourseStartedEmittedToTracking,
|
|
801
|
+
markCourseStartedEmittedToTracking,
|
|
802
|
+
hasCourseStartedPipelineDelivered,
|
|
803
|
+
markCourseStartedPipelineDelivered,
|
|
804
|
+
hasCourseStartedXapiSent,
|
|
805
|
+
markCourseStartedXapiSent,
|
|
806
|
+
resetSharedVolatileSessionIdForTests,
|
|
807
|
+
migrateCourseStartedMark,
|
|
808
|
+
resetCourseStartedEmitFlightForTests,
|
|
809
|
+
tryEmitCourseStarted,
|
|
810
|
+
buildCourseStartedTelemetryEvent,
|
|
811
|
+
completeLessonWithTelemetry,
|
|
812
|
+
completeCourseWithTelemetry
|
|
813
|
+
};
|