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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
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
@@ -7,6 +7,7 @@ import { libraryRoutes } from "./catalog";
import { lessonRoutes } from "./lesson";
import { userDataRoutes } from "./userdata";
import { codeRoutes } from "./code";
import { transpileRoutes } from "./transpile";
/**
* The learner feature: auth, library, reader, notes, progress and exercises.
@@ -30,6 +31,7 @@ export function learnerFeature(ctx: ServerContext): Feature & { auth: AuthServic
...lessonRoutes(ctx),
...userDataRoutes(ctx),
...codeRoutes(ctx),
...transpileRoutes(),
};
// Every handler waits for the learner migrations (a no-op once Phase 2 runs them in migrate()).
for (const methods of Object.values(routes)) {
@@ -0,0 +1,27 @@
import type { Feature } from "@stc/contracts";
import { badRequest, body, json } from "./http";
const MAX = 200_000;
const ts = new Bun.Transpiler({ loader: "ts", target: "browser" });
/** Type-strips TypeScript for the in-browser runner (the worker itself runs plain JavaScript). */
export function transpileTs(code: string): string {
return ts.transformSync(code);
}
export function transpileRoutes(): Feature["routes"] {
return {
"/api/learner/transpile": {
POST: async (req) => {
const b = await body<{ code?: string }>(req);
if (!b || typeof b.code !== "string") return badRequest("code is required");
if (b.code.length > MAX) return badRequest("code is too long");
try {
return json({ code: transpileTs(b.code) });
} catch (e) {
return json({ error: (e as Error).message }, 422);
}
},
},
};
}
@@ -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" });
};
`;
@@ -0,0 +1,62 @@
import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { createBrowserRunner } from "../src/web/runner/runner";
import { transpileTs } from "../src/server/transpile";
import { FIXTURES } from "./app";
const ex = (f: string) => readFileSync(join(FIXTURES, "sample/exercises/add", f), "utf8");
const js = createBrowserRunner("js");
test("the sample starter fails with expected and actual values", async () => {
const r = await js.run(ex("starter.js"), ex("tests.js"), 5000);
expect(r).toEqual([
{ name: "adds 2 and 3", passed: false, message: "Expected values to be strictly equal", expected: "5", actual: "-1" },
{ name: "adds -1 and 1", passed: false, message: "Expected values to be strictly equal", expected: "0", actual: "-2" },
]);
});
test("the sample exercise passes after editing", async () => {
const edited = ex("starter.js").replace("a - b", "a + b");
expect((await js.run(edited, ex("tests.js"), 5000)).every((t) => t.passed)).toBe(true);
});
test("syntax errors and missing exports are reported per test", async () => {
const r = await js.run("export function add(a, b) { return a +", ex("tests.js"), 5000);
expect(r.length).toBe(2);
expect(r[0]!.passed).toBe(false);
expect(r[0]!.message).toContain("did not load");
const r2 = await js.run("export const nope = 1", ex("tests.js"), 5000);
expect(r2[0]!.message).toContain("TypeError");
});
test("an endless loop is killed by the timeout", async () => {
const t0 = Date.now();
const r = await js.run("export function add() { while (true) {} }", ex("tests.js"), 400);
expect(Date.now() - t0).toBeLessThan(3000);
expect(r.map((x) => x.passed)).toEqual([false, false]);
expect(r[0]!.message).toContain("Timed out");
});
test("network access is blocked inside the worker", async () => {
const tests = `export const tests = [{ name: "net", run: async (m, assert) => assert.equal(await m.probe(), "blocked") }];`;
const code = `export async function probe() { try { await fetch("http://127.0.0.1:1/"); return "open"; } catch (e) { return String(e.message).includes("turned off") ? "blocked" : "other " + e.message; } }`;
expect((await js.run(code, tests, 3000))[0]!.passed).toBe(true);
});
test("deepEqual compares structures", async () => {
const tests = `export const tests = [
{ name: "same", run: (m, a) => a.deepEqual(m.v(), { x: [1, 2], y: { z: null } }) },
{ name: "diff", run: (m, a) => a.deepEqual(m.v(), { x: [1, 3] }) },
];`;
const r = await js.run("export const v = () => ({ x: [1, 2], y: { z: null } })", tests, 3000);
expect(r[0]!.passed).toBe(true);
expect(r[1]).toMatchObject({ passed: false, expected: "{x: [1, 3]}", actual: "{x: [1, 2], y: {z: null}}" });
});
test("TypeScript exercises are transpiled first", async () => {
const runner = createBrowserRunner("ts", { transpile: async (c) => transpileTs(c) });
const tests = `import type { X } from "./x"; export const tests = [{ name: "typed", run: (m: any, a: any) => a.equal(m.add(1, 2), 3) }];`;
const r = await runner.run("export function add(a: number, b: number): number { return a + b; }", tests, 3000);
expect(r).toEqual([{ name: "typed", passed: true }]);
});