tessera-learn 0.2.3 → 0.4.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.
Files changed (57) hide show
  1. package/AGENTS.md +50 -21
  2. package/README.md +2 -2
  3. package/dist/{audit--fSWIOgK.js → audit-DsYqXbqm.js} +282 -197
  4. package/dist/audit-DsYqXbqm.js.map +1 -0
  5. package/dist/{build-commands-Qyrlsp3n.js → build-commands-BFuiAxaR.js} +4 -4
  6. package/dist/build-commands-BFuiAxaR.js.map +1 -0
  7. package/dist/{inline-config-DqAKsCNl.js → inline-config-DVvOCKht.js} +6 -6
  8. package/dist/inline-config-DVvOCKht.js.map +1 -0
  9. package/dist/plugin/cli.d.ts +5 -1
  10. package/dist/plugin/cli.d.ts.map +1 -1
  11. package/dist/plugin/cli.js +91 -49
  12. package/dist/plugin/cli.js.map +1 -1
  13. package/dist/plugin/index.d.ts +287 -2
  14. package/dist/plugin/index.d.ts.map +1 -1
  15. package/dist/plugin/index.js +3 -3
  16. package/dist/{plugin-B-aiL9-V.js → plugin-BuMiDTmU.js} +145 -111
  17. package/dist/plugin-BuMiDTmU.js.map +1 -0
  18. package/package.json +7 -7
  19. package/src/components/DefaultLayout.svelte +2 -5
  20. package/src/components/MultipleChoice.svelte +1 -2
  21. package/src/components/Quiz.svelte +18 -26
  22. package/src/plugin/ast.ts +9 -2
  23. package/src/plugin/build-commands.ts +7 -4
  24. package/src/plugin/cli.ts +96 -46
  25. package/src/plugin/csp.ts +59 -0
  26. package/src/plugin/duplicate-cli.ts +37 -1
  27. package/src/plugin/export.ts +56 -27
  28. package/src/plugin/index.ts +138 -93
  29. package/src/plugin/inline-config.ts +4 -2
  30. package/src/plugin/manifest.ts +24 -23
  31. package/src/plugin/new-cli.ts +2 -0
  32. package/src/plugin/validate-cli.ts +5 -2
  33. package/src/plugin/validation.ts +255 -238
  34. package/src/runtime/App.svelte +14 -9
  35. package/src/runtime/Sidebar.svelte +3 -1
  36. package/src/runtime/adapters/cmi5.ts +59 -402
  37. package/src/runtime/adapters/discovery.ts +11 -0
  38. package/src/runtime/adapters/index.ts +27 -60
  39. package/src/runtime/adapters/lms-error.ts +61 -0
  40. package/src/runtime/adapters/scorm-base.ts +15 -14
  41. package/src/runtime/adapters/scorm12.ts +6 -25
  42. package/src/runtime/adapters/scorm2004.ts +12 -54
  43. package/src/runtime/adapters/web.ts +11 -4
  44. package/src/runtime/adapters/xapi-launch-base.ts +346 -0
  45. package/src/runtime/adapters/xapi.ts +26 -0
  46. package/src/runtime/fingerprint.ts +28 -0
  47. package/src/runtime/interaction-format.ts +0 -1
  48. package/src/runtime/persistence.ts +4 -0
  49. package/src/runtime/types.ts +22 -1
  50. package/src/runtime/xapi/publisher.ts +16 -15
  51. package/src/runtime/xapi/setup.ts +24 -15
  52. package/src/virtual.d.ts +4 -1
  53. package/templates/course/course.config.js +1 -0
  54. package/dist/audit--fSWIOgK.js.map +0 -1
  55. package/dist/build-commands-Qyrlsp3n.js.map +0 -1
  56. package/dist/inline-config-DqAKsCNl.js.map +0 -1
  57. package/dist/plugin-B-aiL9-V.js.map +0 -1
