-
Notifications
You must be signed in to change notification settings - Fork 1
Add JavaScript runtime with eval-based execution and interrupt via worker termination #91
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 7 commits
7cd8b2a
f0b2004
6d2a86f
dc2e3d2
6b0ede3
4730d8a
d3dd4f4
bdfa2a1
35244c8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| "use client"; | ||
|
|
||
| import { EditorComponent } from "../editor"; | ||
| import { ReplTerminal } from "../repl"; | ||
|
|
||
| export default function JavaScriptPage() { | ||
| return ( | ||
| <div className="p-4 flex flex-col gap-4"> | ||
| <ReplTerminal | ||
| terminalId="" | ||
| language="javascript" | ||
| initContent={"> console.log('hello, world!')\nhello, world!"} | ||
| /> | ||
| <EditorComponent | ||
| language="javascript" | ||
| filename="main.js" | ||
| initContent="console.log('hello, world!');" | ||
| /> | ||
| </div> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,255 @@ | ||
| "use client"; | ||
|
|
||
| import { | ||
| useState, | ||
| useRef, | ||
| useCallback, | ||
| ReactNode, | ||
| createContext, | ||
| useContext, | ||
| useEffect, | ||
| } from "react"; | ||
| import { SyntaxStatus, ReplOutput, ReplCommand } from "../repl"; | ||
| import { Mutex, MutexInterface } from "async-mutex"; | ||
| import { RuntimeContext } from "../runtime"; | ||
|
|
||
| const JavaScriptContext = createContext<RuntimeContext>(null!); | ||
|
|
||
| export function useJavaScript(): RuntimeContext { | ||
| const context = useContext(JavaScriptContext); | ||
| if (!context) { | ||
| throw new Error("useJavaScript must be used within a JavaScriptProvider"); | ||
| } | ||
| return context; | ||
| } | ||
|
|
||
| type MessageToWorker = | ||
| | { | ||
| type: "init"; | ||
| } | ||
| | { | ||
| type: "runJavaScript"; | ||
| payload: { code: string }; | ||
| } | ||
| | { | ||
| type: "checkSyntax"; | ||
| payload: { code: string }; | ||
| } | ||
| | { | ||
| type: "restoreState"; | ||
| }; | ||
|
|
||
| type MessageFromWorker = | ||
| | { id: number; payload: unknown } | ||
| | { id: number; error: string }; | ||
|
|
||
| type InitPayloadFromWorker = { success: boolean }; | ||
| type RunPayloadFromWorker = { | ||
| output: ReplOutput[]; | ||
| updatedFiles: [string, string][]; | ||
| }; | ||
| type StatusPayloadFromWorker = { status: SyntaxStatus }; | ||
|
|
||
| export function JavaScriptProvider({ children }: { children: ReactNode }) { | ||
| const workerRef = useRef<Worker | null>(null); | ||
| const [ready, setReady] = useState<boolean>(false); | ||
| const mutex = useRef<MutexInterface>(new Mutex()); | ||
| const messageCallbacks = useRef< | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| Map<number, [(payload: any) => void, (error: string) => void]> | ||
| >(new Map()); | ||
| const nextMessageId = useRef<number>(0); | ||
| const isInterrupted = useRef<boolean>(false); | ||
|
|
||
| function postMessage<T>({ type, payload }: MessageToWorker) { | ||
| const id = nextMessageId.current++; | ||
| return new Promise<T>((resolve, reject) => { | ||
| messageCallbacks.current.set(id, [resolve, reject]); | ||
| workerRef.current?.postMessage({ id, type, payload }); | ||
| }); | ||
| } | ||
|
|
||
| const initializeWorker = useCallback(() => { | ||
| const worker = new Worker("/javascript.worker.js"); | ||
| workerRef.current = worker; | ||
|
|
||
| worker.onmessage = (event) => { | ||
| const data = event.data as MessageFromWorker; | ||
| if (messageCallbacks.current.has(data.id)) { | ||
| const [resolve, reject] = messageCallbacks.current.get(data.id)!; | ||
| if ("error" in data) { | ||
| reject(data.error); | ||
| } else { | ||
| resolve(data.payload); | ||
| } | ||
| messageCallbacks.current.delete(data.id); | ||
| } | ||
| }; | ||
|
|
||
| postMessage<InitPayloadFromWorker>({ | ||
| type: "init", | ||
| }).then(({ success }) => { | ||
| if (success) { | ||
| setReady(true); | ||
| } | ||
| }); | ||
|
|
||
| return worker; | ||
| }, []); | ||
|
|
||
| useEffect(() => { | ||
| const worker = initializeWorker(); | ||
|
|
||
| return () => { | ||
| worker.terminate(); | ||
| }; | ||
| }, [initializeWorker]); | ||
|
|
||
| const interrupt = useCallback(async () => { | ||
| // Since we can't interrupt JavaScript execution directly, | ||
| // we terminate the worker and restart it, then restore state | ||
| isInterrupted.current = true; | ||
|
|
||
| // Reject all pending callbacks before terminating | ||
| const error = "Worker interrupted"; | ||
| messageCallbacks.current.forEach(([, reject]) => reject(error)); | ||
| messageCallbacks.current.clear(); | ||
|
|
||
| // Terminate the current worker | ||
| workerRef.current?.terminate(); | ||
|
|
||
| // Reset ready state | ||
| setReady(false); | ||
|
|
||
| // Create a new worker | ||
| initializeWorker(); | ||
|
|
||
| // Wait for initialization with timeout | ||
| const maxRetries = 50; // 5 seconds total | ||
| let retries = 0; | ||
|
|
||
| await new Promise<void>((resolve, reject) => { | ||
| const checkInterval = setInterval(() => { | ||
| retries++; | ||
| if (retries > maxRetries) { | ||
| clearInterval(checkInterval); | ||
| reject(new Error("Worker initialization timeout")); | ||
| return; | ||
| } | ||
|
||
|
|
||
| if (workerRef.current) { | ||
| // Try to restore state | ||
| postMessage<{ success: boolean }>({ | ||
| type: "restoreState", | ||
| }).then(() => { | ||
| clearInterval(checkInterval); | ||
| isInterrupted.current = false; | ||
| resolve(); | ||
| }).catch(() => { | ||
| // Keep trying | ||
| }); | ||
| } | ||
| }, 100); | ||
| }); | ||
| }, [initializeWorker]); | ||
|
|
||
| const runCommand = useCallback( | ||
| async (code: string): Promise<ReplOutput[]> => { | ||
| if (!mutex.current.isLocked()) { | ||
| throw new Error( | ||
| "mutex of JavaScriptContext must be locked for runCommand" | ||
| ); | ||
| } | ||
| if (!workerRef.current || !ready) { | ||
| return [{ type: "error", message: "JavaScript runtime is not ready yet." }]; | ||
| } | ||
|
|
||
| try { | ||
| const { output } = await postMessage<RunPayloadFromWorker>({ | ||
| type: "runJavaScript", | ||
| payload: { code }, | ||
| }); | ||
| return output; | ||
| } catch (error) { | ||
| // If interrupted or worker was terminated, return appropriate message | ||
| if (isInterrupted.current) { | ||
| return [{ type: "error", message: "実行が中断されました" }]; | ||
| } | ||
|
||
| // Handle other errors | ||
| if (error instanceof Error) { | ||
| return [{ type: "error", message: error.message }]; | ||
| } | ||
| return [{ type: "error", message: String(error) }]; | ||
| } | ||
| }, | ||
| [ready] | ||
| ); | ||
|
|
||
| const checkSyntax = useCallback( | ||
| async (code: string): Promise<SyntaxStatus> => { | ||
| if (!workerRef.current || !ready) return "invalid"; | ||
| const { status } = await mutex.current.runExclusive(() => | ||
| postMessage<StatusPayloadFromWorker>({ | ||
| type: "checkSyntax", | ||
| payload: { code }, | ||
| }) | ||
| ); | ||
| return status; | ||
| }, | ||
| [ready] | ||
| ); | ||
|
|
||
| const runFiles = useCallback( | ||
| // eslint-disable-next-line @typescript-eslint/no-unused-vars | ||
| async (_filenames: string[]): Promise<ReplOutput[]> => { | ||
| return [ | ||
| { | ||
| type: "error", | ||
| message: "JavaScript file execution is not supported in this runtime", | ||
| }, | ||
| ]; | ||
| }, | ||
| [] | ||
| ); | ||
|
|
||
| const splitReplExamples = useCallback((content: string): ReplCommand[] => { | ||
| const initCommands: { command: string; output: ReplOutput[] }[] = []; | ||
| for (const line of content.split("\n")) { | ||
| if (line.startsWith("> ")) { | ||
| // Remove the prompt from the command | ||
| initCommands.push({ command: line.slice(2), output: [] }); | ||
| } else { | ||
| // Lines without prompt are output from the previous command | ||
| if (initCommands.length > 0) { | ||
| initCommands[initCommands.length - 1].output.push({ | ||
| type: "stdout", | ||
| message: line, | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| return initCommands; | ||
| }, []); | ||
|
|
||
| const getCommandlineStr = useCallback( | ||
| (filenames: string[]) => `node ${filenames[0]}`, | ||
| [] | ||
| ); | ||
|
|
||
| return ( | ||
| <JavaScriptContext.Provider | ||
| value={{ | ||
| ready, | ||
| runCommand, | ||
| checkSyntax, | ||
| mutex: mutex.current, | ||
| runFiles, | ||
| interrupt, | ||
| splitReplExamples, | ||
| getCommandlineStr, | ||
| }} | ||
| > | ||
| {children} | ||
| </JavaScriptContext.Provider> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@copilot
error: Property 'payload' does not exist on type 'MessageToWorker'.
initとrestorestateメッセージに空のpayloadを追加すると解決すると思います。
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in bdfa2a1. Added
payload?: undefinedto init type andpayload: { commands: string[] }to restoreState type.