coursecode 0.1.17 → 0.1.19
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.
|
@@ -525,12 +525,12 @@ When cloud meta tags are present, they **always win** — even if `course-config
|
|
|
525
525
|
| Utility | Config Key | Meta Tag | Transport | Events |
|
|
526
526
|
|---------|-----------|----------|-----------|--------|
|
|
527
527
|
| `error-reporter.js` | `environment.errorReporting` | `cc-error-endpoint` | POST per error (60s dedup) | `*:error` (14 event types) |
|
|
528
|
-
| `data-reporter.js` | `environment.dataReporting` | `cc-data-endpoint` | Batched POST + `sendBeacon` on unload | `assessment:submitted`, `objective:updated`, `interaction:recorded` |
|
|
528
|
+
| `data-reporter.js` | `environment.dataReporting` | `cc-data-endpoint` | Batched POST + `sendBeacon` on unload | `assessment:submitted`, `objective:updated`, `interaction:recorded`, `course:statusChanged`, `channel:message` |
|
|
529
529
|
| `course-channel.js` | `environment.channel` | `cc-channel-endpoint` + `cc-channel-id` | POST to send, SSE to receive | `channel:message`, `channel:connected`, `channel:disconnected` |
|
|
530
530
|
|
|
531
531
|
**Error Reporter** — Subscribes to all `*:error` events, deduplicates by domain+operation+message (60s window), POSTs to endpoint. Optional `enableUserReports: true` adds "Report Issue" to settings menu. `submitUserReport()` for programmatic user reports.
|
|
532
532
|
|
|
533
|
-
**Data Reporter** — Queues assessment/objective/interaction records, flushes on batch size (default 10) or timer (default 30s). `sendBeacon` fallback on page unload.
|
|
533
|
+
**Data Reporter** — Queues assessment/objective/interaction/session/channel records, flushes on batch size (default 10) or timer (default 30s). `sendBeacon` fallback on page unload. Also listens to `course:statusChanged` (queues a `session` record on completion) and `channel:message` (queues a `channel` record). Exposes `CourseCode.reportData(type, data)` for course authors to send custom event types.
|
|
534
534
|
|
|
535
535
|
**Course Channel** — Generic pub/sub pipe. `sendChannelMessage(data)` POSTs any JSON to `endpoint/channelId`. SSE listener on same URL bridges incoming messages to EventBus. Exponential backoff reconnect (1s → 30s cap). Content-agnostic — the relay is a dumb fan-out router.
|
|
536
536
|
|
|
@@ -68,7 +68,11 @@ export class Cmi5Driver extends HttpDriverBase {
|
|
|
68
68
|
|
|
69
69
|
// Check for cmi5 dev API (stub player or standalone preview)
|
|
70
70
|
// Search current window and parent frame (stub player injects on parent)
|
|
71
|
-
|
|
71
|
+
// Try/catch guards against DOMException in cross-origin iframes
|
|
72
|
+
let devApi = typeof window !== 'undefined' && window.cmi5;
|
|
73
|
+
if (!devApi && typeof window !== 'undefined' && window.parent !== window) {
|
|
74
|
+
try { devApi = window.parent.cmi5; } catch (_e) { /* cross-origin parent */ }
|
|
75
|
+
}
|
|
72
76
|
if (devApi) {
|
|
73
77
|
logger.info('[Cmi5Driver] Using cmi5 development API');
|
|
74
78
|
this._mock = true;
|
|
@@ -82,12 +86,15 @@ export class Cmi5Driver extends HttpDriverBase {
|
|
|
82
86
|
|
|
83
87
|
// Check for cmi5 launch parameters
|
|
84
88
|
if (!this._hasLaunchParameters()) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
if (import.meta.env.DEV) {
|
|
90
|
+
logger.info('[Cmi5Driver] No cmi5 launch parameters. Using localStorage mock.');
|
|
91
|
+
this._mock = true;
|
|
92
|
+
this._loadMockState();
|
|
93
|
+
this._isConnected = true;
|
|
94
|
+
this._logMockStatement('initialized', { verb: 'initialized' });
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
throw new Error('[Cmi5Driver] No cmi5 launch parameters detected. Expected fetch, endpoint, actor, registration, and activityId URL parameters.');
|
|
91
98
|
}
|
|
92
99
|
|
|
93
100
|
// Production mode: dynamically import @xapi/cmi5
|
|
@@ -70,7 +70,11 @@ export class LtiDriver extends HttpDriverBase {
|
|
|
70
70
|
|
|
71
71
|
// Check for LTI dev API (stub player)
|
|
72
72
|
// Search current window and parent frame (stub player injects on parent)
|
|
73
|
-
|
|
73
|
+
// Try/catch guards against DOMException in cross-origin iframes (LMS embeds)
|
|
74
|
+
let devApi = typeof window !== 'undefined' && window.lti;
|
|
75
|
+
if (!devApi && typeof window !== 'undefined' && window.parent !== window) {
|
|
76
|
+
try { devApi = window.parent.lti; } catch (_e) { /* cross-origin parent */ }
|
|
77
|
+
}
|
|
74
78
|
if (devApi) {
|
|
75
79
|
logger.info('[LtiDriver] Using LTI development API');
|
|
76
80
|
this._mock = true;
|
|
@@ -84,12 +88,15 @@ export class LtiDriver extends HttpDriverBase {
|
|
|
84
88
|
|
|
85
89
|
// Check for LTI launch parameters
|
|
86
90
|
if (!this._hasLaunchParameters()) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
if (import.meta.env.DEV) {
|
|
92
|
+
logger.info('[LtiDriver] No LTI launch parameters. Using localStorage mock.');
|
|
93
|
+
this._mock = true;
|
|
94
|
+
this._loadMockState();
|
|
95
|
+
this._isConnected = true;
|
|
96
|
+
this._logMockStatement('initialized', { verb: 'initialized' });
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
throw new Error('[LtiDriver] No LTI launch context detected. Expected <meta name="lms-format" content="lti"> or id_token/state URL parameters.');
|
|
93
100
|
}
|
|
94
101
|
|
|
95
102
|
// Production mode: validate JWT and extract claims
|
|
@@ -248,6 +255,11 @@ export class LtiDriver extends HttpDriverBase {
|
|
|
248
255
|
_hasLaunchParameters() {
|
|
249
256
|
if (typeof window === 'undefined') return false;
|
|
250
257
|
|
|
258
|
+
// Cloud-hosted: engine handles OIDC server-side, signals via meta tag
|
|
259
|
+
const formatMeta = document.querySelector('meta[name="lms-format"]');
|
|
260
|
+
if (formatMeta?.content === 'lti') return true;
|
|
261
|
+
|
|
262
|
+
// Self-hosted: JWT params in URL from OIDC redirect
|
|
251
263
|
const params = new URLSearchParams(window.location.search);
|
|
252
264
|
return Boolean(
|
|
253
265
|
params.get('id_token') ||
|
|
@@ -257,15 +269,27 @@ export class LtiDriver extends HttpDriverBase {
|
|
|
257
269
|
}
|
|
258
270
|
|
|
259
271
|
async _processLaunch() {
|
|
260
|
-
const { jwtVerify, createRemoteJWKSet } = await import('jose');
|
|
261
|
-
|
|
262
272
|
const params = new URLSearchParams(window.location.search);
|
|
263
273
|
const idToken = params.get('id_token');
|
|
264
274
|
|
|
275
|
+
// Cloud-hosted path: OIDC handled server-side, no JWT in URL
|
|
265
276
|
if (!idToken) {
|
|
266
|
-
|
|
277
|
+
this._claims = this._resolveCloudClaims();
|
|
278
|
+
this._stateEndpoint = this._resolveStateEndpoint();
|
|
279
|
+
|
|
280
|
+
const agsUrl = this._resolveCloudAgsEndpoint();
|
|
281
|
+
if (agsUrl) {
|
|
282
|
+
this._agsLineItemUrl = agsUrl;
|
|
283
|
+
logger.debug('[LtiDriver] Cloud AGS endpoint:', agsUrl);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
logger.debug('[LtiDriver] Cloud-hosted launch. User:', this._claims?.sub || 'unknown');
|
|
287
|
+
return;
|
|
267
288
|
}
|
|
268
289
|
|
|
290
|
+
// Self-hosted path: validate JWT from URL params
|
|
291
|
+
const { jwtVerify, createRemoteJWKSet } = await import('jose');
|
|
292
|
+
|
|
269
293
|
const [headerB64] = idToken.split('.');
|
|
270
294
|
const header = JSON.parse(atob(headerB64));
|
|
271
295
|
|
|
@@ -332,6 +356,37 @@ export class LtiDriver extends HttpDriverBase {
|
|
|
332
356
|
return '/api/lti/state';
|
|
333
357
|
}
|
|
334
358
|
|
|
359
|
+
/**
|
|
360
|
+
* Resolves LTI claims from cloud-injected meta tags or config object.
|
|
361
|
+
* Used when OIDC is handled server-side (no JWT in URL).
|
|
362
|
+
*/
|
|
363
|
+
_resolveCloudClaims() {
|
|
364
|
+
const meta = document.querySelector('meta[name="cc-lti-claims"]');
|
|
365
|
+
if (meta?.content) {
|
|
366
|
+
try {
|
|
367
|
+
return JSON.parse(meta.content);
|
|
368
|
+
} catch (e) {
|
|
369
|
+
logger.warn('[LtiDriver] Failed to parse cc-lti-claims meta tag:', e.message);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (window.__LTI_CONFIG__?.claims) return window.__LTI_CONFIG__.claims;
|
|
374
|
+
|
|
375
|
+
throw new Error('[LtiDriver] Cloud LTI launch detected but no claims provided. Expected <meta name="cc-lti-claims"> or window.__LTI_CONFIG__.claims.');
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Resolves AGS lineitem URL from cloud-injected meta tags or config object.
|
|
380
|
+
*/
|
|
381
|
+
_resolveCloudAgsEndpoint() {
|
|
382
|
+
const meta = document.querySelector('meta[name="cc-lti-ags"]');
|
|
383
|
+
if (meta?.content) return meta.content;
|
|
384
|
+
|
|
385
|
+
if (window.__LTI_CONFIG__?.agsEndpoint) return window.__LTI_CONFIG__.agsEndpoint;
|
|
386
|
+
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
|
|
335
390
|
_getStateKey() {
|
|
336
391
|
if (this._claims) {
|
|
337
392
|
const resourceLink = this._claims['https://purl.imsglobal.org/spec/lti/claim/resource_link']?.id;
|
package/framework/js/main.js
CHANGED
|
@@ -48,7 +48,7 @@ import { logger } from './utilities/logger.js';
|
|
|
48
48
|
import { iconManager } from './utilities/icons.js';
|
|
49
49
|
import { breakpointManager } from './utilities/breakpoint-manager.js';
|
|
50
50
|
import { initErrorReporter } from './utilities/error-reporter.js';
|
|
51
|
-
import { initDataReporter } from './utilities/data-reporter.js';
|
|
51
|
+
import { initDataReporter, reportData } from './utilities/data-reporter.js';
|
|
52
52
|
import { initCourseChannel } from './utilities/course-channel.js';
|
|
53
53
|
import { canvasSlide } from './utilities/canvas-slide.js';
|
|
54
54
|
|
|
@@ -98,7 +98,10 @@ window.CourseCode = {
|
|
|
98
98
|
|
|
99
99
|
// Core
|
|
100
100
|
eventBus,
|
|
101
|
-
courseConfig
|
|
101
|
+
courseConfig,
|
|
102
|
+
|
|
103
|
+
// Data reporting public API
|
|
104
|
+
reportData,
|
|
102
105
|
};
|
|
103
106
|
|
|
104
107
|
// --- Conditional Automation Module Loading ---
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Data Reporter - Optional external learning data reporting via webhook
|
|
3
3
|
*
|
|
4
|
-
* Batches important learning records (assessments, objectives, interactions
|
|
5
|
-
*
|
|
4
|
+
* Batches important learning records (assessments, objectives, interactions,
|
|
5
|
+
* session completion, channel messages, and custom events) and sends them
|
|
6
|
+
* to a configured endpoint. Works across all LMS formats.
|
|
6
7
|
*
|
|
7
8
|
* Configuration in course-config.js:
|
|
8
9
|
* environment: {
|
|
@@ -13,6 +14,9 @@
|
|
|
13
14
|
* includeContext: true // Include course metadata (default: true)
|
|
14
15
|
* }
|
|
15
16
|
* }
|
|
17
|
+
*
|
|
18
|
+
* Public API:
|
|
19
|
+
* CourseCode.reportData(type, data) — Queue a custom record for reporting
|
|
16
20
|
*/
|
|
17
21
|
|
|
18
22
|
import { eventBus } from '../core/event-bus.js';
|
|
@@ -39,7 +43,7 @@ const DEFAULT_FLUSH_INTERVAL = 30000; // 30 seconds
|
|
|
39
43
|
|
|
40
44
|
/**
|
|
41
45
|
* Queue a record for batched sending
|
|
42
|
-
* @param {string} type - Record type
|
|
46
|
+
* @param {string} type - Record type (assessment, objective, interaction, session, channel, or custom)
|
|
43
47
|
* @param {Object} data - Record data
|
|
44
48
|
*/
|
|
45
49
|
function queueRecord(type, data) {
|
|
@@ -230,6 +234,26 @@ function handleInteractionRecorded(interaction) {
|
|
|
230
234
|
});
|
|
231
235
|
}
|
|
232
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Handle course completion / status change
|
|
239
|
+
* Only reports when the course reaches 'completed' status.
|
|
240
|
+
*/
|
|
241
|
+
function handleCourseStatusChanged({ completionStatus, successStatus }) {
|
|
242
|
+
if (completionStatus !== 'completed') return;
|
|
243
|
+
|
|
244
|
+
queueRecord('session', {
|
|
245
|
+
completionStatus,
|
|
246
|
+
successStatus: successStatus || 'unknown'
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Handle incoming channel messages — log them as 'channel' records
|
|
252
|
+
*/
|
|
253
|
+
function handleChannelMessage(data) {
|
|
254
|
+
queueRecord('channel', data);
|
|
255
|
+
}
|
|
256
|
+
|
|
233
257
|
/**
|
|
234
258
|
* Handle session termination - flush remaining records
|
|
235
259
|
*/
|
|
@@ -282,10 +306,12 @@ export function initDataReporter(courseConfig) {
|
|
|
282
306
|
|
|
283
307
|
logger.info('[DataReporter] Initialized with endpoint:', config.endpoint);
|
|
284
308
|
|
|
285
|
-
// Subscribe to
|
|
309
|
+
// Subscribe to learning events
|
|
286
310
|
eventBus.on('assessment:submitted', handleAssessmentSubmitted);
|
|
287
311
|
eventBus.on('objective:updated', handleObjectiveUpdated);
|
|
288
312
|
eventBus.on('interaction:recorded', handleInteractionRecorded);
|
|
313
|
+
eventBus.on('course:statusChanged', handleCourseStatusChanged);
|
|
314
|
+
eventBus.on('channel:message', handleChannelMessage);
|
|
289
315
|
eventBus.on('session:beforeTerminate', handleBeforeTerminate);
|
|
290
316
|
|
|
291
317
|
// Emergency flush on page hide (catches tab close, navigation away)
|
|
@@ -294,3 +320,23 @@ export function initDataReporter(courseConfig) {
|
|
|
294
320
|
window.addEventListener('beforeunload', emergencyFlush);
|
|
295
321
|
}
|
|
296
322
|
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Public API: Queue a custom data record for reporting.
|
|
326
|
+
* Exposed on window.CourseCode.reportData so course authors can send
|
|
327
|
+
* arbitrary events to the configured data endpoint.
|
|
328
|
+
*
|
|
329
|
+
* @param {string} type - Record type name (e.g. 'custom-metric', 'survey-response')
|
|
330
|
+
* @param {Object} data - Record data payload
|
|
331
|
+
*/
|
|
332
|
+
export function reportData(type, data) {
|
|
333
|
+
if (!type || typeof type !== 'string') {
|
|
334
|
+
logger.warn('[DataReporter] reportData: type must be a non-empty string');
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (!data || typeof data !== 'object') {
|
|
338
|
+
logger.warn('[DataReporter] reportData: data must be an object');
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
queueRecord(type, data);
|
|
342
|
+
}
|