SCORM Support in LMS
SCORM (Sharable Content Object Reference Model) is a standard for packaging e-learning content. Most corporate courses created in Articulate Storyline, Adobe Captivate, iSpring are SCORM packages. An LMS must be able to upload them, run them in an iframe, and get progress data.
What is a SCORM Package
A SCORM package is a ZIP archive with an imsmanifest.xml file and HTML/JS/media files of the course. Versions: SCORM 1.2 (most common) and SCORM 2004 (4th edition).
Package structure:
course.zip
├── imsmanifest.xml # metadata, structure
├── index.html # course entry point
├── scorm_api.js # SCORM API implementation
└── content/
├── slide1.html
├── media/
└── ...
SCORM API — Bridge Between Course and LMS
The course communicates with the LMS via JavaScript API. The LMS creates a global object API (SCORM 1.2) or API_1484_11 (SCORM 2004) in the window where the iframe is running:
// SCORM 1.2 API object — created on LMS page
class ScormApi12 {
private lessonStatus = 'not attempted';
private suspendData = '';
private score = 0;
private sessionTime = '';
private dataStore = new Map<string, string>();
private onComplete: (data: ScormData) => void;
constructor(onComplete: (data: ScormData) => void) {
this.onComplete = onComplete;
}
LMSInitialize(_: string): string {
this.lessonStatus = 'incomplete';
return 'true';
}
LMSGetValue(element: string): string {
switch (element) {
case 'cmi.core.lesson_status': return this.lessonStatus;
case 'cmi.suspend_data': return this.suspendData;
case 'cmi.core.score.raw': return String(this.score);
case 'cmi.core.lesson_location': return this.dataStore.get('lesson_location') ?? '';
default: return this.dataStore.get(element) ?? '';
}
}
LMSSetValue(element: string, value: string): string {
switch (element) {
case 'cmi.core.lesson_status':
this.lessonStatus = value;
break;
case 'cmi.suspend_data':
this.suspendData = value;
break;
case 'cmi.core.score.raw':
this.score = Number(value);
break;
case 'cmi.core.session_time':
this.sessionTime = value;
break;
default:
this.dataStore.set(element, value);
}
return 'true';
}
LMSCommit(_: string): string {
// Send data to server (throttle — no more than once per 5 seconds)
this.saveProgress();
return 'true';
}
LMSFinish(_: string): string {
this.onComplete({
status: this.lessonStatus,
score: this.score,
suspendData: this.suspendData,
sessionTime: this.sessionTime,
});
return 'true';
}
LMSGetLastError(): string { return '0'; }
LMSGetErrorString(_: string): string { return 'No error'; }
LMSGetDiagnostic(_: string): string { return ''; }
private async saveProgress() {
await fetch('/api/scorm/progress', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status: this.lessonStatus,
score: this.score,
suspendData: this.suspendData,
}),
});
}
}
API Injection into iframe
The course looks for API in parent windows (window.parent.parent...). The LMS sets the object before loading the iframe:
function ScormPlayer({ courseId, enrollmentId }) {
const iframeRef = useRef<HTMLIFrameElement>(null);
useEffect(() => {
// Set SCORM API on current window — iframe will find it via parent
const api = new ScormApi12(async (data) => {
await fetch(`/api/enrollments/${enrollmentId}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
});
// SCORM 1.2
(window as any).API = api;
// SCORM 2004
(window as any).API_1484_11 = api;
return () => {
delete (window as any).API;
delete (window as any).API_1484_11;
};
}, [enrollmentId]);
return (
<iframe
ref={iframeRef}
src={`/api/courses/${courseId}/launch`}
className="w-full border-0"
style={{ height: 'calc(100vh - 64px)' }}
allow="camera; microphone; fullscreen"
title="SCORM Course"
/>
);
}
Uploading and Extracting SCORM Package
import AdmZip from 'adm-zip';
import { parseStringPromise } from 'xml2js';
app.post('/api/courses/upload', authenticate, upload.single('scorm'), async (req, res) => {
const zipBuffer = req.file!.buffer;
const zip = new AdmZip(zipBuffer);
// Extract to storage (S3 or local)
const courseId = crypto.randomUUID();
const extractPath = `/courses/${courseId}`;
zip.extractAllTo(path.join(process.env.STORAGE_PATH!, extractPath), true);
// Parse manifest
const manifestEntry = zip.getEntry('imsmanifest.xml');
if (!manifestEntry) throw new Error('Invalid SCORM package: no imsmanifest.xml');
const manifest = await parseStringPromise(manifestEntry.getData().toString());
const title = manifest.manifest.organizations[0].organization[0].title[0];
const launchUrl = manifest.manifest.resources[0].resource[0]['$']['href'];
const scormVersion = manifest.manifest['$']['version']?.includes('1.2') ? '1.2' : '2004';
const course = await db.courses.create({
id: courseId,
title,
launchUrl: `${extractPath}/${launchUrl}`,
scormVersion,
uploadedBy: req.user.id,
});
res.json(course);
});
Storing Progress
CREATE TABLE scorm_progress (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
enrollment_id UUID REFERENCES enrollments(id),
lesson_status VARCHAR(50), -- 'passed' | 'failed' | 'completed' | 'incomplete'
score NUMERIC(5,2),
suspend_data TEXT, -- for bookmarks and course state
session_time INTERVAL,
completed_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ DEFAULT now()
);
SCORM 1.2 vs SCORM 2004
| Parameter | SCORM 1.2 | SCORM 2004 |
|---|---|---|
| API object | window.API |
window.API_1484_11 |
| Statuses | passed/failed/completed/incomplete | passed/failed/completed/incomplete/not attempted/unknown |
| Score | 0–100 | 0.0–1.0 (min/max/raw) |
| Progress | suspend_data | suspend_data + adl.nav |
| Prevalence | Wide | Less |
Timeframe
SCORM 1.2 support with package upload, API object, and progress storage — 1–1.5 weeks. With SCORM 2004 support — additional 3–5 days.







