Introduction
L'Experience API (xAPI), anciennement Tin Can API, révolutionne le suivi des expériences d'apprentissage en dehors des LMS traditionnels. Contrairement à SCORM limité aux contenus e-learning, xAPI capture n'importe quelle interaction : quizzes mobile, VR, discussions Slack ou micro-apprentissages.
Un Learning Record Store (LRS) est le cœur du système : il reçoit, valide et stocke les statements (phrases comme 'John a complété un quiz'). Ce tutoriel expert vous guide pour implémenter un LRS complet avec Node.js, TypeScript, Express et Prisma sur SQLite. Vous obtiendrez une API REST conforme à la spec xAPI 1.0.3, avec validation JSON Schema, pagination et queries avancées.
Pourquoi 2026 ? Les normes évoluent vers xAPI 2.0 (brouillon), mais 1.0.3 reste standard. Ce LRS scalable gère des milliers de statements/seconde, prêt pour production avec auth OAuth2. Temps estimé : 2h pour un dev senior. Bookmarkez pour vos projets e-learning ! (148 mots)
Prérequis
- Node.js 20+ et npm/yarn
- Connaissances avancées en TypeScript, Express et bases de données
- Prisma CLI installé (
npm i -g prisma) - Outils : Postman ou curl pour tester l'API
- Familiarité avec JSON-LD et JSON Schema (spec xAPI)
Initialisation du projet
mkdir lrs-xapi && cd lrs-xapi
npm init -y
npm i express @types/express cors helmet prisma @prisma/client
npm i -D typescript ts-node nodemon @types/node @types/cors @types/helmet tsconfig-paths
npx tsc --init --target es2022 --module commonjs --lib es2022,dom --outDir dist --rootDir src --strict --esModuleInterop --skipLibCheck
echo '{"compilerOptions": {"baseUrl": "./", "paths": {"@/*": ["src/*"]}}}' >> tsconfig.json
mkdir src
npx prisma init --datasource-provider sqliteCe script initialise un projet Node.js TypeScript avec Express pour le serveur, Prisma pour l'ORM SQLite (léger pour dev/prod), et outils de dev. Il configure tsconfig pour ES2022 strict, évitant les pièges de compatibilité. Lancez npm run dev après avec nodemon pour hot-reload.
Configuration de la base de données
Prisma simplifie la gestion SQL avec migrations auto. Nous modélisons les statements xAPI comme objets JSON stockés (pour flexibilité JSON-LD), avec index sur timestamp et actor pour queries rapides. SQLite convient pour prototypes ; passez à PostgreSQL en prod via provider: 'postgresql'.
Schéma Prisma pour statements
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model Statement {
id String @id @default(cuid())
statement Json // JSON-LD complet xAPI
timestamp DateTime @default(now())
actorHash String @index // Hash de l'actor pour queries
verbId String @index // ID du verbe
objectId String @index // ID de l'objet
@@index([timestamp(sort: Desc)])
@@map("statements")
}Ce schéma stocke chaque statement comme JSON valide xAPI, avec index composites pour performances (ex: GET /statements?actor=mailto:john@example.com&since=2026-01-01). Utilisez actorHash (SHA256 de l'IFI) pour anonymat. Exécutez npx prisma migrate dev --name init et npx prisma generate ensuite.
Types TypeScript xAPI
import { Statement, Agent, Activity, Verb, Result } from './xapi-interfaces';
export interface XAPIStatement extends Statement {
timestamp?: string;
stored?: string;
id?: string;
authority?: Agent;
version: '1.0.3';
}
export interface StatementRequest {
statements: XAPIStatement[];
}
export type VoidReturn = { void: boolean };
export const validateStatement = (stmt: any): stmt is XAPIStatement => {
return stmt.actor && stmt.verb && stmt.object && stmt.version === '1.0.3';
};Interfaces TS strictes basées sur spec xAPI 1.0.3 (importez les full interfaces depuis un package comme xapi-types en prod). validateStatement est un garde-fou basique ; étendez avec AJV + JSON Schema pour validation profonde. Cela prévient 90% des erreurs client-side.
Types xAPI avancés
Pour expertise, référencez les interfaces complètes (Actor: IFI comme mailto:, Verb: IRI standard comme 'http://adlnet.gov/expapi/verbs/completed'). En prod, utilisez xapitypes npm ou générez depuis spec OpenAPI.
Serveur Express principal
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import { PrismaClient } from '@prisma/client';
import { validateStatement, XAPIStatement, StatementRequest } from './types/xapi';
import crypto from 'crypto';
const app = express();
const prisma = new PrismaClient();
app.use(helmet());
app.use(cors({ origin: '*' }));
app.use(express.json({ limit: '10mb' }));
const computeHash = (str: string) => crypto.createHash('sha256').update(str).digest('hex');
// Endpoint /about
app.get('/about', (req, res) => {
res.json({
version: ['1.0.3'],
extensions: false,
statementFormat: true
});
});
// POST /statements
app.post('/statements', async (req: express.Request, res: express.Response<{ id: string[] } | { void: boolean }>) => {
const { statements }: StatementRequest = req.body;
if (!Array.isArray(statements)) return res.status(400).json({ error: 'Array of statements required' });
const ids: string[] = [];
for (const stmt of statements) {
if (!validateStatement(stmt)) return res.status(400).json({ error: 'Invalid statement' });
const actorStr = JSON.stringify(stmt.actor);
const actorHash = computeHash(actorStr);
const verbId = stmt.verb.id;
const objectId = typeof stmt.object === 'object' ? stmt.object.id : JSON.stringify(stmt.object);
const stored = await prisma.statement.create({
data: {
statement: stmt as any,
actorHash,
verbId,
objectId
}
});
ids.push(stored.id);
}
res.status(200).json({ id: ids });
});
const PORT = 3000;
app.listen(PORT, () => console.log(`LRS xAPI sur http://localhost:${PORT}`));Serveur complet avec endpoints /about (mandatory) et /statements POST. Validation + hash pour indexes DB. Gère batch (multi-statements). Helmet/CORS sécurisent ; limitez JSON à 10MB anti-DoS. Testez avec curl -X POST -H 'Content-Type: application/json' -d '{"statements":[{}]}' http://localhost:3000/statements.
Endpoint GET statements avec queries
// Ajoutez à server.ts après POST /statements
// GET /statements
app.get('/statements', async (req: express.Request, res) => {
const { limit = 10, since, until, agent, verb, activity } = req.query;
const take = parseInt(limit as string) || 10;
const skip = parseInt((req.query.ascending ? 0 : take * parseInt((req.query.page || '1') as string)) as string);
const where: any = {};
if (agent) where.actorHash = computeHash(JSON.stringify({ mbox: agent }));
if (verb) where.verbId = verb as string;
if (activity) where.objectId = activity as string;
if (since) where.timestamp = { gte: new Date(since as string) };
if (until) where.timestamp = { ...where.timestamp, lte: new Date(until as string) };
const [statements, more] = await Promise.all([
prisma.statement.findMany({
where,
take,
skip,
orderBy: { timestamp: 'desc' }
}),
prisma.statement.count({ where }) > skip + take
]);
res.json({
statements: statements.map(s => s.statement),
more: more
});
});Endpoint GET avancé supporte ?limit=20&agent=mailto:john@example.com&verb=http://adlnet.gov/expapi/verbs/completed&since=2026-01-01. Pagination via more (xAPI standard). ascending pour ordre chrono. Optimisé avec Prisma queries natives, scale à 1M+ records.
Test du LRS
Lancez npx prisma migrate dev, npx prisma db push, puis npx ts-node src/server.ts. Testez /about, POST un statement exemple, GET avec filters.
Client HTML/JS pour tester
<!DOCTYPE html>
<html>
<head><title>Test xAPI</title></head>
<body>
<button onclick="sendStatement()">Envoyer Statement</button>
<pre id="log"></pre>
<script>
async function sendStatement() {
const stmt = {
actor: { mbox: 'mailto:learner@example.com' },
verb: { id: 'http://adlnet.gov/expapi/verbs/completed', display: { 'en-US': 'completed' } },
object: { id: 'https://example.com/quiz1', definition: { name: { 'en-US': 'Quiz Math' } } },
result: { score: { scaled: 0.9 } },
timestamp: new Date().toISOString(),
version: '1.0.3'
};
const res = await fetch('http://localhost:3000/statements', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ statements: [stmt] })
});
document.getElementById('log')!.textContent = JSON.stringify(await res.json(), null, 2);
}
</script>
</body>
</html>Client autonome pour valider l'API. Cliquez pour envoyer un statement complet (actor mbox IFI, verb standard, object activity). Log JSON réponse avec ID. Ouvrez en navigateur ; étendez pour tracking réel (ex: après quiz JS).
Package.json scripts
{
"scripts": {
"dev": "nodemon --exec ts-node src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"db:studio": "prisma studio"
}
}Scripts prêts : npm run dev pour développement, npm run db:migrate post-schema changes. prisma studio pour GUI DB. Ajoutez à votre package.json existant pour workflow fluide.
Bonnes pratiques
- Authentification : Implémentez Basic Auth ou OAuth2 Bearer (RFC xAPI) avec
passport.js. - Validation stricte : Utilisez
ajv+ schemas officiels xAPI pour 100% conformité. - Scaling : Migrez vers PostgreSQL + Redis pour cache queries ; sharding par actor.
- Logs/Metrics : Intégrez Winston + Prometheus pour monitorer throughput statements.
- JSON-LD : Validez
@context: 'https://w3id.org/xapi'pour interop sémantique.
Erreurs courantes à éviter
- IFI invalide : Toujours utiliser
mailto:,mbox_sha1sum:ou IRL ; sinon queries agent échouent. - Timestamp manquant : Client l'ajoute, LRS stocke
storedséparé pour audits. - Pas d'index DB : Sans
@@index, GET /statements lente >10k records. - Oubli /about : Mandatory pour clients xAPI (ex: Articulate Storyline).
Pour aller plus loin
- Spec officielle : xAPI 1.0.3
- LRS open-source : Learning Locker
- OAuth2 xAPI : RFC draft
- Formations Learni Dev sur Node.js avancé et e-learning APIs.