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.
- package/AGENTS.md +1228 -0
- package/LICENSE +21 -0
- package/README.md +21 -0
- package/dist/plugin/index.d.ts +7 -0
- package/dist/plugin/index.d.ts.map +1 -0
- package/dist/plugin/index.js +1239 -0
- package/dist/plugin/index.js.map +1 -0
- package/package.json +77 -0
- package/src/archiver.d.ts +27 -0
- package/src/components/Accordion.svelte +32 -0
- package/src/components/AccordionItem.svelte +144 -0
- package/src/components/Audio.svelte +38 -0
- package/src/components/Callout.svelte +81 -0
- package/src/components/Carousel.svelte +194 -0
- package/src/components/CarouselSlide.svelte +32 -0
- package/src/components/DefaultLayout.svelte +108 -0
- package/src/components/FillInTheBlank.svelte +345 -0
- package/src/components/Image.svelte +47 -0
- package/src/components/Matching.svelte +513 -0
- package/src/components/MultipleChoice.svelte +363 -0
- package/src/components/Quiz.svelte +569 -0
- package/src/components/RevealModal.svelte +228 -0
- package/src/components/Sorting.svelte +663 -0
- package/src/components/Video.svelte +118 -0
- package/src/components/index.ts +15 -0
- package/src/components/quiz-payload.ts +71 -0
- package/src/components/util.ts +24 -0
- package/src/index.ts +56 -0
- package/src/plugin/export.ts +264 -0
- package/src/plugin/index.ts +464 -0
- package/src/plugin/layout.ts +55 -0
- package/src/plugin/manifest.ts +330 -0
- package/src/plugin/quiz.ts +65 -0
- package/src/plugin/validation.ts +838 -0
- package/src/runtime/App.svelte +435 -0
- package/src/runtime/ErrorPage.svelte +14 -0
- package/src/runtime/LoadingSkeleton.svelte +26 -0
- package/src/runtime/Sidebar.svelte +76 -0
- package/src/runtime/access.ts +55 -0
- package/src/runtime/adapters/cmi5.ts +341 -0
- package/src/runtime/adapters/discovery.ts +38 -0
- package/src/runtime/adapters/index.ts +99 -0
- package/src/runtime/adapters/retry.ts +284 -0
- package/src/runtime/adapters/scorm12.ts +172 -0
- package/src/runtime/adapters/scorm2004.ts +162 -0
- package/src/runtime/adapters/web.ts +62 -0
- package/src/runtime/contexts.ts +76 -0
- package/src/runtime/duration.ts +29 -0
- package/src/runtime/hooks.svelte.ts +543 -0
- package/src/runtime/interaction-format.ts +132 -0
- package/src/runtime/interaction.ts +96 -0
- package/src/runtime/navigation.svelte.ts +117 -0
- package/src/runtime/persistence.ts +56 -0
- package/src/runtime/progress.svelte.ts +168 -0
- package/src/runtime/quiz-policy.ts +227 -0
- package/src/runtime/slugify.ts +17 -0
- package/src/runtime/types.ts +92 -0
- package/src/runtime/xapi/agent-rules.ts +93 -0
- package/src/runtime/xapi/client.ts +133 -0
- package/src/runtime/xapi/derive-actor.ts +90 -0
- package/src/runtime/xapi/publisher.ts +604 -0
- package/src/runtime/xapi/registry.ts +38 -0
- package/src/runtime/xapi/setup.ts +250 -0
- package/src/runtime/xapi/types.ts +106 -0
- package/src/runtime/xapi/uuid.ts +21 -0
- package/src/runtime/xapi/validation.ts +71 -0
- package/src/runtime/xapi/version.ts +23 -0
- package/src/virtual.d.ts +16 -0
- package/styles/base.css +194 -0
- package/styles/layout.css +408 -0
- 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
|
+
}
|