@@ -0,0 +1,346 @@
1
+ import type { PersistenceAdapter, SavedState } from '../persistence.js';
2
+ import type { Interaction } from '../interaction.js';
3
+ import {
4
+ formatResponse,
5
+ formatCorrectPattern,
6
+ XAPI_INTERACTION_FORMAT,
7
+ } from '../interaction-format.js';
8
+ import { formatISO8601Duration } from './format.js';
9
+ import { XAPIPublisher } from '../xapi/publisher.js';
10
+ import type { XAPIAgent, PartialStatement } from '../xapi/types.js';
11
+
12
+ export const VERBS = {
13
+ initialized: 'http://adlnet.gov/expapi/verbs/initialized',
14
+ answered: 'http://adlnet.gov/expapi/verbs/answered',
15
+ completed: 'http://adlnet.gov/expapi/verbs/completed',
16
+ passed: 'http://adlnet.gov/expapi/verbs/passed',
17
+ failed: 'http://adlnet.gov/expapi/verbs/failed',
18
+ terminated: 'http://adlnet.gov/expapi/verbs/terminated',
19
+ } as const;
20
+
21
+ const CMI_INTERACTION_TYPE =
22
+ 'http://adlnet.gov/expapi/activities/cmi.interaction';
23
+
24
+ /**
25
+ * Version-neutral xAPI launch lifecycle shared by the cmi5 and plain-xAPI
26
+ * adapters. Subclasses set the protected fields in init() and may override
27
+ * buildContext()/isDefinedStatementAllowed()/scoreForSuccess() to layer
28
+ * profile rules on top.
29
+ */
30
+ export abstract class BaseXAPILaunchAdapter implements PersistenceAdapter {
31
+ protected publisher: XAPIPublisher | null = null;
32
+ protected endpoint = '';
33
+ protected activityId = '';
34
+ protected actor: XAPIAgent | null = null;
35
+ protected registration: string | undefined;
36
+ protected authToken = '';
37
+ protected version = '1.0.3';
38
+ /** Prefix for this adapter's console warnings (e.g. "cmi5", "xAPI"). */
39
+ protected logName = 'xAPI';
40
+
41
+ protected score: number | null = null;
42
+ protected durationSeconds = 0;
43
+ protected state: SavedState | null = null;
44
+ protected completedEmitted = false;
45
+ protected lastSuccessEmitted: 'unknown' | 'passed' | 'failed' = 'unknown';
46
+ protected terminated = false;
47
+ protected returnURL: string | undefined;
48
+
49
+ abstract init(): Promise<void>;
50
+
51
+ /** Profile context for a Defined Statement. Plain xAPI adds nothing — the publisher injects context.registration on its own. */
52
+ protected buildContext(
53
+ _opts: { moveOn?: boolean; mastery?: boolean } = {},
54
+ ): Record<string, unknown> | undefined {
55
+ return undefined;
56
+ }
57
+
58
+ /** cmi5 Browse/Review gating hook. Plain xAPI always allows. */
59
+ protected isDefinedStatementAllowed(): boolean {
60
+ return true;
61
+ }
62
+
63
+ /** Scaled score to attach to Passed/Failed, or null to omit. cmi5 overrides for masteryScore gating. */
64
+ protected scoreForSuccess(_status: 'passed' | 'failed'): number | null {
65
+ return this.score !== null ? this.score / 100 : null;
66
+ }
67
+
68
+ getPublisher(): XAPIPublisher | null {
69
+ return this.publisher;
70
+ }
71
+
72
+ getState(): SavedState | null {
73
+ return this.state;
74
+ }
75
+
76
+ saveState(state: SavedState): void {
77
+ this.state = state;
78
+ if (!this.publisher) return;
79
+ void this.publisher.chainTask(async () => {
80
+ try {
81
+ const resp = await this.xapiFetch(this.buildStateUrl(), {
82
+ method: 'PUT',
83
+ headers: { 'Content-Type': 'application/json' },
84
+ body: JSON.stringify(state),
85
+ });
86
+ if (!resp.ok) {
87
+ console.warn(
88
+ `Tessera ${this.logName}: State API PUT returned ${resp.status}; learner progress did not persist.`,
89
+ );
90
+ }
91
+ } catch (err) {
92
+ console.warn(`Tessera ${this.logName}: Failed to save state`, err);
93
+ }
94
+ });
95
+ }
96
+
97
+ setScore(score: number): void {
98
+ if (!Number.isFinite(score)) {
99
+ this.score = null;
100
+ return;
101
+ }
102
+ this.score = Math.max(0, Math.min(100, score));
103
+ }
104
+
105
+ setDuration(seconds: number): void {
106
+ this.durationSeconds = seconds;
107
+ }
108
+
109
+ setExit(_mode: 'suspend' | 'normal'): void {
110
+ // No cmi.exit analogue in xAPI; suspend is implicit. No-op.
111
+ }
112
+
113
+ commit(): void {
114
+ // Statements are sent individually. No-op.
115
+ }
116
+
117
+ seedLifecycle(
118
+ completion: 'incomplete' | 'complete',
119
+ success: 'unknown' | 'passed' | 'failed',
120
+ ): void {
121
+ if (completion === 'complete') this.completedEmitted = true;
122
+ if (success === 'passed' || success === 'failed') {
123
+ this.lastSuccessEmitted = success;
124
+ }
125
+ }
126
+
127
+ setCompletionStatus(status: 'incomplete' | 'complete'): void {
128
+ if (status !== 'complete' || this.completedEmitted || !this.publisher)
129
+ return;
130
+ if (!this.isDefinedStatementAllowed()) return;
131
+ this.completedEmitted = true;
132
+ const result: Record<string, unknown> = {
133
+ completion: true,
134
+ duration: formatISO8601Duration(this.durationSeconds),
135
+ };
136
+ this.dispatch('Completed', {
137
+ verb: { id: VERBS.completed, display: { 'en-US': 'completed' } },
138
+ result,
139
+ context: this.buildContext({ moveOn: true }),
140
+ });
141
+ }
142
+
143
+ setSuccessStatus(status: 'passed' | 'failed' | 'unknown'): void {
144
+ if (status === 'unknown' || !this.publisher) return;
145
+ if (status === this.lastSuccessEmitted) return;
146
+ if (!this.isDefinedStatementAllowed()) return;
147
+ this.lastSuccessEmitted = status;
148
+
149
+ const verb = status === 'passed' ? VERBS.passed : VERBS.failed;
150
+ const verbName = status === 'passed' ? 'passed' : 'failed';
151
+ const result: Record<string, unknown> = {
152
+ success: status === 'passed',
153
+ duration: formatISO8601Duration(this.durationSeconds),
154
+ };
155
+ const scaled = this.scoreForSuccess(status);
156
+ if (scaled !== null) result.score = { scaled };
157
+ this.dispatch(status === 'passed' ? 'Passed' : 'Failed', {
158
+ verb: { id: verb, display: { 'en-US': verbName } },
159
+ result,
160
+ context: this.buildContext({ moveOn: true, mastery: true }),
161
+ });
162
+ }
163
+
164
+ reportInteraction(
165
+ questionId: string,
166
+ interaction: Interaction,
167
+ correct: boolean | null,
168
+ ): void {
169
+ if (!this.publisher) return;
170
+ const response = formatResponse(interaction, XAPI_INTERACTION_FORMAT);
171
+ const pattern = formatCorrectPattern(interaction, XAPI_INTERACTION_FORMAT);
172
+ const definition: Record<string, unknown> = {
173
+ type: CMI_INTERACTION_TYPE,
174
+ interactionType: interaction.type,
175
+ };
176
+ if (pattern !== null) {
177
+ definition.correctResponsesPattern = [pattern];
178
+ }
179
+ const result: Record<string, unknown> = { response };
180
+ if (correct !== null) {
181
+ result.success = correct;
182
+ }
183
+ this.dispatch('Answered', {
184
+ verb: { id: VERBS.answered, display: { 'en-US': 'answered' } },
185
+ object: {
186
+ id: `${this.activityId}#${questionId}`,
187
+ objectType: 'Activity',
188
+ definition,
189
+ },
190
+ result,
191
+ });
192
+ }
193
+
194
+ terminate(): void {
195
+ if (this.terminated) return;
196
+ this.terminated = true;
197
+ if (!this.publisher) return;
198
+ this.publisher.markUnloading();
199
+ const duration = formatISO8601Duration(this.durationSeconds);
200
+ this.dispatch('Terminated', {
201
+ verb: { id: VERBS.terminated, display: { 'en-US': 'terminated' } },
202
+ result: { duration },
203
+ context: this.buildContext(),
204
+ });
205
+ }
206
+
207
+ async exit(): Promise<void> {
208
+ this.terminate();
209
+ if (this.publisher) {
210
+ try {
211
+ await this.publisher.chainTask(async () => {});
212
+ } catch {
213
+ // never rejects today; don't block redirect.
214
+ }
215
+ }
216
+ if (this.returnURL && typeof window !== 'undefined') {
217
+ window.location.assign(this.returnURL);
218
+ }
219
+ }
220
+
221
+ /** Parse the launch `actor` param into an Identified Agent, failing loud on malformed JSON. */
222
+ protected parseActorParam(raw: string): void {
223
+ try {
224
+ const parsed = JSON.parse(raw);
225
+ if (!parsed || typeof parsed !== 'object') {
226
+ throw new Error('actor must be an object');
227
+ }
228
+ this.actor = parsed as XAPIAgent;
229
+ } catch (err) {
230
+ throw new Error(
231
+ `Tessera ${this.logName}: launch parameter 'actor' is malformed (${err instanceof Error ? err.message : String(err)}). The LMS did not send a valid Identified Agent JSON.`,
232
+ { cause: err },
233
+ );
234
+ }
235
+ }
236
+
237
+ /** Construct the publisher from the resolved launch fields plus per-profile options. */
238
+ protected createPublisher(opts: {
239
+ sessionId?: string;
240
+ cmi5Mode?: boolean;
241
+ }): XAPIPublisher {
242
+ if (!this.actor) {
243
+ throw new Error(
244
+ `Tessera ${this.logName}: cannot create publisher before the launch actor is resolved.`,
245
+ );
246
+ }
247
+ this.publisher = new XAPIPublisher({
248
+ endpoint: this.endpoint,
249
+ auth: this.authToken,
250
+ actor: this.actor,
251
+ activityId: this.activityId,
252
+ registration: this.registration,
253
+ version: this.version,
254
+ ...opts,
255
+ });
256
+ return this.publisher;
257
+ }
258
+
259
+ /** Fire-and-forget Initialized statement (the first Defined Statement of the session). */
260
+ protected sendInitialized(): void {
261
+ this.dispatch('Initialized', {
262
+ verb: { id: VERBS.initialized, display: { 'en-US': 'initialized' } },
263
+ context: this.buildContext(),
264
+ });
265
+ }
266
+
267
+ /** Enqueue a lifecycle statement fire-and-forget. `label` names it in both the LRS-reject and send-failure warnings. */
268
+ protected dispatch(label: string, partial: PartialStatement): void {
269
+ if (!this.publisher) return;
270
+ this.publisher
271
+ .sendStatement(partial)
272
+ .then(this.warnOnLRSReject(label))
273
+ .catch((err) => {
274
+ console.warn(
275
+ `Tessera ${this.logName}: failed to send ${label} statement`,
276
+ err,
277
+ );
278
+ });
279
+ }
280
+
281
+ /** `.then` handler that warns on LRS non-2xx. The publisher resolves successfully on 4xx/5xx (failure is in the destination outcome), so `.catch` alone misses them. */
282
+ protected warnOnLRSReject(
283
+ label: string,
284
+ ): (res: {
285
+ destinations?: Array<{ ok?: boolean; status?: number; error?: Error }>;
286
+ }) => void {
287
+ const logName = this.logName;
288
+ return (res) => {
289
+ const dest = res.destinations?.[0];
290
+ if (dest && !dest.ok) {
291
+ console.warn(
292
+ `Tessera ${logName}: ${label} statement rejected by LRS (${dest.status ?? 'network error'})`,
293
+ dest.error,
294
+ );
295
+ }
296
+ };
297
+ }
298
+
299
+ protected buildStateUrl(stateId: string = 'tessera-state'): string {
300
+ const params = new URLSearchParams({
301
+ activityId: this.activityId,
302
+ agent: JSON.stringify(this.actor),
303
+ stateId,
304
+ });
305
+ if (this.registration) params.set('registration', this.registration);
306
+ return `${this.endpoint}activities/state?${params.toString()}`;
307
+ }
308
+
309
+ protected async xapiFetch(
310
+ url: string,
311
+ options: RequestInit = {},
312
+ ): Promise<Response> {
313
+ const headers = new Headers(options.headers);
314
+ if (this.authToken) {
315
+ headers.set('Authorization', `Basic ${this.authToken}`);
316
+ }
317
+ headers.set('X-Experience-API-Version', this.version);
318
+ const keepalive = this.publisher?.isUnloading() ?? false;
319
+ return fetch(url, {
320
+ ...options,
321
+ headers,
322
+ ...(keepalive ? { keepalive: true } : {}),
323
+ });
324
+ }
325
+
326
+ /** Shared resume GET — call from a subclass init() after the publisher exists. */
327
+ protected async loadResumeState(): Promise<void> {
328
+ try {
329
+ const resp = await this.xapiFetch(this.buildStateUrl(), {
330
+ method: 'GET',
331
+ });
332
+ if (resp.ok) {
333
+ this.state = await resp.json();
334
+ } else if (resp.status !== 404) {
335
+ console.warn(
336
+ `Tessera ${this.logName}: State API GET returned ${resp.status}; resume disabled for this launch.`,
337
+ );
338
+ }
339
+ } catch (err) {
340
+ console.warn(
341
+ `Tessera ${this.logName}: State API GET failed (${err instanceof Error ? err.message : String(err)}); resume disabled for this launch.`,
342
+ );
343
+ this.state = null;
344
+ }
345
+ }
346
+ }
@@ -0,0 +1,26 @@
1
+ import { BaseXAPILaunchAdapter } from './xapi-launch-base.js';
2
+
3
+ /**
4
+ * Plain xAPI ("Tin Can") launch adapter. Reads launch params straight off the
5
+ * URL — no cmi5 fetch-token, no LMS.LaunchData, no cmi5 context.
6
+ */
7
+ export class XAPIAdapter extends BaseXAPILaunchAdapter {
8
+ async init(): Promise<void> {
9
+ const params = new URLSearchParams(window.location.search);
10
+ this.endpoint = (params.get('endpoint') || '').replace(/\/?$/, '/');
11
+ // Tin Can uses snake_case `activity_id` (NOT cmi5's camelCase `activityId`).
12
+ this.activityId = params.get('activity_id') || '';
13
+ const reg = params.get('registration') || '';
14
+ this.registration = reg ? reg : undefined;
15
+ // Tin Can launch passes `auth` as the full "Basic <base64>" header value;
16
+ // strip the scheme so we don't double-prefix it when sending.
17
+ this.authToken = (params.get('auth') || '').replace(/^Basic\s+/i, '');
18
+ this.parseActorParam(params.get('actor') || '');
19
+
20
+ const publisher = this.createPublisher({});
21
+ await publisher.init();
22
+
23
+ this.sendInitialized();
24
+ await this.loadResumeState();
25
+ }
26
+ }
@@ -0,0 +1,28 @@
1
+ import type { Manifest } from '../plugin/manifest.js';
2
+ import type { SavedState } from './persistence.js';
3
+
4
+ // FNV-1a over the ordered page slugs. SavedState is keyed by page index, so a
5
+ // structure change must change the fingerprint — else stale state restores onto
6
+ // the wrong pages. Slugs can't contain a NUL, so it's a collision-proof delimiter.
7
+ export function structureFingerprint(manifest: Manifest): string {
8
+ const slugs = manifest.pages.map((p) => p.slug).join('\0');
9
+ let h = 0x811c9dc5;
10
+ for (let i = 0; i < slugs.length; i++) {
11
+ h ^= slugs.charCodeAt(i);
12
+ h = Math.imul(h, 0x01000193);
13
+ }
14
+ return (h >>> 0).toString(36);
15
+ }
16
+
17
+ // `never` always starts fresh; otherwise a saved fingerprint that no longer
18
+ // matches the current structure is discarded. State saved before fingerprinting
19
+ // (no `f`) is trusted so upgrading the runtime never wipes an in-progress learner.
20
+ export function shouldRestore(
21
+ saved: SavedState,
22
+ currentFingerprint: string,
23
+ resume: 'auto' | 'never' = 'auto',
24
+ ): boolean {
25
+ if (resume === 'never') return false;
26
+ if (saved.f !== undefined && saved.f !== currentFingerprint) return false;
27
+ return true;
28
+ }
@@ -165,7 +165,6 @@ export function formatCorrectPattern(
165
165
  export function scorm12Type(type: Interaction['type']): string {
166
166
  switch (type) {
167
167
  case 'long-fill-in':
168
- return 'fill-in';
169
168
  case 'other':
170
169
  return 'fill-in';
171
170
  default:
@@ -60,4 +60,8 @@ export interface SavedState {
60
60
  gs?: number[];
61
61
  /** Manual completion latch. 1 if the learner triggered manual completion. Absent otherwise. */
62
62
  m?: 1;
63
+ /** Structure fingerprint (FNV-1a over ordered page slugs) at save time.
64
+ * On resume, a mismatch discards the blob — the course structure changed.
65
+ * Absent on state saved before fingerprinting; treated as a match. */
66
+ f?: string;
63
67
  }
@@ -9,6 +9,15 @@ import type { XAPIAgent } from './xapi/types.js';
9
9
  export const FEEDBACK_MODES = ['review', 'immediate', 'never'] as const;
10
10
  export const RETRY_MODES = ['full', 'incorrect-only'] as const;
11
11
 
12
+ /**
13
+ * Trimmed course identity, or '' when absent. Single source of truth for the
14
+ * "is there a usable id?" check shared by the web storage key, the cmi5/xAPI
15
+ * id derivation, and the config validator.
16
+ */
17
+ export function courseIdentity(config: { id?: unknown }): string {
18
+ return (typeof config.id === 'string' && config.id.trim()) || '';
19
+ }
20
+
12
21
  /**
13
22
  * Per-page quiz configuration. Single source of truth — the build plugin
14
23
  * extracts this from `pageConfig.quiz` and embeds it in the manifest;
@@ -24,9 +33,16 @@ export interface QuizConfig {
24
33
 
25
34
  export interface CourseConfig {
26
35
  title: string;
36
+ /** Stable, unique course identity (e.g. 'urn:uuid:…'). Seeds the web
37
+ * localStorage key and the cmi5/xAPI LRS activity id; scaffolders generate one.
38
+ * Absent → both fall back to a fixed value, colliding across courses. */
39
+ id?: string;
27
40
  description?: string;
28
41
  author?: string;
29
42
  version?: string;
43
+ /** Resume policy. 'auto' (default) restores saved progress unless the page
44
+ * structure changed since it was saved; 'never' always starts fresh. */
45
+ resume?: 'auto' | 'never';
30
46
  /** BCP-47 language tag for <html lang>. Defaults to 'en'. WCAG 3.1.1. */
31
47
  language?: string;
32
48
  /** Accessibility checker configuration. */
@@ -46,7 +62,12 @@ export interface CourseConfig {
46
62
  passingScore: number;
47
63
  };
48
64
  export: {
49
- standard: 'web' | 'scorm12' | 'scorm2004' | 'cmi5';
65
+ standard: 'web' | 'scorm12' | 'scorm2004' | 'cmi5' | 'xapi';
66
+ /** Web export only: extend the baseline Content-Security-Policy. Each key is
67
+ * a directive; its sources are appended (unioned) onto the baseline. `false`
68
+ * drops the CSP meta entirely (for deployments that set a CSP header).
69
+ * Ignored unless `standard` is 'web'. */
70
+ csp?: false | Record<string, string[]>;
50
71
  };
51
72
  /**
52
73
  * Optional xAPI destination(s) for custom statement publishing via
@@ -46,6 +46,8 @@ export interface XAPIPublisherOptions {
46
46
  * Set by the cmi5 adapter and by 'lms'-inherited destinations under cmi5.
47
47
  */
48
48
  cmi5Mode?: boolean;
49
+ /** xAPI version for the X-Experience-API-Version header. Defaults to X_API_VERSION (1.0.3). */
50
+ version?: string;
49
51
  /** When set, every send method rejects with the returned Error without hitting the network. */
50
52
  unavailableReason?: () => Error;
51
53
  }
@@ -94,6 +96,7 @@ export class XAPIPublisher {
94
96
  readonly #registration?: string;
95
97
  readonly #sessionId: string;
96
98
  readonly #cmi5Mode: boolean;
99
+ readonly #version: string;
97
100
 
98
101
  // When set, every send method short-circuits with a rejected promise.
99
102
  readonly #unavailableReason: (() => Error) | null;
@@ -138,6 +141,7 @@ export class XAPIPublisher {
138
141
  this.#activityId = opts.activityId;
139
142
  this.#registration = opts.registration;
140
143
  this.#cmi5Mode = !!opts.cmi5Mode;
144
+ this.#version = opts.version ?? X_API_VERSION;
141
145
  this.#authValue = opts.auth;
142
146
  this.#actorValue = opts.actor;
143
147
  this.#sessionId = opts.sessionId ?? uuidv4();
@@ -485,22 +489,26 @@ export class XAPIPublisher {
485
489
  #buildHeaders(token: string): Headers {
486
490
  const headers = new Headers();
487
491
  if (token) headers.set('Authorization', `Basic ${token}`);
488
- headers.set('X-Experience-API-Version', X_API_VERSION);
492
+ headers.set('X-Experience-API-Version', this.#version);
489
493
  headers.set('Content-Type', 'application/json');
490
494
  return headers;
491
495
  }
492
496
 
493
- #fetchWithToken(
494
- token: string,
495
- body: string,
496
- keepalive: boolean,
497
- ): Promise<SendOutcome> {
497
+ #post(token: string, body: string, keepalive: boolean): Promise<Response> {
498
498
  return fetch(this.#statementsUrl, {
499
499
  method: 'POST',
500
500
  headers: this.#buildHeaders(token),
501
501
  body,
502
502
  keepalive,
503
- })
503
+ });
504
+ }
505
+
506
+ #fetchWithToken(
507
+ token: string,
508
+ body: string,
509
+ keepalive: boolean,
510
+ ): Promise<SendOutcome> {
511
+ return this.#post(token, body, keepalive)
504
512
  .then((resp) => this.#handleResponse(resp, body, keepalive))
505
513
  .catch((err) => ({
506
514
  ok: false,
@@ -525,14 +533,7 @@ export class XAPIPublisher {
525
533
  ) {
526
534
  this.#cachedAuth = null;
527
535
  return this.#resolveAuth(true)
528
- .then((newToken) =>
529
- fetch(this.#statementsUrl, {
530
- method: 'POST',
531
- headers: this.#buildHeaders(newToken),
532
- body,
533
- keepalive,
534
- }),
535
- )
536
+ .then((newToken) => this.#post(newToken, body, keepalive))
536
537
  .then((retryResp): SendOutcome => {
537
538
  if (retryResp.ok || retryResp.status === 409) {
538
539
  return { ok: true, status: retryResp.status };
@@ -7,18 +7,19 @@ import {
7
7
  synthesizeSCORM12Actor,
8
8
  synthesizeSCORM2004Actor,
9
9
  } from './derive-actor.js';
10
- import { CMI5Adapter } from '../adapters/cmi5.js';
10
+ import { BaseXAPILaunchAdapter } from '../adapters/xapi-launch-base.js';
11
11
  import { SCORM12Adapter } from '../adapters/scorm12.js';
12
12
  import { SCORM2004Adapter } from '../adapters/scorm2004.js';
13
13
 
14
14
  /**
15
15
  * Wraps a value that the runtime knows how to materialize into an
16
16
  * `XAPIPublisher`. Either a fresh publisher constructed for an explicit
17
- * destination, or a reference to the cmi5 adapter's existing publisher
18
- * (for the `endpoint: 'lms'` sentinel — same instance, shared queue).
17
+ * destination, or a reference to a launch adapter's existing publisher
18
+ * (for the `endpoint: 'lms'` sentinel — same instance, shared queue). Both
19
+ * the cmi5 and plain-xAPI adapters expose that publisher via the base.
19
20
  */
20
21
  type DestinationSource =
21
- | { kind: 'lms-shared'; adapter: CMI5Adapter }
22
+ | { kind: 'lms-shared'; adapter: BaseXAPILaunchAdapter }
22
23
  | { kind: 'explicit'; publisher: XAPIPublisher };
23
24
 
24
25
  /**
@@ -29,10 +30,13 @@ type DestinationSource =
29
30
  * produces the "works in dev, silently broken in prod" footgun.
30
31
  */
31
32
  class XAPIDevFallbackError extends Error {
32
- constructor() {
33
+ constructor(standard: 'cmi5' | 'xapi') {
34
+ const missing =
35
+ standard === 'cmi5'
36
+ ? 'cmi5 launch parameters (fetch / endpoint / activityId / actor)'
37
+ : 'xAPI launch parameters (endpoint / auth / actor / activity_id)';
33
38
  super(
34
- "Tessera xAPI: xapi.endpoint is 'lms' but no cmi5 launch parameters " +
35
- '(fetch / endpoint / activityId / actor) were present on the URL. ' +
39
+ `Tessera xAPI: xapi.endpoint is 'lms' but no ${missing} were present on the URL. ` +
36
40
  'Either launch this course from a real LMS / SCORM Cloud, or ' +
37
41
  'temporarily change xapi.endpoint to an explicit URL pointed at a ' +
38
42
  'local LRS (e.g. http://localhost:8080/data/xAPI/) for dev work.',
@@ -59,8 +63,8 @@ function makeRejectingPublisher(error: () => Error): XAPIPublisher {
59
63
  });
60
64
  }
61
65
 
62
- function makeDevFallbackPublisher(): XAPIPublisher {
63
- return makeRejectingPublisher(() => new XAPIDevFallbackError());
66
+ function makeDevFallbackPublisher(standard: 'cmi5' | 'xapi'): XAPIPublisher {
67
+ return makeRejectingPublisher(() => new XAPIDevFallbackError(standard));
64
68
  }
65
69
 
66
70
  class XAPISCORMDevFallbackError extends Error {
@@ -100,20 +104,21 @@ function resolveDestination(
100
104
  adapter: PersistenceAdapter | null,
101
105
  ): DestinationSource | null {
102
106
  if (entry.endpoint === 'lms') {
103
- if (config.export?.standard !== 'cmi5') {
107
+ const standard = config.export?.standard;
108
+ if (standard !== 'cmi5' && standard !== 'xapi') {
104
109
  // Build-time validator should reject this; defense in depth at runtime.
105
110
  console.warn(
106
- "Tessera xAPI: ignoring xapi entry with endpoint: 'lms' under non-cmi5 export.",
111
+ "Tessera xAPI: ignoring xapi entry with endpoint: 'lms' under a non-launch export.",
107
112
  );
108
113
  return null;
109
114
  }
110
- if (adapter instanceof CMI5Adapter) {
115
+ if (adapter instanceof BaseXAPILaunchAdapter) {
111
116
  return { kind: 'lms-shared', adapter };
112
117
  }
113
- // Dev fallback — cmi5 launch params absent, adapter is the WebAdapter
118
+ // Dev fallback — launch params absent, adapter is the WebAdapter
114
119
  // fallback. Materialize a publisher whose sends reject with an
115
120
  // explicit error so author code surfaces the dev/prod gap.
116
- return { kind: 'explicit', publisher: makeDevFallbackPublisher() };
121
+ return { kind: 'explicit', publisher: makeDevFallbackPublisher(standard) };
117
122
  }
118
123
 
119
124
  // Explicit endpoint.
@@ -151,7 +156,11 @@ function resolveExplicitActor(
151
156
  if (explicit.actor !== undefined) {
152
157
  return { kind: 'actor', value: explicit.actor };
153
158
  }
154
- if (config.export?.standard === 'cmi5' && adapter instanceof CMI5Adapter) {
159
+ const standard = config.export?.standard;
160
+ if (
161
+ (standard === 'cmi5' || standard === 'xapi') &&
162
+ adapter instanceof BaseXAPILaunchAdapter
163
+ ) {
155
164
  const inner = adapter.getPublisher();
156
165
  if (!inner) return null;
157
166
  try {
package/src/virtual.d.ts CHANGED
@@ -7,7 +7,10 @@ declare module 'virtual:tessera-layout' {
7
7
  declare module 'virtual:tessera-adapter' {
8
8
  import type { PersistenceAdapter } from 'tessera-learn/runtime/persistence.js';
9
9
  import type { CourseConfig } from 'tessera-learn/runtime/types.js';
10
- export function createAdapter(config: CourseConfig): PersistenceAdapter;
10
+ export function createAdapter(
11
+ config: CourseConfig,
12
+ options?: { manifest?: unknown; allowFallback?: boolean },
13
+ ): PersistenceAdapter;
11
14
  }
12
15
 
13
16
  declare module 'virtual:tessera-xapi-setup' {
@@ -1,5 +1,6 @@
1
1
  export default {
2
2
  title: '__PROJECT_TITLE__',
3
+ id: '__COURSE_ID__',
3
4
  language: 'en',
4
5
  navigation: { mode: 'free' },
5
6
  completion: { mode: 'percentage', percentageThreshold: 100 },