tessera-learn 0.0.1

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 (71) hide show
  1. package/AGENTS.md +1228 -0
  2. package/LICENSE +21 -0
  3. package/README.md +21 -0
  4. package/dist/plugin/index.d.ts +7 -0
  5. package/dist/plugin/index.d.ts.map +1 -0
  6. package/dist/plugin/index.js +1239 -0
  7. package/dist/plugin/index.js.map +1 -0
  8. package/package.json +77 -0
  9. package/src/archiver.d.ts +27 -0
  10. package/src/components/Accordion.svelte +32 -0
  11. package/src/components/AccordionItem.svelte +144 -0
  12. package/src/components/Audio.svelte +38 -0
  13. package/src/components/Callout.svelte +81 -0
  14. package/src/components/Carousel.svelte +194 -0
  15. package/src/components/CarouselSlide.svelte +32 -0
  16. package/src/components/DefaultLayout.svelte +108 -0
  17. package/src/components/FillInTheBlank.svelte +345 -0
  18. package/src/components/Image.svelte +47 -0
  19. package/src/components/Matching.svelte +513 -0
  20. package/src/components/MultipleChoice.svelte +363 -0
  21. package/src/components/Quiz.svelte +569 -0
  22. package/src/components/RevealModal.svelte +228 -0
  23. package/src/components/Sorting.svelte +663 -0
  24. package/src/components/Video.svelte +118 -0
  25. package/src/components/index.ts +15 -0
  26. package/src/components/quiz-payload.ts +71 -0
  27. package/src/components/util.ts +24 -0
  28. package/src/index.ts +56 -0
  29. package/src/plugin/export.ts +264 -0
  30. package/src/plugin/index.ts +464 -0
  31. package/src/plugin/layout.ts +55 -0
  32. package/src/plugin/manifest.ts +330 -0
  33. package/src/plugin/quiz.ts +65 -0
  34. package/src/plugin/validation.ts +838 -0
  35. package/src/runtime/App.svelte +435 -0
  36. package/src/runtime/ErrorPage.svelte +14 -0
  37. package/src/runtime/LoadingSkeleton.svelte +26 -0
  38. package/src/runtime/Sidebar.svelte +76 -0
  39. package/src/runtime/access.ts +55 -0
  40. package/src/runtime/adapters/cmi5.ts +341 -0
  41. package/src/runtime/adapters/discovery.ts +38 -0
  42. package/src/runtime/adapters/index.ts +99 -0
  43. package/src/runtime/adapters/retry.ts +284 -0
  44. package/src/runtime/adapters/scorm12.ts +172 -0
  45. package/src/runtime/adapters/scorm2004.ts +162 -0
  46. package/src/runtime/adapters/web.ts +62 -0
  47. package/src/runtime/contexts.ts +76 -0
  48. package/src/runtime/duration.ts +29 -0
  49. package/src/runtime/hooks.svelte.ts +543 -0
  50. package/src/runtime/interaction-format.ts +132 -0
  51. package/src/runtime/interaction.ts +96 -0
  52. package/src/runtime/navigation.svelte.ts +117 -0
  53. package/src/runtime/persistence.ts +56 -0
  54. package/src/runtime/progress.svelte.ts +168 -0
  55. package/src/runtime/quiz-policy.ts +227 -0
  56. package/src/runtime/slugify.ts +17 -0
  57. package/src/runtime/types.ts +92 -0
  58. package/src/runtime/xapi/agent-rules.ts +93 -0
  59. package/src/runtime/xapi/client.ts +133 -0
  60. package/src/runtime/xapi/derive-actor.ts +90 -0
  61. package/src/runtime/xapi/publisher.ts +604 -0
  62. package/src/runtime/xapi/registry.ts +38 -0
  63. package/src/runtime/xapi/setup.ts +250 -0
  64. package/src/runtime/xapi/types.ts +106 -0
  65. package/src/runtime/xapi/uuid.ts +21 -0
  66. package/src/runtime/xapi/validation.ts +71 -0
  67. package/src/runtime/xapi/version.ts +23 -0
  68. package/src/virtual.d.ts +16 -0
  69. package/styles/base.css +194 -0
  70. package/styles/layout.css +408 -0
  71. package/styles/theme.css +36 -0
