scorm-kit 0.2.0
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/LICENSE +21 -0
- package/README.md +109 -0
- package/bin/scorm-kit.js +62 -0
- package/package.json +32 -0
- package/src/a11y/a11y.js +401 -0
- package/src/cmi5/cmi5.js +561 -0
- package/src/confine.js +20 -0
- package/src/diff/diff.js +277 -0
- package/src/i18n/i18n-cli.js +222 -0
- package/src/i18n/runtime/i18n.js +168 -0
- package/src/lint/lint.js +323 -0
- package/src/mock/mock.js +197 -0
- package/src/mock/web/mock-lms.css +57 -0
- package/src/mock/web/mock-lms.html +53 -0
- package/src/mock/web/mock-lms.js +328 -0
- package/src/privacy/privacy.js +535 -0
- package/src/report/report.js +148 -0
- package/src/rum/rum-cli.js +144 -0
- package/src/rum/runtime/rum.js +156 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* scorm-rum — inject a Real User Monitoring runtime into a SCORM 1.2 package.
|
|
4
|
+
*
|
|
5
|
+
* scorm-rum course.zip --endpoint https://rum.example.com/ingest [--token TOKEN]
|
|
6
|
+
*
|
|
7
|
+
* Captures nav timing, resource errors, JS errors, long tasks, and slide
|
|
8
|
+
* transitions. Beacons batched and POSTed to the configured endpoint.
|
|
9
|
+
*/
|
|
10
|
+
"use strict";
|
|
11
|
+
|
|
12
|
+
var fs = require("fs");
|
|
13
|
+
var path = require("path");
|
|
14
|
+
var os = require("os");
|
|
15
|
+
var { spawnSync } = require("child_process");
|
|
16
|
+
var verifyConfinement = require("../confine");
|
|
17
|
+
|
|
18
|
+
function parseArgs(argv) {
|
|
19
|
+
var a = { input: "", out: "", endpoint: "", token: "", courseId: "", flushMs: 2000, dryRun: false };
|
|
20
|
+
for (var i = 0; i < argv.length; i++) {
|
|
21
|
+
var k = argv[i];
|
|
22
|
+
if (k === "--out") a.out = argv[++i];
|
|
23
|
+
else if (k === "--endpoint") a.endpoint = argv[++i];
|
|
24
|
+
else if (k === "--token") a.token = argv[++i];
|
|
25
|
+
else if (k === "--course-id") a.courseId = argv[++i];
|
|
26
|
+
else if (k === "--flush-ms") a.flushMs = +argv[++i];
|
|
27
|
+
else if (k === "--dry-run") a.dryRun = true;
|
|
28
|
+
else if (k === "-h" || k === "--help") { usage(); process.exit(0); }
|
|
29
|
+
else if (k[0] === "-") { console.error("Unknown flag: " + k); process.exit(2); }
|
|
30
|
+
else if (!a.input) a.input = k;
|
|
31
|
+
else { console.error("Unexpected arg: " + k); process.exit(2); }
|
|
32
|
+
}
|
|
33
|
+
if (!a.input || !a.endpoint) { usage(); process.exit(2); }
|
|
34
|
+
return a;
|
|
35
|
+
}
|
|
36
|
+
function usage() {
|
|
37
|
+
console.error("Usage: scorm-rum <package.zip|dir> --endpoint URL [options]");
|
|
38
|
+
console.error(" --token T optional bearer token (Authorization header)");
|
|
39
|
+
console.error(" --course-id ID course id (default: manifest identifier)");
|
|
40
|
+
console.error(" --flush-ms N beacon batch interval (default 2000)");
|
|
41
|
+
console.error(" --out PATH output zip");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function unzipToTemp(zipPath) {
|
|
45
|
+
var tmp = fs.mkdtempSync(path.join(os.tmpdir(), "scorm-rum-"));
|
|
46
|
+
var r = spawnSync("unzip", ["-q", "-o", zipPath, "-d", tmp]);
|
|
47
|
+
if (r.status !== 0) throw new Error("unzip: " + r.stderr.toString());
|
|
48
|
+
verifyConfinement(tmp);
|
|
49
|
+
return tmp;
|
|
50
|
+
}
|
|
51
|
+
function zipFromDir(dir, out) {
|
|
52
|
+
if (fs.existsSync(out)) fs.unlinkSync(out);
|
|
53
|
+
var r = spawnSync("zip", ["-qrX", out, "."], { cwd: dir });
|
|
54
|
+
if (r.status !== 0) throw new Error("zip: " + r.stderr.toString());
|
|
55
|
+
}
|
|
56
|
+
function findLaunchHref(root) {
|
|
57
|
+
var p = path.join(root, "imsmanifest.xml");
|
|
58
|
+
if (!fs.existsSync(p)) return null;
|
|
59
|
+
var xml = fs.readFileSync(p, "utf8");
|
|
60
|
+
var m = /<resource\b[^>]*\bscormtype\s*=\s*["']sco["'][^>]*\bhref\s*=\s*["']([^"']+)["']/i.exec(xml);
|
|
61
|
+
if (!m) m = /<resource\b[^>]*\bhref\s*=\s*["']([^"']+)["'][^>]*\bscormtype\s*=\s*["']sco["']/i.exec(xml);
|
|
62
|
+
if (!m) m = /<resource\b[^>]*\bhref\s*=\s*["']([^"']+)["']/i.exec(xml);
|
|
63
|
+
return m ? m[1] : null;
|
|
64
|
+
}
|
|
65
|
+
function parseId(root) {
|
|
66
|
+
var p = path.join(root, "imsmanifest.xml");
|
|
67
|
+
if (!fs.existsSync(p)) return "";
|
|
68
|
+
var xml = fs.readFileSync(p, "utf8");
|
|
69
|
+
var m = /<manifest\b[^>]*\bidentifier\s*=\s*["']([^"']+)["']/i.exec(xml);
|
|
70
|
+
return m ? m[1] : "";
|
|
71
|
+
}
|
|
72
|
+
function injectScripts(html, cfgName, runtimeName) {
|
|
73
|
+
if (/<script\s+[^>]*src=["']scorm-rum\.js["']/i.test(html)) return { html: html, injected: false };
|
|
74
|
+
var tags =
|
|
75
|
+
'<script src="' + cfgName + '"></script>\n' +
|
|
76
|
+
'<script src="' + runtimeName + '"></script>\n';
|
|
77
|
+
if (/<\/head>/i.test(html))
|
|
78
|
+
return { html: html.replace(/<\/head>/i, tags + "</head>"), injected: true };
|
|
79
|
+
// Some HTML omits the closing </head> tag — fall back to the opening one.
|
|
80
|
+
if (/<head\b[^>]*>/i.test(html))
|
|
81
|
+
return { html: html.replace(/<head\b[^>]*>/i, function (m) { return m + "\n" + tags; }), injected: true };
|
|
82
|
+
if (/<html\b[^>]*>/i.test(html))
|
|
83
|
+
return { html: html.replace(/<html\b[^>]*>/i, function (m) { return m + "\n<head>\n" + tags + "</head>"; }), injected: true };
|
|
84
|
+
return { html: tags + html, injected: true };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function main() {
|
|
88
|
+
var args = parseArgs(process.argv.slice(2));
|
|
89
|
+
if (!fs.existsSync(args.input)) { console.error("Not found: " + args.input); process.exit(2); }
|
|
90
|
+
|
|
91
|
+
var runtimeSrc = path.join(__dirname, "runtime", "rum.js");
|
|
92
|
+
if (!fs.existsSync(runtimeSrc)) { console.error("Runtime missing"); process.exit(2); }
|
|
93
|
+
|
|
94
|
+
var isZip = fs.statSync(args.input).isFile();
|
|
95
|
+
var root, cleanup = function () {};
|
|
96
|
+
if (isZip) {
|
|
97
|
+
root = unzipToTemp(args.input);
|
|
98
|
+
cleanup = function () { try { fs.rmSync(root, { recursive: true, force: true }); } catch (e) {} };
|
|
99
|
+
} else {
|
|
100
|
+
root = path.resolve(args.input);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
var href = findLaunchHref(root);
|
|
105
|
+
if (!href) { console.error("No launch HTML in manifest."); process.exit(2); }
|
|
106
|
+
var courseId = args.courseId || parseId(root) || "course";
|
|
107
|
+
var launchPath = path.resolve(root, href.split("?")[0]);
|
|
108
|
+
|
|
109
|
+
console.log("endpoint: " + args.endpoint);
|
|
110
|
+
console.log("course: " + courseId);
|
|
111
|
+
console.log("flushMs: " + args.flushMs);
|
|
112
|
+
if (args.token) console.log("auth: Bearer (" + args.token.length + " chars)");
|
|
113
|
+
|
|
114
|
+
if (args.dryRun) { console.log("(dry-run)"); return; }
|
|
115
|
+
|
|
116
|
+
var launchDir = path.dirname(launchPath);
|
|
117
|
+
fs.copyFileSync(runtimeSrc, path.join(launchDir, "scorm-rum.js"));
|
|
118
|
+
fs.writeFileSync(
|
|
119
|
+
path.join(launchDir, "rum-config.js"),
|
|
120
|
+
"window.RUM_CONFIG = " + JSON.stringify({
|
|
121
|
+
endpoint: args.endpoint, token: args.token, courseId: courseId, flushMs: args.flushMs,
|
|
122
|
+
}, null, 2) + ";\n"
|
|
123
|
+
);
|
|
124
|
+
var html = fs.readFileSync(launchPath, "utf8");
|
|
125
|
+
var inj = injectScripts(html, "rum-config.js", "scorm-rum.js");
|
|
126
|
+
if (inj.injected) {
|
|
127
|
+
fs.writeFileSync(launchPath, inj.html);
|
|
128
|
+
console.log("injected " + path.relative(root, launchPath));
|
|
129
|
+
} else {
|
|
130
|
+
console.log("inject skipped (already wrapped); config refreshed");
|
|
131
|
+
}
|
|
132
|
+
console.log("copied scorm-rum.js");
|
|
133
|
+
console.log("wrote rum-config.js");
|
|
134
|
+
|
|
135
|
+
if (isZip) {
|
|
136
|
+
var out = path.resolve(args.out || args.input.replace(/\.zip$/i, "") + "-rum.zip");
|
|
137
|
+
zipFromDir(root, out);
|
|
138
|
+
console.log("\nwrote " + out);
|
|
139
|
+
}
|
|
140
|
+
} finally {
|
|
141
|
+
cleanup();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
main();
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* scorm-rum runtime — Real User Monitoring for SCORM courses.
|
|
3
|
+
*
|
|
4
|
+
* Captures:
|
|
5
|
+
* - navigation timing (DNS, TCP, TTFB, DOMContentLoaded, load)
|
|
6
|
+
* - resource load failures (img/script/css 404, timeouts)
|
|
7
|
+
* - JS errors (window.onerror, unhandledrejection)
|
|
8
|
+
* - long tasks (PerformanceObserver: longtask)
|
|
9
|
+
* - slide-level page views (when the launch HTML changes location.hash)
|
|
10
|
+
*
|
|
11
|
+
* Beacons are batched and posted to cfg.endpoint as JSON. On pagehide, we
|
|
12
|
+
* flush via fetch keepalive so abrupt closes don't lose the session.
|
|
13
|
+
*/
|
|
14
|
+
(function () {
|
|
15
|
+
"use strict";
|
|
16
|
+
var cfg = window.RUM_CONFIG || {};
|
|
17
|
+
if (!cfg.endpoint) { console.warn("[scorm-rum] no endpoint — runtime disabled"); return; }
|
|
18
|
+
|
|
19
|
+
var sessionId = uuid();
|
|
20
|
+
var courseId = cfg.courseId || "course";
|
|
21
|
+
var actor = cfg.actor || "anonymous";
|
|
22
|
+
|
|
23
|
+
// Try to enrich actor from SCORM if available (read-only, no init side effect).
|
|
24
|
+
try {
|
|
25
|
+
var api = (function () {
|
|
26
|
+
var w = window, d = 500;
|
|
27
|
+
while (w && d-- > 0) {
|
|
28
|
+
if (w.API) return w.API;
|
|
29
|
+
if (w.parent && w.parent !== w) { w = w.parent; continue; }
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
})();
|
|
34
|
+
if (api) {
|
|
35
|
+
var id = (api.LMSGetValue("cmi.core.student_id") || "").trim();
|
|
36
|
+
if (id) actor = id;
|
|
37
|
+
}
|
|
38
|
+
} catch (e) {}
|
|
39
|
+
|
|
40
|
+
// ---------- event capture ----------------------------------------------
|
|
41
|
+
|
|
42
|
+
var queue = [];
|
|
43
|
+
function push(type, fields) {
|
|
44
|
+
var ev = {
|
|
45
|
+
t: Date.now(), session: sessionId, course: courseId, actor: actor,
|
|
46
|
+
url: location.pathname + location.hash, type: type,
|
|
47
|
+
};
|
|
48
|
+
for (var k in fields) ev[k] = fields[k];
|
|
49
|
+
queue.push(ev);
|
|
50
|
+
scheduleFlush();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function uuid() {
|
|
54
|
+
if (window.crypto && crypto.randomUUID) return crypto.randomUUID();
|
|
55
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
|
|
56
|
+
var r = (Math.random() * 16) | 0; return (c === "x" ? r : (r & 0x3) | 0x8).toString(16);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 1. Navigation timing — emit one record after load.
|
|
61
|
+
function captureNavTiming() {
|
|
62
|
+
if (!window.performance || !performance.getEntriesByType) return;
|
|
63
|
+
var nav = performance.getEntriesByType("navigation")[0];
|
|
64
|
+
if (!nav) return;
|
|
65
|
+
push("nav-timing", {
|
|
66
|
+
dns: Math.round(nav.domainLookupEnd - nav.domainLookupStart),
|
|
67
|
+
tcp: Math.round(nav.connectEnd - nav.connectStart),
|
|
68
|
+
ttfb: Math.round(nav.responseStart - nav.requestStart),
|
|
69
|
+
dom: Math.round(nav.domContentLoadedEventEnd - nav.startTime),
|
|
70
|
+
load: Math.round(nav.loadEventEnd - nav.startTime),
|
|
71
|
+
transfer: nav.transferSize || null,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
if (document.readyState === "complete") setTimeout(captureNavTiming, 0);
|
|
75
|
+
else window.addEventListener("load", function () { setTimeout(captureNavTiming, 0); });
|
|
76
|
+
|
|
77
|
+
// 2. Resource load failures (PerformanceResourceTiming with transferSize=0
|
|
78
|
+
// is sometimes ambiguous; the simpler signal is the `error` event on
|
|
79
|
+
// img/script/link, which we capture via capture-phase listener).
|
|
80
|
+
window.addEventListener("error", function (e) {
|
|
81
|
+
if (e.target && e.target !== window && e.target.src !== undefined) {
|
|
82
|
+
push("resource-error", {
|
|
83
|
+
kind: (e.target.tagName || "").toLowerCase(),
|
|
84
|
+
src: e.target.src || e.target.href || "",
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}, true);
|
|
88
|
+
|
|
89
|
+
// 3. JS errors.
|
|
90
|
+
window.addEventListener("error", function (e) {
|
|
91
|
+
if (e.target && e.target !== window && e.target !== document) return; // resource-error handled above
|
|
92
|
+
push("js-error", {
|
|
93
|
+
message: e.message,
|
|
94
|
+
filename: e.filename,
|
|
95
|
+
line: e.lineno, col: e.colno,
|
|
96
|
+
stack: e.error && e.error.stack ? String(e.error.stack).slice(0, 1000) : "",
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
window.addEventListener("unhandledrejection", function (e) {
|
|
100
|
+
var reason = e.reason && (e.reason.stack || e.reason.message || String(e.reason));
|
|
101
|
+
push("js-error", { kind: "unhandledrejection", message: String(reason).slice(0, 1000) });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// 4. Long tasks (any task ≥ 50ms on the main thread).
|
|
105
|
+
if (window.PerformanceObserver) {
|
|
106
|
+
try {
|
|
107
|
+
new PerformanceObserver(function (list) {
|
|
108
|
+
list.getEntries().forEach(function (entry) {
|
|
109
|
+
push("longtask", { duration: Math.round(entry.duration), startTime: Math.round(entry.startTime) });
|
|
110
|
+
});
|
|
111
|
+
}).observe({ entryTypes: ["longtask"] });
|
|
112
|
+
} catch (e) {}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 5. Slide transitions — record hash changes (the launch HTML's slide nav)
|
|
116
|
+
// or full-page navigation via the SPA pattern.
|
|
117
|
+
var lastHash = location.hash, lastHashAt = Date.now();
|
|
118
|
+
window.addEventListener("hashchange", function () {
|
|
119
|
+
var now = Date.now();
|
|
120
|
+
push("slide-change", {
|
|
121
|
+
from: lastHash, to: location.hash,
|
|
122
|
+
dwell: now - lastHashAt,
|
|
123
|
+
});
|
|
124
|
+
lastHash = location.hash; lastHashAt = now;
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ---------- dispatch ---------------------------------------------------
|
|
128
|
+
|
|
129
|
+
var flushTimer = null;
|
|
130
|
+
function scheduleFlush() {
|
|
131
|
+
if (flushTimer) return;
|
|
132
|
+
flushTimer = setTimeout(function () { flushTimer = null; flush(false); }, cfg.flushMs || 2000);
|
|
133
|
+
}
|
|
134
|
+
function flush(unload) {
|
|
135
|
+
if (queue.length === 0) return;
|
|
136
|
+
var batch = queue.splice(0, queue.length);
|
|
137
|
+
var headers = { "Content-Type": "application/json" };
|
|
138
|
+
if (cfg.token) headers["Authorization"] = "Bearer " + cfg.token;
|
|
139
|
+
try {
|
|
140
|
+
fetch(cfg.endpoint, {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: headers,
|
|
143
|
+
body: JSON.stringify({ session: sessionId, events: batch }),
|
|
144
|
+
keepalive: !!unload, mode: "cors",
|
|
145
|
+
}).catch(function () { /* swallow; next batch will retry */ });
|
|
146
|
+
} catch (e) {}
|
|
147
|
+
}
|
|
148
|
+
window.addEventListener("pagehide", function () { flush(true); });
|
|
149
|
+
|
|
150
|
+
// Expose for ad-hoc author beacons.
|
|
151
|
+
window.ScormRUM = {
|
|
152
|
+
record: function (type, fields) { push(type || "custom", fields || {}); },
|
|
153
|
+
flush: flush,
|
|
154
|
+
sessionId: sessionId,
|
|
155
|
+
};
|
|
156
|
+
})();
|