246 lines
7.7 KiB
TypeScript
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,
|
|
};
|
|
};
|