@@ -0,0 +1,604 @@
1
+ import type {
2
+ XAPIAgent,
3
+ Statement,
4
+ PartialStatement,
5
+ SendStatementOptions,
6
+ SendStatementResult,
7
+ DestinationOutcome,
8
+ } from './types.js';
9
+ import { uuidv4 } from './uuid.js';
10
+ import { X_API_VERSION } from './version.js';
11
+ import {
12
+ validatePartialStatement,
13
+ validateAgent,
14
+ validateAuthCredential,
15
+ XAPIConfigError,
16
+ XAPIStatementError,
17
+ } from './validation.js';
18
+
19
+ /** cmi5 §9.6.2 — well-known IRI owned by ADL for the cmi5 session id extension. */
20
+ const CMI5_SESSIONID_EXT =
21
+ 'https://w3id.org/xapi/cmi5/context/extensions/sessionid';
22
+
23
+ // Re-exported so existing callers (xapi/client.ts, tests, etc.) that pull
24
+ // these symbols from the publisher entry point keep working.
25
+ export { XAPIConfigError, XAPIStatementError, validateAgent, validateAuthCredential };
26
+
27
+ /**
28
+ * Combine a field label (e.g. `xapi.actor`) with the prefix-friendly suffix
29
+ * returned by `validateAgent`. Sub-field suffixes start with `.` and chain
30
+ * directly (`xapi.actor.mbox …`); top-level messages get a `: ` separator
31
+ * (`xapi.actor: must be an object`).
32
+ */
33
+ function joinFieldError(label: string, suffix: string): string {
34
+ return suffix.startsWith('.') ? `${label}${suffix}` : `${label}: ${suffix}`;
35
+ }
36
+
37
+ export interface XAPIPublisherOptions {
38
+ /** Resolved http(s) endpoint URL. The 'lms' sentinel is a config-layer concept and never reaches the publisher. */
39
+ endpoint: string;
40
+ /**
41
+ * Basic-auth credential (the value after "Basic "), an empty string for
42
+ * unauthenticated requests, or a function that resolves one. Function
43
+ * form is re-invoked once on 401 to cover short-lived tokens.
44
+ */
45
+ auth: string | (() => string | Promise<string>);
46
+ /**
47
+ * Identified Agent (or function returning one). Resolved once during
48
+ * `init()` and cached for the publisher's lifetime.
49
+ */
50
+ actor: XAPIAgent | (() => XAPIAgent | Promise<XAPIAgent>);
51
+ /** xAPI activity IRI scoped to this destination. */
52
+ activityId: string;
53
+ /** Optional UUID — primarily a cmi5 launch concept. */
54
+ registration?: string;
55
+ /** Optional caller-supplied session id. cmi5 adapter supplies its own. */
56
+ sessionId?: string;
57
+ /**
58
+ * When true, every statement carries the cmi5 sessionid context extension.
59
+ * Set by the cmi5 adapter and by 'lms'-inherited destinations under cmi5.
60
+ */
61
+ cmi5Mode?: boolean;
62
+ }
63
+
64
+ interface SendOutcome {
65
+ ok: boolean;
66
+ status?: number;
67
+ error?: Error;
68
+ }
69
+
70
+ const STATEMENT_RETRY_ATTEMPTS = 3;
71
+ /**
72
+ * Soft cap on the number of in-flight statements queued behind the head of
73
+ * the chain. We log a one-time warning when the queue grows past this so
74
+ * tight-loop senders can't silently retain every prior `Statement` in the
75
+ * promise chain's closure. Hitting the saturation cap rejects further
76
+ * sends — the publisher is being driven faster than the LRS can drain.
77
+ */
78
+ const QUEUE_DEPTH_WARN = 200;
79
+ const QUEUE_DEPTH_SATURATED = 1000;
80
+ /**
81
+ * Browsers cap the cumulative body size of `keepalive: true` fetches at
82
+ * 64 KiB per page. A batched statement payload above this threshold is
83
+ * silently dropped during unload — the request never leaves the browser.
84
+ * We log when a keepalive send would exceed the cap so the data loss is
85
+ * at least visible. Splitting the batch isn't safe here (statements
86
+ * within a batch are atomic from the LRS's perspective; partial sends
87
+ * would corrupt ordering against Terminated), so the warning is the
88
+ * useful signal.
89
+ */
90
+ const KEEPALIVE_BODY_LIMIT_BYTES = 64 * 1024;
91
+
92
+ /**
93
+ * Single-destination xAPI publisher. Builds and sends statements with
94
+ * sequential queue ordering, retry on 5xx/network errors, and 401-driven
95
+ * auth re-resolution.
96
+ *
97
+ * The cmi5 adapter constructs one of these for its lifecycle stream
98
+ * (`cmi5Mode: true`). Author-config destinations construct one (or more,
99
+ * fanned out) via the runtime registry.
100
+ */
101
+ export class XAPIPublisher {
102
+ readonly #endpoint: string;
103
+ readonly #statementsUrl: string;
104
+ readonly #activityId: string;
105
+ readonly #registration?: string;
106
+ readonly #sessionId: string;
107
+ readonly #cmi5Mode: boolean;
108
+
109
+ // Auth — string or resolver. Cached after first resolution.
110
+ readonly #authValue: string | (() => string | Promise<string>);
111
+ #cachedAuth: string | null = null;
112
+ // Once two consecutive 401s are observed (one initial + one re-resolve),
113
+ // auth is marked dead and every subsequent send fails fast without
114
+ // hitting the LRS — the credentials have just been rejected and there
115
+ // is no in-band signal to tell us when they would be accepted again.
116
+ // The flag persists for the lifetime of the publisher; authors who
117
+ // need recovery should reload the runtime.
118
+ #authDead = false;
119
+
120
+ // Actor — object or resolver. Resolved during init and cached.
121
+ readonly #actorValue: XAPIAgent | (() => XAPIAgent | Promise<XAPIAgent>);
122
+ #cachedActor: XAPIAgent | null = null;
123
+ #initPromise: Promise<void> | null = null;
124
+
125
+ // Sequential send chain — chains promises so statements arrive at the
126
+ // LRS in the order they were enqueued.
127
+ #queue: Promise<void> = Promise.resolve();
128
+ #queueDepth = 0;
129
+ #queueWarned = false;
130
+ #unloading = false;
131
+
132
+ constructor(opts: XAPIPublisherOptions) {
133
+ if (!opts.endpoint || typeof opts.endpoint !== 'string') {
134
+ throw new XAPIConfigError('XAPIPublisher: endpoint is required');
135
+ }
136
+ if (!/^https?:\/\//i.test(opts.endpoint)) {
137
+ throw new XAPIConfigError(
138
+ 'XAPIPublisher: endpoint must be an absolute http(s) URL'
139
+ );
140
+ }
141
+ if (!opts.activityId) {
142
+ throw new XAPIConfigError('XAPIPublisher: activityId is required');
143
+ }
144
+ this.#endpoint = opts.endpoint.replace(/\/?$/, '/');
145
+ this.#statementsUrl = `${this.#endpoint}statements`;
146
+ this.#activityId = opts.activityId;
147
+ this.#registration = opts.registration;
148
+ this.#cmi5Mode = !!opts.cmi5Mode;
149
+ this.#authValue = opts.auth;
150
+ this.#actorValue = opts.actor;
151
+ this.#sessionId = opts.sessionId ?? uuidv4();
152
+
153
+ if (typeof this.#actorValue !== 'function') {
154
+ this.#cachedActor = this.#actorValue;
155
+ }
156
+ // Eagerly cache static auth (including the empty-string sentinel for
157
+ // an unauthenticated cmi5 launch where the fetch URL produced no
158
+ // token) so the hot send path stays synchronous up to fetch().
159
+ if (typeof this.#authValue !== 'function') {
160
+ this.#cachedAuth = this.#authValue;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Resolve actor (if function-form) and validate it. Idempotent.
166
+ * Throws `XAPIConfigError` if the resolved actor fails the Identified
167
+ * Agent rule. App.svelte awaits init before registering the publisher
168
+ * so by the time `useXAPI()` returns non-null, init is complete.
169
+ */
170
+ init(): Promise<void> {
171
+ if (this.#initPromise) return this.#initPromise;
172
+ this.#initPromise = this.#runInit();
173
+ return this.#initPromise;
174
+ }
175
+
176
+ async #runInit(): Promise<void> {
177
+ if (this.#cachedActor === null && typeof this.#actorValue === 'function') {
178
+ let resolved: unknown;
179
+ try {
180
+ resolved = await this.#actorValue();
181
+ } catch (err) {
182
+ throw new XAPIConfigError(
183
+ `xapi.actor resolver threw: ${err instanceof Error ? err.message : String(err)}`
184
+ );
185
+ }
186
+ const err = validateAgent(resolved);
187
+ if (err) throw new XAPIConfigError(joinFieldError('xapi.actor', err));
188
+ this.#cachedActor = resolved as XAPIAgent;
189
+ } else if (this.#cachedActor) {
190
+ const err = validateAgent(this.#cachedActor);
191
+ if (err) throw new XAPIConfigError(joinFieldError('xapi.actor', err));
192
+ } else {
193
+ throw new XAPIConfigError('xapi.actor is required');
194
+ }
195
+ // Validate static auth eagerly. An empty string is allowed (the
196
+ // unauthenticated cmi5 case where the LMS fetch URL produced no
197
+ // token) — only non-empty values are run through the Basic/Bearer
198
+ // prefix checks.
199
+ if (typeof this.#authValue === 'string' && this.#authValue.length > 0) {
200
+ const aErr = validateAuthCredential(this.#authValue);
201
+ if (aErr) throw new XAPIConfigError(`xapi.auth: ${aErr}`);
202
+ }
203
+ }
204
+
205
+ /** Returns the cached actor. Must be called after `init()` resolves. */
206
+ getActor(): XAPIAgent {
207
+ if (!this.#cachedActor) {
208
+ throw new XAPIConfigError(
209
+ 'XAPIPublisher.getActor() called before init() resolved. Await publisher.init() before reading the actor.'
210
+ );
211
+ }
212
+ return this.#cachedActor;
213
+ }
214
+
215
+ getActivityId(): string {
216
+ return this.#activityId;
217
+ }
218
+
219
+ getSessionId(): string {
220
+ return this.#sessionId;
221
+ }
222
+
223
+ /** Resolved http(s) endpoint URL (with trailing slash). */
224
+ getEndpoint(): string {
225
+ return this.#endpoint;
226
+ }
227
+
228
+ /**
229
+ * Build a fully-formed statement from a partial. Mints a UUID, fills in
230
+ * actor / timestamp / context.registration / context.contextActivities.grouping
231
+ * and (when in cmi5 mode) the cmi5 sessionid extension. Caller-supplied
232
+ * `context` keys are preserved; publisher-supplied values fill gaps.
233
+ *
234
+ * `opts.id` lets a fan-out wrapper share a single UUID across every
235
+ * destination's statement so all LRSes see the same id (idempotent
236
+ * dedupe works across destinations).
237
+ */
238
+ buildStatement(partial: PartialStatement, opts?: { id?: string }): Statement {
239
+ if (!this.#cachedActor) {
240
+ throw new XAPIConfigError(
241
+ 'XAPIPublisher.buildStatement() called before init() resolved.'
242
+ );
243
+ }
244
+ const userCtx = partial.context ?? {};
245
+ const userCtxActivities = userCtx.contextActivities ?? {};
246
+ const grouping = userCtxActivities.grouping ?? [{ id: this.#activityId }];
247
+
248
+ const context: NonNullable<Statement['context']> = {
249
+ ...userCtx,
250
+ contextActivities: {
251
+ ...userCtxActivities,
252
+ grouping,
253
+ },
254
+ };
255
+ if (this.#registration && context.registration === undefined) {
256
+ context.registration = this.#registration;
257
+ }
258
+ if (this.#cmi5Mode) {
259
+ context.extensions = {
260
+ ...(context.extensions ?? {}),
261
+ [CMI5_SESSIONID_EXT]: this.#sessionId,
262
+ };
263
+ }
264
+
265
+ const verbName = partial.verb.id.split('/').pop() || partial.verb.id;
266
+ const verb = partial.verb.display
267
+ ? partial.verb
268
+ : { id: partial.verb.id, display: { 'en-US': verbName } };
269
+
270
+ const object = partial.object ?? {
271
+ id: this.#activityId,
272
+ objectType: 'Activity',
273
+ };
274
+
275
+ const statement: Statement = {
276
+ id: opts?.id ?? uuidv4(),
277
+ actor: this.#cachedActor,
278
+ verb,
279
+ object,
280
+ context,
281
+ timestamp: new Date().toISOString(),
282
+ };
283
+ if (partial.result !== undefined) statement.result = partial.result;
284
+ if (partial.attachments !== undefined) statement.attachments = partial.attachments;
285
+ return statement;
286
+ }
287
+
288
+ /**
289
+ * Author API. Validates the partial, builds the statement, enqueues it,
290
+ * resolves with the per-destination outcome (single-element since this
291
+ * class is single-destination — the fan-out wrapper combines them).
292
+ *
293
+ * Validation failures (verb.id missing, object.id missing when supplied,
294
+ * score.scaled out of range) throw synchronously — the returned promise
295
+ * rejects before any HTTP traffic happens.
296
+ */
297
+ sendStatement(
298
+ partial: PartialStatement,
299
+ options?: SendStatementOptions & { id?: string }
300
+ ): Promise<SendStatementResult> {
301
+ try {
302
+ validatePartialStatement(partial);
303
+ } catch (err) {
304
+ // Caller-friendly: surface validation errors as a rejected promise
305
+ // so both `await sendStatement(...)` and
306
+ // `sendStatement(...).catch(...)` paths see them. The rejection
307
+ // happens before any HTTP traffic — no LRS round-trip is started.
308
+ return Promise.reject(err);
309
+ }
310
+ if (!this.#cachedActor) {
311
+ return Promise.reject(
312
+ new XAPIConfigError(
313
+ 'XAPIPublisher.sendStatement() called before init() resolved.'
314
+ )
315
+ );
316
+ }
317
+ const statement = this.buildStatement(partial, { id: options?.id });
318
+ return this.enqueueBuilt(statement, options).then((outcome) => ({
319
+ statementId: statement.id,
320
+ statement,
321
+ destinations: [outcome],
322
+ }));
323
+ }
324
+
325
+ /**
326
+ * Enqueue a pre-built statement (or array as a single batch POST) onto
327
+ * the queue. Returns a promise that resolves with the outcome once the
328
+ * send settles. Used by the cmi5 adapter for its lifecycle stream and
329
+ * its interaction batches.
330
+ */
331
+ enqueueBuilt(
332
+ statementOrBatch: Statement | Statement[],
333
+ options?: SendStatementOptions
334
+ ): Promise<DestinationOutcome> {
335
+ if (this.#queueDepth >= QUEUE_DEPTH_SATURATED) {
336
+ return Promise.resolve<DestinationOutcome>({
337
+ endpoint: this.#endpoint,
338
+ ok: false,
339
+ error: new XAPIConfigError(
340
+ `XAPIPublisher queue saturated (${this.#queueDepth} in-flight); refusing further sends until the LRS catches up.`
341
+ ),
342
+ });
343
+ }
344
+ if (!this.#queueWarned && this.#queueDepth >= QUEUE_DEPTH_WARN) {
345
+ this.#queueWarned = true;
346
+ console.warn(
347
+ `Tessera: xAPI publisher queue depth ${this.#queueDepth} (>= ${QUEUE_DEPTH_WARN}). ` +
348
+ `Each pending statement is retained in the promise chain's closure until it drains; ` +
349
+ `consider rate-limiting authoring sends or batching before sendStatement.`
350
+ );
351
+ }
352
+ this.#queueDepth++;
353
+ let resolveOutcome!: (o: DestinationOutcome) => void;
354
+ const outcomePromise = new Promise<DestinationOutcome>((r) => {
355
+ resolveOutcome = r;
356
+ });
357
+ // Use raw .then chaining (not async/await) so that when the queue's
358
+ // previous promise resolves, the next .then's microtask runs the send
359
+ // path synchronously up to fetch — same timing as the original
360
+ // cmi5.ts queue, which a few tests rely on (e.g., Initialized must
361
+ // POST before mockClear() runs in the test body).
362
+ this.#queue = this.#queue.then(() =>
363
+ this.#sendWithRetry(statementOrBatch, options).then((outcome) => {
364
+ this.#queueDepth--;
365
+ resolveOutcome({
366
+ endpoint: this.#endpoint,
367
+ ok: outcome.ok,
368
+ status: outcome.status,
369
+ error: outcome.error,
370
+ });
371
+ })
372
+ );
373
+ return outcomePromise;
374
+ }
375
+
376
+ /**
377
+ * Chain an arbitrary task on the queue. Used by the cmi5 adapter for
378
+ * State API writes that need to land before Terminated.
379
+ *
380
+ * The task is wrapped so a thrown error never breaks the queue's
381
+ * Promise chain — subsequent enqueues still flow.
382
+ */
383
+ chainTask(fn: () => Promise<void>): Promise<void> {
384
+ let resolveTask!: () => void;
385
+ const taskPromise = new Promise<void>((r) => {
386
+ resolveTask = r;
387
+ });
388
+ this.#queue = this.#queue.then(() =>
389
+ fn().catch(() => {}).then(() => resolveTask())
390
+ );
391
+ return taskPromise;
392
+ }
393
+
394
+ /**
395
+ * Switch the publisher to "page is unloading" mode. Subsequent fetches
396
+ * use `keepalive: true` so they survive the unload. The cmi5 adapter
397
+ * calls this before enqueuing the final Terminated statement.
398
+ */
399
+ markUnloading(): void {
400
+ this.#unloading = true;
401
+ }
402
+
403
+ /** Whether the publisher is in unloading mode (for tests). */
404
+ isUnloading(): boolean {
405
+ return this.#unloading;
406
+ }
407
+
408
+ /** Whether this publisher participates in cmi5 ordering (Terminated must be last). */
409
+ isCmi5Mode(): boolean {
410
+ return this.#cmi5Mode;
411
+ }
412
+
413
+ // ---- Internal: send with retry policy ----
414
+
415
+ #sendWithRetry(
416
+ statementOrBatch: Statement | Statement[],
417
+ options?: SendStatementOptions
418
+ ): Promise<SendOutcome> {
419
+ const body = JSON.stringify(statementOrBatch);
420
+ const retry = options?.retry !== false; // default: retry enabled
421
+ const maxAttempts = retry ? STATEMENT_RETRY_ATTEMPTS : 1;
422
+
423
+ const attempt = (n: number): Promise<SendOutcome> => {
424
+ const isFinal = n === maxAttempts - 1;
425
+ // keepalive on final attempt (so it survives unload) and any
426
+ // attempt after the adapter has flagged the page as unloading.
427
+ const keepalive = isFinal || this.#unloading;
428
+ if (keepalive && body.length > KEEPALIVE_BODY_LIMIT_BYTES) {
429
+ const count = Array.isArray(statementOrBatch)
430
+ ? statementOrBatch.length
431
+ : 1;
432
+ console.warn(
433
+ `Tessera: xAPI ${count}-statement batch is ${body.length} bytes, ` +
434
+ `over the 64 KiB keepalive cap. The browser may silently drop this ` +
435
+ `request during unload. Reduce per-statement size or split sends ` +
436
+ `before terminate.`
437
+ );
438
+ }
439
+ return this.#sendOnce(body, keepalive).then((outcome) => {
440
+ if (outcome.ok) return outcome;
441
+ // 4xx (other than the 401 path which #sendOnce already retried) won't
442
+ // recover — short-circuit.
443
+ if (
444
+ outcome.status !== undefined &&
445
+ outcome.status >= 400 &&
446
+ outcome.status < 500
447
+ ) {
448
+ return outcome;
449
+ }
450
+ if (isFinal) return outcome;
451
+ return new Promise<void>((r) =>
452
+ setTimeout(r, 100 * Math.pow(2, n))
453
+ ).then(() => attempt(n + 1));
454
+ });
455
+ };
456
+
457
+ return attempt(0);
458
+ }
459
+
460
+ #sendOnce(body: string, keepalive: boolean): Promise<SendOutcome> {
461
+ if (this.#authDead) {
462
+ return Promise.resolve({
463
+ ok: false,
464
+ error: new XAPIConfigError(
465
+ 'xapi.auth was rejected by the LRS twice in a row; refusing further sends for the publisher lifetime. Reload the runtime to retry.'
466
+ ),
467
+ });
468
+ }
469
+ // Hot path: cached auth — fetch fires synchronously inside this
470
+ // function call, in the same microtask the queue's .then() ran.
471
+ if (this.#cachedAuth !== null) {
472
+ return this.#fetchWithToken(this.#cachedAuth, body, keepalive);
473
+ }
474
+ // Cold path: need to resolve the auth resolver.
475
+ return this.#resolveAuth(false)
476
+ .then((token) => this.#fetchWithToken(token, body, keepalive))
477
+ .catch((err) => ({
478
+ ok: false,
479
+ error: err instanceof Error ? err : new Error(String(err)),
480
+ }));
481
+ }
482
+
483
+ #fetchWithToken(
484
+ token: string,
485
+ body: string,
486
+ keepalive: boolean
487
+ ): Promise<SendOutcome> {
488
+ const headers = new Headers();
489
+ if (token) headers.set('Authorization', `Basic ${token}`);
490
+ headers.set('X-Experience-API-Version', X_API_VERSION);
491
+ headers.set('Content-Type', 'application/json');
492
+ return fetch(this.#statementsUrl, {
493
+ method: 'POST',
494
+ headers,
495
+ body,
496
+ keepalive,
497
+ })
498
+ .then((resp) => this.#handleResponse(resp, body, keepalive))
499
+ .catch((err) => ({
500
+ ok: false,
501
+ error: err instanceof Error ? err : new Error(String(err)),
502
+ }));
503
+ }
504
+
505
+ #handleResponse(
506
+ resp: Response,
507
+ body: string,
508
+ keepalive: boolean
509
+ ): Promise<SendOutcome> | SendOutcome {
510
+ if (resp.ok || resp.status === 409) {
511
+ return { ok: true, status: resp.status };
512
+ }
513
+ // 401 with a function-form auth: invalidate cache, re-resolve once,
514
+ // retry the same request.
515
+ if (
516
+ resp.status === 401 &&
517
+ typeof this.#authValue === 'function' &&
518
+ !this.#unloading
519
+ ) {
520
+ this.#cachedAuth = null;
521
+ return this.#resolveAuth(true)
522
+ .then((newToken) => {
523
+ const retryHeaders = new Headers();
524
+ if (newToken) retryHeaders.set('Authorization', `Basic ${newToken}`);
525
+ retryHeaders.set('X-Experience-API-Version', X_API_VERSION);
526
+ retryHeaders.set('Content-Type', 'application/json');
527
+ return fetch(this.#statementsUrl, {
528
+ method: 'POST',
529
+ headers: retryHeaders,
530
+ body,
531
+ keepalive,
532
+ });
533
+ })
534
+ .then((retryResp): SendOutcome => {
535
+ if (retryResp.ok || retryResp.status === 409) {
536
+ return { ok: true, status: retryResp.status };
537
+ }
538
+ if (retryResp.status === 401) {
539
+ this.#authDead = true;
540
+ return {
541
+ ok: false,
542
+ status: 401,
543
+ error: new Error(
544
+ 'LRS rejected re-resolved auth (consecutive 401s); auth resolver marked dead'
545
+ ),
546
+ };
547
+ }
548
+ return {
549
+ ok: false,
550
+ status: retryResp.status,
551
+ error: new Error(`LRS responded ${retryResp.status}`),
552
+ };
553
+ })
554
+ .catch(
555
+ (err): SendOutcome => ({
556
+ ok: false,
557
+ status: 401,
558
+ error: err instanceof Error ? err : new Error(String(err)),
559
+ })
560
+ );
561
+ }
562
+ return {
563
+ ok: false,
564
+ status: resp.status,
565
+ error: new Error(`LRS responded ${resp.status}`),
566
+ };
567
+ }
568
+
569
+ #resolveAuth(forceRefresh: boolean): Promise<string> {
570
+ if (!forceRefresh && this.#cachedAuth !== null) {
571
+ return Promise.resolve(this.#cachedAuth);
572
+ }
573
+ if (typeof this.#authValue === 'string') {
574
+ // Static auth: an empty string means unauthenticated (the cmi5 case
575
+ // where the LMS fetch URL produced no token); otherwise revalidate.
576
+ if (this.#authValue.length > 0) {
577
+ const err = validateAuthCredential(this.#authValue);
578
+ if (err) return Promise.reject(new XAPIConfigError(`xapi.auth: ${err}`));
579
+ }
580
+ this.#cachedAuth = this.#authValue;
581
+ return Promise.resolve(this.#cachedAuth);
582
+ }
583
+ return Promise.resolve()
584
+ .then(() => (this.#authValue as () => string | Promise<string>)())
585
+ .then((resolved) => {
586
+ if (typeof resolved !== 'string' || !resolved) {
587
+ throw new XAPIConfigError(
588
+ 'xapi.auth resolver must return a non-empty string'
589
+ );
590
+ }
591
+ const err = validateAuthCredential(resolved);
592
+ if (err) throw new XAPIConfigError(`xapi.auth: ${err}`);
593
+ this.#cachedAuth = resolved;
594
+ return resolved;
595
+ })
596
+ .catch((err) => {
597
+ if (err instanceof XAPIConfigError) throw err;
598
+ throw new XAPIConfigError(
599
+ `xapi.auth resolver threw: ${err instanceof Error ? err.message : String(err)}`
600
+ );
601
+ });
602
+ }
603
+ }
604
+
@@ -0,0 +1,38 @@
1
+ import type { XAPIClient } from './client.js';
2
+
3
+ /**
4
+ * Module-scoped client reference. App.svelte calls
5
+ * `registerXAPIClient(client)` once after the persistence adapter
6
+ * finishes its async init; `useXAPI()` reads from this slot. Plain TS
7
+ * (not a Svelte store) — the client reference is stable for the
8
+ * lifetime of the page-load and reactivity buys nothing.
9
+ *
10
+ * Resets to null on module init so the registry is empty before any
11
+ * App.svelte instance has run. One JS realm = one course = one
12
+ * client: this matches how Tessera is deployed (one course per
13
+ * page-load) and the registry assumes that.
14
+ */
15
+ let client: XAPIClient | null = null;
16
+
17
+ /**
18
+ * Install the page's xAPI client. Called once by App.svelte after the
19
+ * adapter has completed its async init (cmi5 launch fetch, SCORM
20
+ * LMSInitialize, etc.). Pass `null` to unregister at teardown.
21
+ */
22
+ export function registerXAPIClient(c: XAPIClient | null): void {
23
+ client = c;
24
+ }
25
+
26
+ /**
27
+ * Get the page's xAPI client, or `null` when no LRS is configured
28
+ * (web/scorm with no `config.xapi`) or when called before App.svelte has
29
+ * registered the client. Author code should null-check the result and
30
+ * degrade gracefully — `useXAPI()?.sendStatement(...)` works in both
31
+ * cases.
32
+ *
33
+ * Callable from anywhere — `.svelte` setup blocks, event handlers, async
34
+ * callbacks, plain `.ts` modules. Not a Svelte context hook.
35
+ */
36
+ export function useXAPI(): XAPIClient | null {
37
+ return client;
38
+ }