feat(learner): JS/TS exercise runner in a Web Worker with timeout and network blocked

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-10-30 03:26:32 +01:00
co-authored by Claude Opus 5.5
parent 752e44696f
commit 9ff5862382
5 changed files with 268 additions and 0 deletions
@@ -0,0 +1,73 @@
import type { ExerciseLanguage, ExerciseRunner, TestResult } from "@stc/contracts";
import { WORKER_SOURCE } from "./worker-source";
export interface RunnerOptions {
/** TypeScript → JavaScript. Defaults to the server's `POST /api/learner/transpile` (Bun.Transpiler). */
transpile?: (code: string) => Promise<string>;
createWorker?: () => Worker;
}
export async function serverTranspile(code: string): Promise<string> {
const r = await fetch("/api/learner/transpile", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ code }) });
const b = (await r.json()) as { code?: string; error?: string };
if (!r.ok || typeof b.code !== "string") throw new Error(b.error ?? "TypeScript could not be compiled");
return b.code;
}
const defaultWorker = () => new Worker(URL.createObjectURL(new Blob([WORKER_SOURCE], { type: "text/javascript" })), { type: "module" });
/**
* Runs learner code against a tests module in a fresh Web Worker (never in the page), then terminates it.
* When `timeoutMs` passes, the worker is killed and every test without a result is reported as timed out.
*/
export function createBrowserRunner(language: ExerciseLanguage, opts: RunnerOptions = {}): ExerciseRunner {
const transpile = opts.transpile ?? serverTranspile;
const make = opts.createWorker ?? defaultWorker;
return {
language,
async run(learnerCode, testsModule, timeoutMs) {
let code = learnerCode, tests = testsModule;
if (language === "ts") {
try { code = await transpile(learnerCode); } catch (e) {
return [{ name: "Your code did not compile", passed: false, message: (e as Error).message }];
}
tests = await transpile(testsModule);
}
const worker = make();
return new Promise<TestResult[]>((resolve) => {
let names: string[] = [];
const results: TestResult[] = [];
let settled = false;
const finish = (extra: TestResult[] = []) => {
if (settled) return;
settled = true;
clearTimeout(timer);
worker.terminate();
resolve([...results, ...extra]);
};
const pending = (message: string) => names.slice(results.length).map((name) => ({ name, passed: false, message }));
const timer = setTimeout(() => {
const rest = pending(`Timed out after ${timeoutMs} ms. Is there an endless loop?`);
finish(rest.length ? rest : [{ name: "Timed out", passed: false, message: `No result after ${timeoutMs} ms` }]);
}, timeoutMs);
worker.onmessage = (ev: MessageEvent) => {
const m = ev.data as { type: string; names?: string[]; result?: TestResult; stage?: string; message?: string };
if (m.type === "tests") names = m.names ?? [];
else if (m.type === "result" && m.result) results.push(m.result);
else if (m.type === "done") finish();
else if (m.type === "error") {
if (m.stage === "code") {
const rest = pending(`Your code did not load. ${m.message}`);
finish(rest.length ? rest : [{ name: "Your code did not load", passed: false, message: m.message }]);
} else finish([{ name: "The tests did not load", passed: false, message: m.message }]);
}
};
worker.onerror = (ev: ErrorEvent) => {
ev.preventDefault?.();
finish(pending(ev.message || "The exercise worker crashed"));
};
worker.postMessage({ code, tests });
});
},
};
}
@@ -0,0 +1,104 @@
/**
* Source of the exercise Web Worker, kept as a string so it can be started from a blob URL without a
* separate bundle. Protocol (worker → page): {type:"tests", names}, {type:"result", result}, {type:"done"},
* {type:"error", stage:"tests"|"code", message}. Page → worker: {code, tests} (plain JavaScript ES modules).
*/
export const WORKER_SOURCE = String.raw`
"use strict";
const blocked = () => { throw new Error("Network access is turned off while exercises run"); };
for (const k of ["fetch", "XMLHttpRequest", "WebSocket", "EventSource", "importScripts"]) {
try { Object.defineProperty(self, k, { value: blocked, configurable: false, writable: false }); } catch (_) {}
}
class AssertionError extends Error {
constructor(message, expected, actual) { super(message); this.name = "AssertionError"; this.expected = expected; this.actual = actual; this.hasValues = arguments.length > 1; }
}
function fmt(v, seen = new Set()) {
if (v === undefined) return "undefined";
if (v === null) return "null";
if (typeof v === "string") return JSON.stringify(v);
if (typeof v === "number") return Object.is(v, -0) ? "-0" : String(v);
if (typeof v === "bigint") return v + "n";
if (typeof v === "function") return "[Function " + (v.name || "anonymous") + "]";
if (typeof v === "symbol") return v.toString();
if (typeof v !== "object") return String(v);
if (seen.has(v)) return "[Circular]";
seen.add(v);
let out;
if (Array.isArray(v)) out = "[" + v.map((x) => fmt(x, seen)).join(", ") + "]";
else if (v instanceof Map) out = "Map {" + [...v].map(([k, x]) => fmt(k, seen) + " => " + fmt(x, seen)).join(", ") + "}";
else if (v instanceof Set) out = "Set {" + [...v].map((x) => fmt(x, seen)).join(", ") + "}";
else if (v instanceof Date) out = "Date(" + v.toISOString() + ")";
else if (v instanceof Error) out = v.name + ": " + v.message;
else out = "{" + Object.keys(v).map((k) => (/^[A-Za-z_$][\w$]*$/.test(k) ? k : JSON.stringify(k)) + ": " + fmt(v[k], seen)).join(", ") + "}";
seen.delete(v);
return out;
}
function deepEq(a, b, seen = new Map()) {
if (Object.is(a, b)) return true;
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
if (seen.get(a) === b) return true;
seen.set(a, b);
if (a instanceof Date) return a.getTime() === b.getTime();
if (a instanceof Map || a instanceof Set) {
if (a.size !== b.size) return false;
if (a instanceof Set) { for (const x of a) if (!b.has(x)) return false; return true; }
for (const [k, x] of a) if (!b.has(k) || !deepEq(x, b.get(k), seen)) return false;
return true;
}
const ka = Object.keys(a), kb = Object.keys(b);
if (ka.length !== kb.length) return false;
for (const k of ka) if (!Object.prototype.hasOwnProperty.call(b, k) || !deepEq(a[k], b[k], seen)) return false;
return true;
}
const assert = {
equal(actual, expected, message) {
if (!Object.is(actual, expected) && !(actual === expected)) throw new AssertionError(message || "Expected values to be strictly equal", expected, actual);
},
deepEqual(actual, expected, message) {
if (!deepEq(actual, expected)) throw new AssertionError(message || "Expected values to be deeply equal", expected, actual);
},
ok(value, message) {
if (!value) throw new AssertionError(message || "Expected a truthy value", true, value);
},
};
const load = (src) => import(URL.createObjectURL(new Blob([src], { type: "text/javascript" })));
const describe = (e) => (e && e.name && e.message ? e.name + ": " + e.message : String(e));
self.onmessage = async (ev) => {
const { code, tests } = ev.data;
let list;
try {
const t = await load(tests);
list = Array.isArray(t.tests) ? t.tests : Array.isArray(t.default) ? t.default : null;
if (!list) throw new Error("the tests module must export \"tests\" (an array)");
} catch (e) {
self.postMessage({ type: "error", stage: "tests", message: describe(e) });
return;
}
self.postMessage({ type: "tests", names: list.map((t, i) => String(t && t.name || "test " + (i + 1))) });
let mod;
try { mod = await load(code); } catch (e) {
self.postMessage({ type: "error", stage: "code", message: describe(e) });
return;
}
for (let i = 0; i < list.length; i++) {
const t = list[i];
const name = String(t && t.name || "test " + (i + 1));
try {
await t.run(mod, assert);
self.postMessage({ type: "result", result: { name, passed: true } });
} catch (e) {
const r = { name, passed: false, message: describe(e) };
if (e instanceof AssertionError && e.hasValues) { r.expected = fmt(e.expected); r.actual = fmt(e.actual); r.message = e.message; }
self.postMessage({ type: "result", result: r });
}
}
self.postMessage({ type: "done" });
};
`;