Using @studiolxd/xapi
@studiolxd/xapi is a headless xAPI 1.0.3/2.0 client. A framework-agnostic core
(createXapiClient) plus thin adapters, talking to any standards-compliant LRS.
Built-in in-memory mock LRS runs without a real LRS.
Pick the entry point
- Vanilla / any framework:
import { createXapiClient, buildStatement, VERBS } from '@studiolxd/xapi' - React:
import { XapiProvider, useXapiClient, useXapiStatus } from '@studiolxd/xapi/react' - Vue:
import { useXapiClient } from '@studiolxd/xapi/vue' - Angular (>=17):
import { provideXapi, XAPI } from '@studiolxd/xapi/angular' - Svelte (>=4):
import { createXapiStore } from '@studiolxd/xapi/svelte' - Implementing an LRS (server side):
import { … } from '@studiolxd/xapi/server' - CDN
<script>: globalwindow.Xapi
The golden rules
- Always check
.okbefore.value. Every network method returnsPromise<Result<T, XapiError>>— the API never throws. (Unlike SCORM, everything here is async: HTTP.) - Build statements with
client.buildStatement()(or the standalonebuildStatement):verb/objectaccept a plain IRI string,id(UUID) andtimestamp(ISO 8601) are auto-generated, and the client'sdefaults(actor,registration,context) are merged in. An actor needs exactly one identifier:mbox|mbox_sha1sum|openid|account. - Use the mock LRS for dev/tests:
createXapiClient({ endpoint: 'https://mock.lrs/xapi', fetch: createMemoryLrs().fetch }). - Documents ≠ statements. State/Activity Profile/Agent Profile are mutable
key-value documents (session state, settings); statements are an immutable
historical log.
getState/get*Profileresolveok(null)on 404 — missing is not an error. - Voiding does not delete.
voidStatement(targetId)sends a new statement (VERBS.voided+StatementRef); the target disappears from normal queries but stays retrievable viagetVoidedStatement(id). - Version is an option, not a fork:
version: '1.0.3' | '2.0'(default'1.0.3'); the client adapts payloads and headers per version.
Canonical vanilla example
import { createXapiClient, VERBS } from '@studiolxd/xapi';
const client = createXapiClient({
endpoint: 'https://lrs.example.com/xapi',
auth: { username: 'key', password: 'secret' }, // or { token } or { header }
defaults: { actor: { mbox: 'mailto:[email protected]' } },
});
const sent = await client.sendStatement(
client.buildStatement({
verb: VERBS.completed, // or a raw IRI string
object: 'https://example.com/course/1', // IRI shorthand → Activity
result: { score: { scaled: 0.9 }, success: true },
}),
);
if (sent.ok) console.log('statement id:', sent.value);
else console.error(sent.error.kind, sent.error.status, sent.error.message);
// Session state survives page reloads via the State document API:
await client.setState('https://example.com/course/1', 'bookmark', { page: 4 });
const state = await client.getState('https://example.com/course/1', 'bookmark');
if (state.ok && state.value) console.log(state.value.content); // { page: 4 }
Launch (content started by an LMS)
TinCan/Rustici-style launches pass endpoint, auth, actor, registration and
activity_id as query params. Don't parse them by hand:
import { createXapiClientFromLaunch } from '@studiolxd/xapi';
const launched = createXapiClientFromLaunch(); // reads window.location.href, SSR-safe
if (launched.ok) {
const client = launched.value; // actor/registration already set as defaults
}
Common gotchas (these cause real bugs)
- IRIs (
verb.id, Activityobject.id) must have a scheme and no spaces —validateStatementrejects them withkind: 'validation'before any network call. mboxmust bemailto:....mbox_sha1sumis a SHA-1 hex digest withoutmailto:. Never send both — exactly one identifier per actor.- Resending the same statement
idwith different content → 409 conflict. Identical content is idempotent (no error, no duplicate). getStatements()paginates: followresult.value.morewithgetMoreStatements(more)until it isnull.concurrency: 'auto'(default) manages document ETags and retries a 412 once; some strict 1.0.3 LRSs reject conditional headers — useconcurrency: 'off'there../serverhelpers use Web APIs only (Request/Response/crypto.subtle) — they run in Next.js Route Handlers/edge/Node >=18, but not withnode:httpobjects.- SSR (Next.js/Remix):
parseXapiLaunch()without an explicit URL returnserr(kind: 'usage')on the server instead of throwing — pass the URL explicitly or call it client-side only. client.destroy()when done (adapters do it for you on unmount/dispose).
XapiError fields
kind ('network'|'http'|'validation'|'timeout'|'version'|'usage'), operation,
endpoint, status (HTTP code or null), responseBody (truncated LRS error body),
issues (validation only: { path, rule, message }[]), exception.