esolang/ui/use-exec-controller.ts
2022-01-30 20:47:33 +05:30

246 lines
7.7 KiB
TypeScript

import React from "react";
import { StepExecutionResult } from "../languages/types";
import {
WorkerRequestData,
WorkerResponseData,
} from "../languages/worker-constants";
import {
WorkerParseError,
WorkerRuntimeError,
} from "../languages/worker-errors";
import { useErrorBoundary } from "./providers/error-boundary-provider";
/** Possible states for the worker to be in */
type WorkerState =
| "loading" // Worker is not initialized yet
| "empty" // Worker loaded, no code loaded yet
| "ready" // Ready to start execution
| "processing" // Executing code
| "paused" // Execution currently paused
| "error" // Execution ended due to error
| "done"; // Program ended, reset now
/**
* React Hook that manages initialization, communication and
* cleanup for the worker thread used for code execution.
*
* Also abstracts away the details of message-passing and exposes
* an imperative API to the parent component.
*/
export const useExecController = <RS>(langName: string) => {
const workerRef = React.useRef<Worker | null>(null);
const [workerState, setWorkerState] = React.useState<WorkerState>("loading");
const errorBoundary = useErrorBoundary();
/**
* Type-safe wrapper to abstract request-response cycle into
* a simple imperative asynchronous call. Returns Promise that resolves
* with response data.
*
* Note that if the worker misbehaves due to any reason, the returned response data
* (or `onData` argument) may not correspond to the request. Check this in the caller.
*
* @param request Data to send in request
* @param onData Optional argument - if passed, function enters response-streaming mode.
* Callback is called with response data. Return `true` to keep the connection alive, `false` to end.
* On end, promise resolves with last (already used) response data.
*/
const requestWorker = (
request: WorkerRequestData,
onData?: (data: WorkerResponseData<RS, any>) => boolean
): Promise<WorkerResponseData<RS, any>> => {
return new Promise((resolve) => {
const handler = (ev: MessageEvent<WorkerResponseData<RS, any>>) => {
if (!onData) {
// Normal mode
workerRef.current!.removeEventListener("message", handler);
resolve(ev.data);
} else {
// Persistent connection mode
const keepAlive = onData(ev.data);
if (keepAlive) return;
// keepAlive is false: terminate connection
workerRef.current!.removeEventListener("message", handler);
resolve(ev.data);
}
};
workerRef.current!.addEventListener("message", handler);
workerRef.current!.postMessage(request);
});
};
/** Utility to throw error on unexpected response */
const throwUnexpectedRes = (
fnName: string,
res: WorkerResponseData<RS, any>
): never => {
throw new Error(`Unexpected response on ${fnName}: ${JSON.stringify(res)}`);
};
// Initialization and cleanup of web worker
React.useEffect(() => {
(async () => {
if (workerRef.current) throw new Error("Tried to reinitialize worker");
workerRef.current = new Worker(`../workers/${langName}.js`);
// Add event listener for bubbling errors to main thread
workerRef.current.addEventListener(
"message",
(event: MessageEvent<WorkerResponseData<RS, any>>) => {
if (event.data.type !== "error") return;
const plainError = event.data.error;
const err = new Error(
`[Worker] ${plainError.name}: ${plainError.message}`
);
err.stack = event.data.error.stack;
errorBoundary.throwError(err);
// throw err;
}
);
const res = await requestWorker({ type: "Init" });
if (res.type === "ack" && res.data === "init") setWorkerState("empty");
else throwUnexpectedRes("init", res);
})();
return () => {
// Terminate worker and clean up
workerRef.current!.terminate();
workerRef.current = null;
};
}, []);
/**
* Load code and user input into the execution controller.
* @param code Code content
* @param input User input
*/
const prepare = React.useCallback(
async (code: string, input: string): Promise<WorkerParseError | void> => {
const res = await requestWorker({
type: "Prepare",
params: { code, input },
});
if (res.type === "ack" && res.data === "prepare") {
if (res.error) return res.error;
else setWorkerState("ready");
} else throwUnexpectedRes("loadCode", res);
},
[]
);
/**
* Update debugging breakpoints in the execution controller.
* @param points Array of line numbers having breakpoints
*/
const updateBreakpoints = React.useCallback(async (points: number[]) => {
await requestWorker(
{
type: "UpdateBreakpoints",
params: { points },
},
(res) => {
if (res.type === "ack" && res.data === "bp-update") return false;
else return true;
}
);
}, []);
/**
* Reset the state of the controller and engine.
*/
const resetState = React.useCallback(async () => {
const res = await requestWorker({ type: "Reset" });
if (res.type === "ack" && res.data === "reset") setWorkerState("empty");
else throwUnexpectedRes("resetState", res);
}, []);
/**
* Validate the syntax of the user's program code
* @param code Code content of the user's program
*/
const validateCode = React.useCallback(
async (code: string): Promise<WorkerParseError | undefined> => {
const res = await requestWorker(
{ type: "ValidateCode", params: { code } },
(res) => res.type !== "validate"
);
return res.error;
},
[]
);
/**
* Pause program execution
*/
const pauseExecution = React.useCallback(async () => {
await requestWorker({ type: "Pause" }, (res) => {
// We don't update state here - that's done by the execution stream instead
if (!(res.type === "ack" && res.data === "pause")) return true;
return false;
});
}, []);
/**
* Run a single step of execution
* @return Execution result
*/
const executeStep = React.useCallback(async (): Promise<{
result: StepExecutionResult<RS>;
error?: WorkerRuntimeError;
}> => {
const res = await requestWorker(
{ type: "ExecuteStep" },
(res) => res.type !== "result"
);
if (res.type !== "result") throw new Error("Something unexpected happened");
if (!res.data.nextStepLocation) setWorkerState("done");
return { result: res.data, error: res.error };
}, []);
/**
* Execute the code loaded into the engine
* @param onResult Callback used when an execution result is received
*/
const execute = React.useCallback(
async (
onResult: (
result: StepExecutionResult<RS>,
error?: WorkerRuntimeError
) => void,
interval: number
) => {
setWorkerState("processing");
// Set up a streaming-response cycle with the worker
await requestWorker({ type: "Execute", params: { interval } }, (res) => {
if (res.type !== "result") return true;
onResult(res.data, res.error);
if (!res.data.nextStepLocation) {
// Program execution complete
setWorkerState("done");
return false;
} else if (res.data.signal === "paused") {
// Execution paused by user or breakpoint
setWorkerState("paused");
return false;
} else if (res.error) {
// Runtime error occured
setWorkerState("error");
return false;
} else return true;
});
},
[]
);
return {
state: workerState,
resetState,
prepare,
pauseExecution,
validateCode,
execute,
executeStep,
updateBreakpoints,
};
};