diff --git a/bun.lockb b/bun.lockb index 36ba9e2..183f533 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 119712d..fad4ec2 100644 --- a/package.json +++ b/package.json @@ -17,17 +17,17 @@ "@php-wasm/web": "^0.7.20", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", - "astro": "^4.10.0", + "astro": "^4.10.1", "astro-icon": "^1.1.0", "fast-deep-equal": "^3.1.3", "monaco-editor": "^0.49.0", "monaco-vim": "^0.4.1", - "pyodide": "^0.26.0", + "pyodide": "^0.26.1", "tailwindcss": "^3.4.4", "typescript": "^5.4.5" }, "devDependencies": { - "@iconify-json/lucide": "^1.1.190", + "@iconify-json/lucide": "^1.1.191", "@iconify/svelte": "^4.0.2", "@tailwindcss/typography": "^0.5.13", "@types/color": "^3.0.6", diff --git a/src/adapters/testing-actor.ts b/src/adapters/testing-actor.ts new file mode 100644 index 0000000..c187968 --- /dev/null +++ b/src/adapters/testing-actor.ts @@ -0,0 +1,123 @@ +import { + Actor, + MessageType, + WorkerConnection, + startRemote, + type Connection, + type EventMessage, + type IncomingMessage, + type OutgoingMessage, +} from "@/lib/actor"; +import { createLogger } from "@/lib/logger"; +import type { TestRunner, TestRunnerFactory } from "@/lib/testing"; + +interface Handlers { + [key: string]: any; + init: (code: string) => Promise; + run: (data: I) => Promise; +} + +type Incoming = IncomingMessage>; + +interface WriteEventMessage extends EventMessage<"write", string> {} +interface WritelnEventMessage extends EventMessage<"writeln", string> {} + +type TestingActorEvent = WriteEventMessage | WritelnEventMessage; + +type Outgoing = OutgoingMessage, string> | TestingActorEvent; + +class TestRunnerActor extends Actor, string> { + private runner: TestRunner | null = null; + + constructor( + connection: Connection, Outgoing>, + factory: TestRunnerFactory + ) { + const handlers: Handlers = { + init: async (code) => { + this.runner = await factory({ + code, + out: { + write(text) { + connection.send({ + type: MessageType.Event, + event: "write", + payload: text, + }); + }, + writeln(text) { + connection.send({ + type: MessageType.Event, + event: "writeln", + payload: text, + }); + }, + }, + }); + }, + run: (input) => { + if (!this.runner) { + const err = new Error("Test runner not initialized"); + connection.send({ + type: MessageType.Event, + event: "error", + payload: err.message, + }); + throw err; + } + return this.runner.run(input); + }, + }; + super(connection, handlers, String); + } +} + +export function startTestRunnerActor(factory: TestRunnerFactory) { + const connection = new WorkerConnection, Outgoing>( + self as unknown as Worker + ); + const actor = new TestRunnerActor(connection, factory); + const stopConnection = connection.start(); + const stopActor = actor.start(); + return () => { + stopActor(); + stopConnection(); + }; +} + +interface WorkerConstructor { + new (): Worker; +} + +export function makeRemoteTestRunnerFactory( + Worker: WorkerConstructor +): TestRunnerFactory { + return async ({ code, out }) => { + const worker = new Worker(); + const connection = new WorkerConnection, Incoming>( + worker + ); + const stopConnection = connection.start(); + const log = createLogger(out); + const remote = startRemote, string, TestingActorEvent>( + log, + connection, + { + error: (err) => log.error(err), + write: (text) => out.write(text), + writeln: (text) => out.writeln(text), + } + ); + await remote.init(code); + return { + async run(input) { + return remote.run(input); + }, + [Symbol.dispose]() { + remote[Symbol.dispose](); + stopConnection(); + worker.terminate(); + }, + }; + }; +} diff --git a/src/content/design-patterns/factory/editor.svelte b/src/content/design-patterns/factory/editor.svelte index d4cc810..79b62de 100644 --- a/src/content/design-patterns/factory/editor.svelte +++ b/src/content/design-patterns/factory/editor.svelte @@ -1,31 +1,32 @@ { - async executeTest(m: TestingModule, input: Input): Promise { - return m.payment(input.paymentSystem, input.base, input.amount); - } -} - -export const jsTestRunnerFactory = async ({ code, out }: TestRunnerConfig) => - new SimpleJsTestRunner(createLogger(out), code); - -class SimpleTsTestRunner extends TsTestRunner { - async executeTest(m: TestingModule, input: Input): Promise { - return m.payment(input.paymentSystem, input.base, input.amount); - } -} - -export const tsTestRunnerFactory = async ({ code, out }: TestRunnerConfig) => - new SimpleTsTestRunner(createLogger(out), code); diff --git a/src/content/design-patterns/factory/js/worker.ts b/src/content/design-patterns/factory/js/worker.ts new file mode 100644 index 0000000..a877eb3 --- /dev/null +++ b/src/content/design-patterns/factory/js/worker.ts @@ -0,0 +1,22 @@ +import { createLogger } from "@/lib/logger"; +import type { TestRunnerConfig } from "@/lib/testing"; +import { JsTestRunner } from "@/lib/testing/js"; +import { startTestRunnerActor } from "@/adapters/testing-actor"; + +import { type Input, type Output } from "../tests-data"; +import type { PaymentSystemType } from "../reference"; + +interface TestingModule { + payment(type: PaymentSystemType, base: number, amount: number): number; +} + +class SimpleJsTestRunner extends JsTestRunner { + async executeTest(m: TestingModule, input: Input): Promise { + return m.payment(input.paymentSystem, input.base, input.amount); + } +} + +startTestRunnerActor( + async ({ code, out }: TestRunnerConfig) => + new SimpleJsTestRunner(createLogger(out), code) +); diff --git a/src/content/design-patterns/factory/php/index.ts b/src/content/design-patterns/factory/php/index.ts new file mode 100644 index 0000000..729afca --- /dev/null +++ b/src/content/design-patterns/factory/php/index.ts @@ -0,0 +1,3 @@ +export { default as phpCode } from './code.php?raw' + +export { default as PhpWorker } from './worker?worker' diff --git a/src/content/design-patterns/factory/php/test-runners.ts b/src/content/design-patterns/factory/php/worker.ts similarity index 82% rename from src/content/design-patterns/factory/php/test-runners.ts rename to src/content/design-patterns/factory/php/worker.ts index f0365b6..c6ca3f5 100644 --- a/src/content/design-patterns/factory/php/test-runners.ts +++ b/src/content/design-patterns/factory/php/worker.ts @@ -4,6 +4,7 @@ import { PHPTestRunner, phpRuntimeFactory, } from "@/lib/testing/php"; +import { startTestRunnerActor } from "@/adapters/testing-actor"; import { type Input, type Output } from "../tests-data"; import { PaymentSystemType } from "../reference"; @@ -27,5 +28,7 @@ class SimpleTestRunner extends PHPTestRunner { } } -export const testRunnerFactory = async ({ code, out }: TestRunnerConfig) => - new SimpleTestRunner(out, new FailSafePHP(phpRuntimeFactory), code); +startTestRunnerActor( + async ({ code, out }: TestRunnerConfig) => + new SimpleTestRunner(out, new FailSafePHP(phpRuntimeFactory), code) +); diff --git a/src/content/design-patterns/factory/python/index.ts b/src/content/design-patterns/factory/python/index.ts new file mode 100644 index 0000000..40cbc3e --- /dev/null +++ b/src/content/design-patterns/factory/python/index.ts @@ -0,0 +1,3 @@ +export { default as pyCode } from "./code.py?raw" + +export { default as PyWorker } from "./worker?worker" diff --git a/src/content/design-patterns/factory/python/test-runners.ts b/src/content/design-patterns/factory/python/worker.ts similarity index 57% rename from src/content/design-patterns/factory/python/test-runners.ts rename to src/content/design-patterns/factory/python/worker.ts index 2760d5e..6c2f146 100644 --- a/src/content/design-patterns/factory/python/test-runners.ts +++ b/src/content/design-patterns/factory/python/worker.ts @@ -1,5 +1,6 @@ -import type { TestRunnerConfig } from '@/lib/testing'; +import type { TestRunnerConfig } from "@/lib/testing"; import { PyTestRunner, pyRuntimeFactory } from "@/lib/testing/python"; +import { startTestRunnerActor } from "@/adapters/testing-actor"; import { type Input, type Output } from "../tests-data"; @@ -9,5 +10,7 @@ class SimpleTestRunner extends PyTestRunner { } } -export const testRunnerFactory = async ({ code, out }: TestRunnerConfig) => - new SimpleTestRunner(await pyRuntimeFactory(out), code); +startTestRunnerActor( + async ({ code, out }: TestRunnerConfig) => + new SimpleTestRunner(await pyRuntimeFactory(out), code) +); diff --git a/src/content/design-patterns/factory/js/code.ts b/src/content/design-patterns/factory/ts/code.ts similarity index 100% rename from src/content/design-patterns/factory/js/code.ts rename to src/content/design-patterns/factory/ts/code.ts diff --git a/src/content/design-patterns/factory/ts/index.ts b/src/content/design-patterns/factory/ts/index.ts new file mode 100644 index 0000000..ea37eb2 --- /dev/null +++ b/src/content/design-patterns/factory/ts/index.ts @@ -0,0 +1,3 @@ +export { default as tsCode } from './code.ts?raw' + +export { default as TsWorker } from './worker?worker' diff --git a/src/content/design-patterns/factory/ts/worker.ts b/src/content/design-patterns/factory/ts/worker.ts new file mode 100644 index 0000000..78fddb8 --- /dev/null +++ b/src/content/design-patterns/factory/ts/worker.ts @@ -0,0 +1,22 @@ +import { createLogger } from "@/lib/logger"; +import type { TestRunnerConfig } from "@/lib/testing"; +import { TsTestRunner } from "@/lib/testing/js"; +import { startTestRunnerActor } from "@/adapters/testing-actor"; + +import { type Input, type Output } from "../tests-data"; +import type { PaymentSystemType } from "../reference"; + +interface TestingModule { + payment(type: PaymentSystemType, base: number, amount: number): number; +} + +class SimpleTsTestRunner extends TsTestRunner { + async executeTest(m: TestingModule, input: Input): Promise { + return m.payment(input.paymentSystem, input.base, input.amount); + } +} + +startTestRunnerActor( + async ({ code, out }: TestRunnerConfig) => + new SimpleTsTestRunner(createLogger(out), code) +); diff --git a/src/lib/actor/actor.ts b/src/lib/actor/actor.ts new file mode 100644 index 0000000..e893d4b --- /dev/null +++ b/src/lib/actor/actor.ts @@ -0,0 +1,87 @@ +import { err, ok, type Result } from "@/lib/result"; +import { neverError } from "@/lib/guards"; + +import { + MessageType, + type Connection, + type EventMessage, + type Handlers, + type IncomingMessage, + type OutgoingMessage, + type RequestMessage, + type ResponseMessage, +} from "./model"; + +export interface ActorConfig { + connection: Connection, OutgoingMessage>; + handlers: H; + caseError: (error: any) => E; +} + +export class Actor { + constructor( + protected readonly connection: Connection< + IncomingMessage, + OutgoingMessage + >, + protected readonly handlers: H, + protected readonly caseError: (error: any) => E + ) {} + + start() { + const handleMessage = this.handleMessage.bind(this); + this.connection.onMessage(handleMessage); + return () => { + this.connection.onMessage(handleMessage); + }; + } + + private async handleMessage(msg: IncomingMessage) { + switch (msg.type) { + case MessageType.Request: { + const result = await this.handleRequest(msg); + this.connection.send(result); + return; + } + case MessageType.Event: { + await this.handleEvent(msg); + return; + } + default: + throw neverError(msg, "Unknown message type"); + } + } + + private async handleEvent( + event: EventMessage[0]> + ) { + try { + await this.handlers[event.event](event.payload); + } catch (error) { + this.connection.send({ + type: MessageType.Event, + event: "error", + payload: this.caseError(error), + }); + } + } + + private async handleRequest( + request: RequestMessage[0]> + ): Promise, E>>> { + try { + const result = await this.handlers[request.request](request.payload); + return { + type: MessageType.Response, + id: request.id, + result: ok(result), + }; + } catch (error) { + return { + type: MessageType.Response, + id: request.id, + result: err(this.caseError(error)), + }; + } + } +} diff --git a/src/lib/actor/index.ts b/src/lib/actor/index.ts new file mode 100644 index 0000000..032b80c --- /dev/null +++ b/src/lib/actor/index.ts @@ -0,0 +1,4 @@ +export * from './model' +export * from './actor' +export * from './worker-connection' +export * from './remote' diff --git a/src/lib/actor/model.ts b/src/lib/actor/model.ts new file mode 100644 index 0000000..35601e2 --- /dev/null +++ b/src/lib/actor/model.ts @@ -0,0 +1,52 @@ +import type { AnyKey, Brand } from "@/lib/type"; +import type { Result } from "@/lib/result"; + +export enum MessageType { + Event = "event", + Request = "request", + Response = "response", +} + +export interface AbstractMessage { + type: T; +} + +export interface EventMessage + extends AbstractMessage { + event: T; + payload: P; +} + +export type RequestId = Brand<"RequestId", number>; + +export interface RequestMessage + extends AbstractMessage { + id: RequestId; + request: T; + payload: P; +} + +export interface ResponseMessage + extends AbstractMessage { + id: RequestId; + result: R; +} + +export interface Connection { + onMessage: (handler: (message: Incoming) => void) => () => void; + send: (message: Outgoing) => void; +} + +export type Handlers = Record any>; + +export type IncomingMessage = { + [K in keyof H]: + | RequestMessage[0]> + | EventMessage[0]>; +}[keyof H]; + +export interface ErrorEventMessage extends EventMessage<"error", E> {} + +export type OutgoingMessage = + | ResponseMessage, E>> + | ErrorEventMessage; diff --git a/src/lib/actor/remote.ts b/src/lib/actor/remote.ts new file mode 100644 index 0000000..a8580ab --- /dev/null +++ b/src/lib/actor/remote.ts @@ -0,0 +1,98 @@ +import type { Logger } from "@/lib/logger"; +import { neverError } from "@/lib/guards"; +import { isOk } from "@/lib/result"; + +import { + MessageType, + type Connection, + type ErrorEventMessage, + type EventMessage, + type Handlers, + type IncomingMessage, + type OutgoingMessage, + type RequestId, +} from "./model"; + +interface DeferredPromise { + resolve: (value: T) => void; + reject: (error: E) => void; +} + +export function startRemote< + H extends Handlers, + E, + Event extends EventMessage +>( + log: Logger, + connection: Connection | Event, IncomingMessage>, + eventHandlers: { + [K in Event["event"]]: (e: Extract>['payload']) => void; + } & { + [K in ErrorEventMessage["event"]]: (e: ErrorEventMessage['payload']) => void; + } +) { + let lastId = 0; + const promises = new Map< + RequestId, + DeferredPromise, E> + >(); + const unsubscribe = connection.onMessage((msg) => { + switch (msg.type) { + case MessageType.Response: { + const { id, result } = msg; + const deferred = promises.get(id); + if (!deferred) { + log.error(`Received response for unknown request ${id}`); + return; + } + promises.delete(id); + if (isOk(result)) { + deferred.resolve(result.value); + } else { + deferred.reject(result.error); + } + return; + } + case MessageType.Event: { + // @ts-expect-error ts problem + const handler = eventHandlers[msg.event]; + if (!handler) { + log.error(`Received unknown event ${msg.event}`); + return; + } + handler(msg.payload); + return; + } + default: + throw neverError(msg, "Unknown message type"); + } + }); + return new Proxy( + {}, + { + get(_, prop) { + if (prop === Symbol.dispose) { + return unsubscribe; + } + const request = prop as keyof H; + return (arg: Parameters[0]) => { + const id = lastId++ as RequestId; + const promise = new Promise>( + (resolve, reject) => { + promises.set(id, { resolve, reject }); + } + ); + connection.send({ + id: id, + request, + type: MessageType.Request, + payload: arg, + }); + return promise; + }; + }, + } + ) as { + [K in keyof H]: (arg: Parameters[0]) => Promise>; + } & Disposable; +} diff --git a/src/lib/actor/worker-connection.ts b/src/lib/actor/worker-connection.ts new file mode 100644 index 0000000..49ee872 --- /dev/null +++ b/src/lib/actor/worker-connection.ts @@ -0,0 +1,32 @@ +import type { Connection } from "./model"; + +export class WorkerConnection + implements Connection +{ + private handlers = new Set<(message: Incoming) => void>(); + + constructor(private readonly worker: Worker) {} + + start() { + const onMsg = (e: MessageEvent) => { + for (const handler of this.handlers) { + handler(e.data); + } + }; + this.worker.addEventListener("message", onMsg); + return () => { + this.worker.removeEventListener("message", onMsg); + }; + } + + send(message: Outgoing) { + this.worker.postMessage(message); + } + + onMessage(handler: (message: Incoming) => void) { + this.handlers.add(handler); + return () => { + this.handlers.delete(handler); + }; + } +} diff --git a/src/lib/context.ts b/src/lib/context.ts new file mode 100644 index 0000000..3d9390b --- /dev/null +++ b/src/lib/context.ts @@ -0,0 +1,41 @@ +export interface Context { + signal: AbortSignal; + canceled: boolean; + cancel(): void; + onCancel(cb: () => void): () => void; +} + +export function root(): Context { + const ctrl = new AbortController(); + return { + get canceled() { + return ctrl.signal.aborted; + }, + get signal() { + return ctrl.signal; + }, + onCancel(cb) { + ctrl.signal.addEventListener("abort", cb); + return () => ctrl.signal.removeEventListener("abort", cb); + }, + cancel() { + ctrl.abort(); + }, + }; +} + +export function withCancel(ctx: Context): Context { + if (ctx.canceled) { + return ctx; + } + const leaf = root(); + const cancel = () => { + ctx.signal.removeEventListener("abort", cancel); + leaf.cancel(); + }; + ctx.signal.addEventListener("abort", cancel); + return { + ...leaf, + cancel, + }; +} diff --git a/src/lib/guards.ts b/src/lib/guards.ts new file mode 100644 index 0000000..43788a8 --- /dev/null +++ b/src/lib/guards.ts @@ -0,0 +1,3 @@ +export function neverError(value: never, message: string) { + return new Error(`${message}: ${value}`); +} diff --git a/src/lib/patcher.ts b/src/lib/patcher.ts new file mode 100644 index 0000000..12d1ef3 --- /dev/null +++ b/src/lib/patcher.ts @@ -0,0 +1,7 @@ +export function patch(obj: T, key: K, value: T[K]) { + const originalValue = obj[key]; + obj[key] = value; + return () => { + obj[key] = originalValue; + }; +} diff --git a/src/lib/result.ts b/src/lib/result.ts new file mode 100644 index 0000000..8746f64 --- /dev/null +++ b/src/lib/result.ts @@ -0,0 +1,27 @@ +export interface Ok { + ok: true + value: T +} + +export interface Err { + ok: false + error: E +} + +export type Result = Ok | Err + +export function isOk(result: Result): result is Ok { + return result.ok +} + +export function isErr(result: Result): result is Err { + return !result.ok +} + +export function ok(value: T): Ok { + return { ok: true, value } +} + +export function err(error: E): Err { + return { ok: false, error } +} diff --git a/src/lib/testing/js/js-test-runner.ts b/src/lib/testing/js/js-test-runner.ts index 273a823..3da6bf7 100644 --- a/src/lib/testing/js/js-test-runner.ts +++ b/src/lib/testing/js/js-test-runner.ts @@ -1,4 +1,5 @@ import { redirect, type Logger } from "@/lib/logger"; +import { patch } from "@/lib/patcher"; import type { TestRunner } from "../model"; @@ -19,13 +20,12 @@ export abstract class JsTestRunner implements TestRunner { async run(input: I): Promise { const transformedCode = this.transformCode(this.code); - const originalConsole = globalThis.console; - globalThis.console = this.patchedConsole + const recover = patch(globalThis, "console", this.patchedConsole); try { const m = await import(/* @vite-ignore */ transformedCode); return this.executeTest(m, input); } finally { - globalThis.console = originalConsole; + recover(); } } diff --git a/src/lib/type.ts b/src/lib/type.ts new file mode 100644 index 0000000..d2b797a --- /dev/null +++ b/src/lib/type.ts @@ -0,0 +1,3 @@ +export type Brand = Base & { __brand: Name } + +export type AnyKey = keyof any;