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>: global window.Xapi
The golden rules
- Always check
.ok before .value. Every network method returns
Promise<Result<T, XapiError>> — the API never throws. (Unlike SCORM, everything
here is async: HTTP.)
- Build statements with
client.buildStatement() (or the standalone
buildStatement): verb/object accept a plain IRI string, id (UUID) and
timestamp (ISO 8601) are auto-generated, and the client's
defaults (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*Profile resolve ok(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 via getVoidedStatement(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:learner@example.com' } },
});
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, Activity object.id) must have a scheme and no spaces —
validateStatement rejects them with kind: 'validation' before any network call.
mbox must be mailto:.... mbox_sha1sum is a SHA-1 hex digest without
mailto:. Never send both — exactly one identifier per actor.
- Resending the same statement
id with different content → 409 conflict.
Identical content is idempotent (no error, no duplicate).
getStatements() paginates: follow result.value.more with
getMoreStatements(more) until it is null.
concurrency: 'auto' (default) manages document ETags and retries a 412 once;
some strict 1.0.3 LRSs reject conditional headers — use concurrency: 'off' there.
./server helpers use Web APIs only (Request/Response/crypto.subtle) — they
run in Next.js Route Handlers/edge/Node >=18, but not with node:http objects.
- SSR (Next.js/Remix):
parseXapiLaunch() without an explicit URL returns
err(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.
1---2name: xapi3description: Use when integrating xAPI (Experience API / Tin Can) in a web app or server with @studiolxd/xapi — sending/querying statements, managing State/Activity Profile/Agent Profile documents, parsing launch URLs, handling Result errors, or wiring the client across React, Vue, Angular, Svelte, vanilla JS, or LRS server helpers.4---56# Using @studiolxd/xapi78`@studiolxd/xapi` is a headless xAPI 1.0.3/2.0 client. A framework-agnostic core9(`createXapiClient`) plus thin adapters, talking to **any** standards-compliant LRS.10Built-in **in-memory mock LRS** runs without a real LRS.1112## Pick the entry point1314- Vanilla / any framework: `import { createXapiClient, buildStatement, VERBS } from '@studiolxd/xapi'`15- React: `import { XapiProvider, useXapiClient, useXapiStatus } from '@studiolxd/xapi/react'`16- Vue: `import { useXapiClient } from '@studiolxd/xapi/vue'`17- Angular (>=17): `import { provideXapi, XAPI } from '@studiolxd/xapi/angular'`18- Svelte (>=4): `import { createXapiStore } from '@studiolxd/xapi/svelte'`19- Implementing an LRS (server side): `import { … } from '@studiolxd/xapi/server'`20- CDN `<script>`: global `window.Xapi`2122## The golden rules23241. **Always check `.ok` before `.value`.** Every network method returns25 `Promise<Result<T, XapiError>>` — the API never throws. (Unlike SCORM, everything26 here is async: HTTP.)272. **Build statements with `client.buildStatement()`** (or the standalone28 `buildStatement`): `verb`/`object` accept a plain IRI string, `id` (UUID) and29 `timestamp` (ISO 8601) are auto-generated, and the client's30 `defaults` (`actor`, `registration`, `context`) are merged in. An actor needs31 **exactly one** identifier: `mbox` | `mbox_sha1sum` | `openid` | `account`.323. **Use the mock LRS for dev/tests:**33 `createXapiClient({ endpoint: 'https://mock.lrs/xapi', fetch: createMemoryLrs().fetch })`.344. **Documents ≠ statements.** State/Activity Profile/Agent Profile are mutable35 key-value documents (session state, settings); statements are an immutable36 historical log. `getState`/`get*Profile` resolve `ok(null)` on 404 — missing is37 not an error.385. **Voiding does not delete.** `voidStatement(targetId)` sends a new statement39 (`VERBS.voided` + `StatementRef`); the target disappears from normal queries but40 stays retrievable via `getVoidedStatement(id)`.416. **Version is an option, not a fork:** `version: '1.0.3' | '2.0'` (default42 `'1.0.3'`); the client adapts payloads and headers per version.4344## Canonical vanilla example4546```ts47import { createXapiClient, VERBS } from '@studiolxd/xapi';4849const client = createXapiClient({50 endpoint: 'https://lrs.example.com/xapi',51 auth: { username: 'key', password: 'secret' }, // or { token } or { header }52 defaults: { actor: { mbox: 'mailto:learner@example.com' } },53});5455const sent = await client.sendStatement(56 client.buildStatement({57 verb: VERBS.completed, // or a raw IRI string58 object: 'https://example.com/course/1', // IRI shorthand → Activity59 result: { score: { scaled: 0.9 }, success: true },60 }),61);62if (sent.ok) console.log('statement id:', sent.value);63else console.error(sent.error.kind, sent.error.status, sent.error.message);6465// Session state survives page reloads via the State document API:66await client.setState('https://example.com/course/1', 'bookmark', { page: 4 });67const state = await client.getState('https://example.com/course/1', 'bookmark');68if (state.ok && state.value) console.log(state.value.content); // { page: 4 }69```7071## Launch (content started by an LMS)7273TinCan/Rustici-style launches pass `endpoint`, `auth`, `actor`, `registration` and74`activity_id` as query params. Don't parse them by hand:7576```ts77import { createXapiClientFromLaunch } from '@studiolxd/xapi';78const launched = createXapiClientFromLaunch(); // reads window.location.href, SSR-safe79if (launched.ok) {80 const client = launched.value; // actor/registration already set as defaults81}82```8384## Common gotchas (these cause real bugs)8586- IRIs (`verb.id`, Activity `object.id`) must have a scheme and no spaces —87 `validateStatement` rejects them with `kind: 'validation'` before any network call.88- `mbox` must be `mailto:...`. `mbox_sha1sum` is a SHA-1 hex digest **without**89 `mailto:`. Never send both — exactly one identifier per actor.90- Resending the same statement `id` with **different** content → 409 conflict.91 Identical content is idempotent (no error, no duplicate).92- `getStatements()` paginates: follow `result.value.more` with93 `getMoreStatements(more)` until it is `null`.94- `concurrency: 'auto'` (default) manages document ETags and retries a 412 **once**;95 some strict 1.0.3 LRSs reject conditional headers — use `concurrency: 'off'` there.96- `./server` helpers use Web APIs only (`Request`/`Response`/`crypto.subtle`) — they97 run in Next.js Route Handlers/edge/Node >=18, but not with `node:http` objects.98- SSR (Next.js/Remix): `parseXapiLaunch()` without an explicit URL returns99 `err(kind: 'usage')` on the server instead of throwing — pass the URL explicitly100 or call it client-side only.101- `client.destroy()` when done (adapters do it for you on unmount/dispose).102103## XapiError fields104105`kind` (`'network'|'http'|'validation'|'timeout'|'version'|'usage'`), `operation`,106`endpoint`, `status` (HTTP code or null), `responseBody` (truncated LRS error body),107`issues` (validation only: `{ path, rule, message }[]`), `exception`.