Files
Iqbal f219b26f71 Initial commit: Learning Lab Express
Includes fixes found while reviewing the app:
- Results: open each result by a type+agent+id key, so a video and a
  material draft with the same id no longer open each other's popup
- AuthGate: clear the query cache during render, before pages build their
  queries, and remount pages on organization switch (pages no longer stay
  on skeletons for up to a minute after sign-in or refresh)
- Instruction: keep only failed rules after a partial approve, so retrying
  does not create the successful schedules twice; drop the monthly note
  once count or frequency is edited
- Parser/schedules: never schedule a count below one
- Audio: ignore the end event of a cancelled utterance when changing speed
- Downloads: revoke the object URL later so Safari/Firefox can read it
- Results: refetch SCORM content when any part's revision changes; show a
  failed video as failed instead of "still rendering"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:42:12 +07:00

53 lines
2.4 KiB
JavaScript

// Static host for Learning Lab Express. Proxies /api to the learning-lab engine
// so the browser stays same-origin (no CORS setup needed on the engine).
import http from 'node:http';
import { readFile } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = fileURLToPath(new URL('.', import.meta.url));
const PORT = Number(process.env.PORT || 3000);
const ENGINE = new URL(process.env.ENGINE_URL || 'http://localhost:8000');
const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css', '.svg': 'image/svg+xml' };
function proxy(req, res) {
const upstream = http.request({
host: ENGINE.hostname, port: ENGINE.port, path: req.url, method: req.method,
headers: { ...req.headers, host: ENGINE.host },
}, up => { res.writeHead(up.statusCode, up.headers); up.pipe(res); });
upstream.on('error', () => {
res.writeHead(502, { 'content-type': 'application/json' });
res.end(JSON.stringify({ detail: `Engine learning-lab tidak dapat dihubungi di ${ENGINE.origin}` }));
});
req.pipe(upstream);
}
// Clerk publishable keys are public; default to the engine's own dev key so both share one Clerk instance.
async function clerkKey() {
if (process.env.CLERK_PUBLISHABLE_KEY) return process.env.CLERK_PUBLISHABLE_KEY;
try {
const env = await readFile(join(ROOT, '../../learning-lab/backend/.env'), 'utf8');
return env.match(/^VITE_CLERK_PUBLISHABLE_KEY=["']?([^"'\s]+)/m)?.[1] || '';
} catch { return ''; }
}
async function serve(req, res) {
const path = new URL(req.url, 'http://x').pathname;
if (path === '/config.js') {
res.writeHead(200, { 'content-type': TYPES['.js'] });
return res.end(`window.LLE_CONFIG=${JSON.stringify({ clerkKey: await clerkKey() })};`);
}
const name = path === '/' ? 'index.html' : normalize(path).replace(/^([/\\])+/, '');
if (name.startsWith('..')) { res.writeHead(403); return res.end(); }
try {
const body = await readFile(join(ROOT, name));
res.writeHead(200, { 'content-type': TYPES[extname(name)] || 'application/octet-stream' });
res.end(body);
} catch {
res.writeHead(404); res.end('Not found');
}
}
http.createServer((req, res) => (req.url.startsWith('/api/') ? proxy(req, res) : serve(req, res)))
.listen(PORT, () => console.log(`Learning Lab Express: http://localhost:${PORT} → engine ${ENGINE.origin}`));