const http = require('http'); const https = require('https'); const fs = require('fs'); const path = require('path'); const WebSocket = require('ws'); const { questionIndexFromOrderedResult, evaluateActivityResultCorrect, isTextInputResultValidateLater, countValidateLaterTextInputResults } = require('./lib/activity-results-validation'); const { buildActivityReportMarkdown } = require('./lib/build-activity-report'); const { buildScoreJson } = require('./lib/build-score-json'); const { renderMarkdown } = require('./lib/render-markdown'); const { extractQuestionTypeFromMarkdown, buildActivityFromMarkdown, parseAnswersFromMarkdown, detectAnswersPresent, } = require('./lib/parse-question'); const { splitCombinedMarkdown, mergeQuestionAndAnswerKey } = require('./lib/split-answer-key'); const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000; const PUBLIC_DIR = path.join(__dirname, 'public'); const DATA_DIR = path.join(__dirname, 'data'); const EXAMPLES_DIR = path.join(DATA_DIR, 'examples'); const QUESTION_MD_PATH = path.join(DATA_DIR, 'question.md'); /** When set, requests under /sim/ (and configured root paths) reverse-proxy to the simulation backend. */ const SIM_ORIGIN = process.env.SIM_ORIGIN || null; const SIM_MOUNT_PATH = '/sim'; /** Root-absolute paths some simulations request when embedded (e.g. bundled /assets/*). Override via SIM_ROOT_PROXY_PATHS. */ const SIM_ROOT_PROXY_PATHS = (process.env.SIM_ROOT_PROXY_PATHS || '/assets/,/configs/,/api/logs') .split(',') .map((entry) => entry.trim()) .filter(Boolean); function resolveCliPath(arg) { return path.isAbsolute(arg) ? arg : path.resolve(process.cwd(), arg); } const CLI_FLAGS = ['--edit', '--copy-markdown', '--examples', '--answer-key']; function takePathOperand(flag, args, i) { const operand = args[i + 1]; if (operand === undefined || CLI_FLAGS.includes(operand)) { console.error(`${flag} requires a file path`); process.exit(1); } return resolveCliPath(operand); } // Parse command line arguments for --edit, --answer-key, --copy-markdown, and --examples let EDIT_MODE = false; let EXAMPLES_MODE = false; let EDIT_FILE_PATH = null; let ANSWER_KEY_FILE_PATH = null; let COPY_MARKDOWN_ENABLED = false; const args = process.argv.slice(2); for (let i = 0; i < args.length; i++) { if (args[i] === '--copy-markdown') { COPY_MARKDOWN_ENABLED = true; continue; } if (args[i] === '--examples') { EXAMPLES_MODE = true; continue; } if (args[i] === '--edit') { EDIT_MODE = true; EDIT_FILE_PATH = takePathOperand('--edit', args, i); i++; continue; } if (args[i] === '--answer-key') { ANSWER_KEY_FILE_PATH = takePathOperand('--answer-key', args, i); i++; continue; } } if (ANSWER_KEY_FILE_PATH && !EDIT_MODE) { console.warn('--answer-key is ignored unless --edit is also set'); ANSWER_KEY_FILE_PATH = null; } if (EDIT_MODE && ANSWER_KEY_FILE_PATH && EDIT_FILE_PATH === ANSWER_KEY_FILE_PATH) { console.error('--edit and --answer-key must resolve to different files'); process.exit(1); } const MIME_TYPES = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'application/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.md': 'text/markdown; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.ico': 'image/x-icon', '.txt': 'text/plain; charset=utf-8' }; function isPathInside(child, parent) { const relative = path.relative(parent, child); return !!relative && !relative.startsWith('..') && !path.isAbsolute(relative); } /** Basename only; safe characters for data/examples/*.md */ function isValidExampleBasename(name) { if (typeof name !== 'string' || !name.trim()) return false; const t = name.trim(); if (t !== path.basename(t)) return false; return /^[a-zA-Z0-9][a-zA-Z0-9._-]*\.md$/i.test(t); } function sendFile(res, filePath, status = 200) { const ext = path.extname(filePath).toLowerCase(); const contentType = MIME_TYPES[ext] || 'application/octet-stream'; res.writeHead(status, { 'Content-Type': contentType, // Disable aggressive caching during development 'Cache-Control': 'no-store' }); const read = fs.createReadStream(filePath); read.on('error', () => { res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('Internal Server Error'); }); read.pipe(res); } function respondJson(res, status, payload) { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(payload)); } function writeUtf8File(filePath, contents, cb) { const dir = path.dirname(filePath); try { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } } catch (err) { cb(err); return; } fs.writeFile(filePath, contents, 'utf8', cb); } function simUpstreamPath(pathname) { if (pathname === SIM_MOUNT_PATH) return '/'; const prefix = `${SIM_MOUNT_PATH}/`; if (pathname.startsWith(prefix)) { const subpath = pathname.slice(SIM_MOUNT_PATH.length) || '/'; // Collapse leading slashes so subpath cannot resolve as a scheme-relative URL via new URL(). return `/${subpath.replace(/^\/+/, '')}`; } return pathname; } function shouldProxyToSim(pathname) { // Always intercept /sim/* so it never falls through to the activity page. if (pathname === SIM_MOUNT_PATH || pathname.startsWith(`${SIM_MOUNT_PATH}/`)) return true; if (!SIM_ORIGIN) return false; for (const entry of SIM_ROOT_PROXY_PATHS) { if (entry.endsWith('/')) { if (pathname.startsWith(entry)) return true; } else if (pathname === entry || pathname.startsWith(`${entry}/`)) { return true; } } return false; } function simErrorHtml(message) { return `
${message}
`; } function proxyToSim(req, res, pathname, search) { if (!SIM_ORIGIN) { res.writeHead(502, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(simErrorHtml('No simulation configured —SIM_ORIGIN is not set.'));
return;
}
const subpath = simUpstreamPath(pathname);
const target = new URL(subpath, SIM_ORIGIN);
const isHttps = target.protocol === 'https:';
const transport = isHttps ? https : http;
const options = {
hostname: target.hostname,
port: target.port || (isHttps ? 443 : 80),
path: target.pathname + (search || ''),
method: req.method,
headers: { ...req.headers, host: target.host }
};
const proxyReq = transport.request(options, (proxyRes) => {
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res);
});
proxyReq.on('error', () => {
res.writeHead(502, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(simErrorHtml(`Nothing is running at ${SIM_ORIGIN}`));
});
if (req.method === 'GET' || req.method === 'HEAD') {
proxyReq.end();
} else {
req.pipe(proxyReq);
}
}
// Store active WebSocket connections
const clients = new Set();
const server = http.createServer((req, res) => {
const urlObj = new URL(req.url, 'http://localhost');
let pathname = decodeURIComponent(urlObj.pathname || '/');
if (shouldProxyToSim(pathname)) {
proxyToSim(req, res, pathname, urlObj.search);
return;
}
// Examples mode: shell at /, activity app in iframe at /play
if (EXAMPLES_MODE && pathname === '/') {
sendFile(res, path.join(PUBLIC_DIR, 'examples-mode.html'), 200);
return;
}
if (EXAMPLES_MODE && pathname === '/play') {
sendFile(res, path.join(PUBLIC_DIR, 'index.html'), 200);
return;
}
// In edit mode, serve editor.html for root path
if (EDIT_MODE && pathname === '/') {
pathname = '/editor.html';
}
// API: /api/editor/load - Load markdown file for editing
if (pathname === '/api/editor/load' && req.method === 'GET') {
if (!EDIT_MODE || !EDIT_FILE_PATH) {
respondJson(res, 400, { error: 'Editor mode not enabled' });
return;
}
fs.readFile(EDIT_FILE_PATH, 'utf8', (err, data) => {
if (err) {
if (err.code === 'ENOENT') {
if (ANSWER_KEY_FILE_PATH && fs.existsSync(ANSWER_KEY_FILE_PATH)) {
respondJson(res, 409, { error: 'Question file is missing while answer key exists' });
return;
}
// File doesn't exist; client prompts for question type
respondJson(res, 200, {
markdown: '',
filePath: EDIT_FILE_PATH,
questionType: null,
copyMarkdownEnabled: COPY_MARKDOWN_ENABLED
});
} else {
respondJson(res, 500, { error: 'Failed to read file' });
}
return;
}
let markdown = data;
if (ANSWER_KEY_FILE_PATH && fs.existsSync(ANSWER_KEY_FILE_PATH)) {
try {
const answerKeyMd = fs.readFileSync(ANSWER_KEY_FILE_PATH, 'utf8');
markdown = mergeQuestionAndAnswerKey(data, answerKeyMd);
} catch (e) {
respondJson(res, 500, { error: 'Failed to load answer key' });
return;
}
}
respondJson(res, 200, {
markdown,
filePath: EDIT_FILE_PATH,
questionType: extractQuestionTypeFromMarkdown(markdown),
copyMarkdownEnabled: COPY_MARKDOWN_ENABLED
});
});
return;
}
// API: /api/editor/save - Save markdown file
if (pathname === '/api/editor/save' && req.method === 'POST') {
if (!EDIT_MODE || !EDIT_FILE_PATH) {
respondJson(res, 400, { error: 'Editor mode not enabled' });
return;
}
let body = '';
req.on('data', chunk => {
body += chunk.toString();
});
req.on('end', () => {
try {
const data = JSON.parse(body);
const markdown = data.markdown || '';
let questionMarkdown = markdown;
let answerKeyMarkdown = null;
if (ANSWER_KEY_FILE_PATH) {
const split = splitCombinedMarkdown(markdown);
questionMarkdown = split.questionMarkdown;
answerKeyMarkdown = split.answerKeyMarkdown;
}
if (!ANSWER_KEY_FILE_PATH) {
writeUtf8File(EDIT_FILE_PATH, questionMarkdown, (err) => {
if (err) {
respondJson(res, 500, { error: 'Failed to save file' });
return;
}
respondJson(res, 200, { success: true, filePath: EDIT_FILE_PATH });
});
return;
}
writeUtf8File(ANSWER_KEY_FILE_PATH, answerKeyMarkdown || '', (keyErr) => {
if (keyErr) {
respondJson(res, 500, { error: 'Failed to save answer key' });
return;
}
writeUtf8File(EDIT_FILE_PATH, questionMarkdown, (err) => {
if (err) {
respondJson(res, 500, { error: 'Failed to save file' });
return;
}
respondJson(res, 200, {
success: true,
filePath: EDIT_FILE_PATH,
answerKeyPath: ANSWER_KEY_FILE_PATH
});
});
});
} catch (e) {
respondJson(res, 400, { error: 'Invalid JSON' });
}
});
return;
}
// API: /api/examples/list — names of data/examples/*.md (only with --examples)
if (EXAMPLES_MODE && pathname === '/api/examples/list' && req.method === 'GET') {
fs.readdir(EXAMPLES_DIR, { withFileTypes: true }, (err, entries) => {
if (err) {
if (err.code === 'ENOENT') {
respondJson(res, 200, { examples: [] });
return;
}
respondJson(res, 500, { error: 'Failed to read examples directory' });
return;
}
const names = entries
.filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md') && !e.name.toLowerCase().endsWith('-answer-key.md'))
.map((e) => e.name)
.sort((a, b) => a.localeCompare(b));
respondJson(res, 200, { examples: names });
});
return;
}
// API: /api/examples/select — copy one example to data/question.md (only with --examples)
if (EXAMPLES_MODE && pathname === '/api/examples/select' && req.method === 'POST') {
let body = '';
req.on('data', (chunk) => {
body += chunk.toString();
});
req.on('end', () => {
let payload;
try {
payload = JSON.parse(body || '{}');
} catch (e) {
respondJson(res, 400, { error: 'Invalid JSON' });
return;
}
const filename = payload.filename;
if (!isValidExampleBasename(filename)) {
respondJson(res, 400, { error: 'Invalid example filename' });
return;
}
const srcPath = path.join(EXAMPLES_DIR, filename);
if (!isPathInside(srcPath, EXAMPLES_DIR)) {
respondJson(res, 400, { error: 'Invalid example path' });
return;
}
fs.readFile(srcPath, 'utf8', (readErr, content) => {
if (readErr) {
if (readErr.code === 'ENOENT') {
respondJson(res, 404, { error: 'Example file not found' });
} else {
respondJson(res, 500, { error: 'Failed to read example' });
}
return;
}
fs.writeFile(QUESTION_MD_PATH, content, 'utf8', (writeErr) => {
if (writeErr) {
respondJson(res, 500, { error: 'Failed to write question.md' });
return;
}
const staleFiles = [
path.join(DATA_DIR, 'answer.md'),
path.join(DATA_DIR, 'report.md'),
path.join(DATA_DIR, 'score.json'),
];
Promise.all(staleFiles.map((filePath) => new Promise((resolve, reject) => {
fs.unlink(filePath, (err) => {
if (err && err.code !== 'ENOENT') {
reject(err);
return;
}
resolve();
});
}))).then(() => {
respondJson(res, 200, { success: true, filename });
}).catch(() => {
respondJson(res, 500, { error: 'Failed to clear previous example results' });
});
});
});
});
return;
}
// API: /api/examples/grade — run generate-score.js for the current assessment example
if (EXAMPLES_MODE && pathname === '/api/examples/grade' && req.method === 'POST') {
let body = '';
req.on('data', (chunk) => { body += chunk.toString(); });
req.on('end', () => {
let payload;
try {
payload = JSON.parse(body || '{}');
} catch (e) {
respondJson(res, 400, { error: 'Invalid JSON' });
return;
}
const { filename } = payload;
if (!filename || typeof filename !== 'string') {
respondJson(res, 400, { error: 'Missing filename' });
return;
}
// Derive the answer-key filename from the example filename
const answerKeyFilename = filename.replace(/\.md$/i, '-answer-key.md');
const answerKeyPath = path.join(EXAMPLES_DIR, answerKeyFilename);
if (!isPathInside(answerKeyPath, EXAMPLES_DIR)) {
respondJson(res, 400, { error: 'Invalid path' });
return;
}
if (!fs.existsSync(answerKeyPath)) {
respondJson(res, 404, { error: `Answer key not found: ${answerKeyFilename}` });
return;
}
const questionPath = QUESTION_MD_PATH;
const answerPath = path.join(DATA_DIR, 'answer.md');
if (!fs.existsSync(answerPath)) {
respondJson(res, 400, { error: 'No answer.md found — submit answers in the activity first' });
return;
}
const scriptPath = path.join(__dirname, 'generate-score.js');
const reportPath = path.join(DATA_DIR, 'report.md');
const scorePath = path.join(DATA_DIR, 'score.json');
const { execFile } = require('child_process');
execFile(process.execPath, [
scriptPath,
'--question', questionPath,
'--answer', answerPath,
'--answer-key', answerKeyPath,
'--report', reportPath,
'--score', scorePath,
], { timeout: 15000 }, (err, stdout, stderr) => {
if (err) {
respondJson(res, 500, { error: 'Grading failed: ' + (stderr || err.message) });
return;
}
try {
const report = fs.readFileSync(reportPath, 'utf8');
const score = JSON.parse(fs.readFileSync(scorePath, 'utf8'));
respondJson(res, 200, { report, score, stdout: stdout.trim() });
} catch (readErr) {
respondJson(res, 500, {
error: 'Grading failed: report or score output is missing or invalid'
});
}
});
});
return;
}
// API: /api/examples/results — read existing score.json and report.md (classic mode)
if (EXAMPLES_MODE && pathname === '/api/examples/results' && req.method === 'GET') {
const reportPath = path.join(DATA_DIR, 'report.md');
const scorePath = path.join(DATA_DIR, 'score.json');
const report = fs.existsSync(reportPath) ? fs.readFileSync(reportPath, 'utf8') : '';
const scoreRaw = fs.existsSync(scorePath) ? fs.readFileSync(scorePath, 'utf8') : '';
if (!report && !scoreRaw) {
respondJson(res, 404, { error: 'No results found — submit answers in the activity first' });
return;
}
let score = null;
try { score = JSON.parse(scoreRaw); } catch (e) { /* ignore parse errors */ }
respondJson(res, 200, { report, score });
return;
}
// API: /api/activity
if (pathname === '/api/activity') {
const activityFile = path.join(DATA_DIR, 'question.md');
fs.readFile(activityFile, 'utf8', (err, data) => {
if (err) {
respondJson(res, 404, { error: 'Activity file not found' });
return;
}
try {
const activity = buildActivityFromMarkdown(data);
if (!detectAnswersPresent(activity)) {
activity.assessmentMode = true;
}
respondJson(res, 200, activity);
} catch (e) {
respondJson(res, 500, { error: 'Failed to parse markdown' });
}
});
return;
}
// API: /api/answers
if (pathname === '/api/answers') {
const answerFile = path.join(DATA_DIR, 'answer.md');
const activityFile = path.join(DATA_DIR, 'question.md');
// Read both answer.md and question.md to map result indices to question IDs
fs.readFile(answerFile, 'utf8', (err, answerData) => {
if (err) {
// If file doesn't exist, return empty answers
respondJson(res, 200, { answers: null, type: null, explanations: null });
return;
}
// Also read activity to map indices to question IDs
fs.readFile(activityFile, 'utf8', (err, activityData) => {
if (err) {
// If activity file doesn't exist, just return parsed answers without mapping
try {
const parsed = parseAnswersFromMarkdown(answerData);
const { answers, type, explanations } = parsed;
respondJson(res, 200, { answers, type, explanations: explanations || null });
} catch (e) {
respondJson(res, 500, { error: 'Failed to parse answers' });
}
return;
}
try {
const parsed = parseAnswersFromMarkdown(answerData);
let { answers, type, explanations } = parsed;
// For MCQ, map result indices to question IDs
if (/^multiple choice$/i.test(type) && explanations) {
const activity = buildActivityFromMarkdown(activityData);
if (activity && activity.mcq && activity.mcq.questions) {
// Map explanations from result index to question ID
// Result indices (0, 1, 2, 3...) should match question IDs (0, 1, 2, 3...)
// since questions are created sequentially
const mappedExplanations = {};
Object.keys(explanations).forEach(resultIndex => {
const idx = parseInt(resultIndex, 10);
if (idx >= 0 && idx < activity.mcq.questions.length) {
const questionId = activity.mcq.questions[idx].id;
mappedExplanations[questionId] = explanations[resultIndex];
}
});
explanations = mappedExplanations;
}
}
respondJson(res, 200, { answers, type, explanations: explanations || null });
} catch (e) {
respondJson(res, 500, { error: 'Failed to parse answers' });
}
});
});
return;
}
// API: /validate
if (pathname === '/validate' && req.method === 'POST') {
// Send message to all connected WebSocket clients
clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({ type: 'validate' }));
}
});
respondJson(res, 200, {
status: 'success',
message: 'Validation message sent to all connected clients',
clientCount: clients.size
});
return;
}
// API: /api/content/markdown - Render markdown content for iframe
if (pathname === '/api/content/markdown' && req.method === 'POST') {
let body = '';
req.on('data', chunk => {
body += chunk.toString();
});
req.on('end', () => {
try {
const data = JSON.parse(body);
const markdown = data.markdown || '';
const html = renderMarkdown(markdown);
// Return HTML wrapped in a proper document with styles
const fullHtml = `