diff --git a/app/terminal/runtime.tsx b/app/terminal/runtime.tsx index 65bb14c..520c244 100644 --- a/app/terminal/runtime.tsx +++ b/app/terminal/runtime.tsx @@ -79,9 +79,9 @@ export function useRuntime(language: RuntimeLang): RuntimeContext { } export function RuntimeProvider({ children }: { children: ReactNode }) { return ( - - - + + + {children} diff --git a/public/javascript.worker.js b/app/terminal/worker/jsEval.worker.ts similarity index 56% rename from public/javascript.worker.js rename to app/terminal/worker/jsEval.worker.ts index 98a59c0..1b90c9f 100644 --- a/public/javascript.worker.js +++ b/app/terminal/worker/jsEval.worker.ts @@ -1,32 +1,39 @@ -// JavaScript web worker -let jsOutput = []; +import type { ReplOutput } from "../repl"; +import type { MessageType, WorkerRequest, WorkerResponse } from "./runtime"; + +let jsOutput: ReplOutput[] = []; // Helper function to capture console output const originalConsole = globalThis.console; globalThis.console = { - log: (...args) => { + ...originalConsole, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + log: (...args: any[]) => { jsOutput.push({ type: "stdout", message: args.join(" ") }); }, - error: (...args) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + error: (...args: any[]) => { jsOutput.push({ type: "stderr", message: args.join(" ") }); }, - warn: (...args) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + warn: (...args: any[]) => { jsOutput.push({ type: "stderr", message: args.join(" ") }); }, - info: (...args) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + info: (...args: any[]) => { jsOutput.push({ type: "stdout", message: args.join(" ") }); }, }; -async function init(id, payload) { +async function init({ id }: WorkerRequest["init"]) { // Initialize the worker and report capabilities self.postMessage({ id, payload: { capabilities: { interrupt: "restart" } }, - }); + } satisfies WorkerResponse["init"]); } -async function runCode(id, payload) { +async function runCode({ id, payload }: WorkerRequest["runCode"]) { const { code } = payload; try { // Execute code directly with eval in the worker global scope @@ -60,11 +67,11 @@ async function runCode(id, payload) { self.postMessage({ id, payload: { output, updatedFiles: [] }, - }); + } satisfies WorkerResponse["runCode"]); } -function runFile(id, payload) { - const output = [ +function runFile({ id }: WorkerRequest["runFile"]) { + const output: ReplOutput[] = [ { type: "error", message: "File execution is not supported in this runtime", @@ -73,16 +80,19 @@ function runFile(id, payload) { self.postMessage({ id, payload: { output, updatedFiles: [] }, - }); + } satisfies WorkerResponse["runFile"]); } -async function checkSyntax(id, payload) { +async function checkSyntax({ id, payload }: WorkerRequest["checkSyntax"]) { const { code } = payload; try { // Try to create a Function to check syntax new Function(code); - self.postMessage({ id, payload: { status: "complete" } }); + self.postMessage({ + id, + payload: { status: "complete" }, + } satisfies WorkerResponse["checkSyntax"]); } catch (e) { // Check if it's a syntax error or if more input is expected if (e instanceof SyntaxError) { @@ -91,17 +101,26 @@ async function checkSyntax(id, payload) { e.message.includes("Unexpected end of input") || e.message.includes("expected expression") ) { - self.postMessage({ id, payload: { status: "incomplete" } }); + self.postMessage({ + id, + payload: { status: "incomplete" }, + } satisfies WorkerResponse["checkSyntax"]); } else { - self.postMessage({ id, payload: { status: "invalid" } }); + self.postMessage({ + id, + payload: { status: "invalid" }, + } satisfies WorkerResponse["checkSyntax"]); } } else { - self.postMessage({ id, payload: { status: "invalid" } }); + self.postMessage({ + id, + payload: { status: "invalid" }, + } satisfies WorkerResponse["checkSyntax"]); } } } -async function restoreState(id, payload) { +async function restoreState({ id, payload }: WorkerRequest["restoreState"]) { // Re-execute all previously successful commands to restore state const { commands } = payload; jsOutput = []; // Clear output for restoration @@ -116,29 +135,32 @@ async function restoreState(id, payload) { } jsOutput = []; // Clear any output from restoration - self.postMessage({ id, payload: {} }); + self.postMessage({ + id, + payload: {}, + } satisfies WorkerResponse["restoreState"]); } -self.onmessage = async (event) => { - const { id, type, payload } = event.data; - switch (type) { +self.onmessage = async (event: MessageEvent) => { + switch (event.data.type) { case "init": - await init(id, payload); + await init(event.data); return; case "runCode": - await runCode(id, payload); + await runCode(event.data); return; case "runFile": - runFile(id, payload); + runFile(event.data); return; case "checkSyntax": - await checkSyntax(id, payload); + await checkSyntax(event.data); return; case "restoreState": - await restoreState(id, payload); + await restoreState(event.data); return; default: - originalConsole.error(`Unknown message type: ${type}`); + event.data satisfies never; + originalConsole.error(`Unknown message: ${event.data}`); return; } }; diff --git a/public/pyodide.worker.js b/app/terminal/worker/pyodide.worker.ts similarity index 66% rename from public/pyodide.worker.js rename to app/terminal/worker/pyodide.worker.ts index 43ba76f..0aa5edf 100644 --- a/public/pyodide.worker.js +++ b/app/terminal/worker/pyodide.worker.ts @@ -1,39 +1,49 @@ -// Pyodide web worker -let pyodide; -let pyodideOutput = []; -const PYODIDE_CDN = `https://cdn.jsdelivr.net/pyodide/v0.28.1/full/`; +import type { PyodideInterface } from "pyodide"; +// import { loadPyodide } from "pyodide"; -> Reading from "node:child_process" is not handled by plugins +import { version as pyodideVersion } from "pyodide/package.json"; +import type { PyCallable } from "pyodide/ffi"; +import type { MessageType, WorkerRequest, WorkerResponse } from "./runtime"; +import type { ReplOutput } from "../repl"; + +const PYODIDE_CDN = `https://cdn.jsdelivr.net/pyodide/v${pyodideVersion}/full/`; + +let pyodide: PyodideInterface; +let pyodideOutput: ReplOutput[] = []; // Helper function to read all files from the Pyodide file system -function readAllFiles() { +function readAllFiles(): Record { + if (!pyodide) { + return {}; + } + const updatedFiles: Record = {}; const dirFiles = pyodide.FS.readdir(HOME); - const updatedFiles = []; for (const filename of dirFiles) { if (filename === "." || filename === "..") continue; - const filepath = HOME + filename; + const filepath = `${HOME}/${filename}`; const stat = pyodide.FS.stat(filepath); if (pyodide.FS.isFile(stat.mode)) { const content = pyodide.FS.readFile(filepath, { encoding: "utf8" }); - updatedFiles.push([filename, content]); + updatedFiles[filename] = content; } } return updatedFiles; } -async function init(id, payload) { +async function init({ id, payload }: WorkerRequest["init"]) { const { interruptBuffer } = payload; if (!pyodide) { - importScripts(`${PYODIDE_CDN}pyodide.js`); - pyodide = await loadPyodide({ - indexURL: PYODIDE_CDN, - }); + (globalThis as WorkerGlobalScope).importScripts(`${PYODIDE_CDN}pyodide.js`); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + pyodide = await (globalThis as any).loadPyodide({ indexURL: PYODIDE_CDN }); pyodide.setStdout({ - batched: (str) => { + batched: (str: string) => { pyodideOutput.push({ type: "stdout", message: str }); }, }); pyodide.setStderr({ - batched: (str) => { + batched: (str: string) => { pyodideOutput.push({ type: "stderr", message: str }); }, }); @@ -43,13 +53,16 @@ async function init(id, payload) { self.postMessage({ id, payload: { capabilities: { interrupt: "buffer" } }, - }); + } satisfies WorkerResponse["init"]); } -async function runCode(id, payload) { +async function runCode({ id, payload }: WorkerRequest["runCode"]) { const { code } = payload; if (!pyodide) { - self.postMessage({ id, error: "Pyodide not initialized" }); + self.postMessage({ + id, + error: "Pyodide not initialized", + } satisfies WorkerResponse["runCode"]); return; } try { @@ -59,10 +72,8 @@ async function runCode(id, payload) { type: "return", message: String(result), }); - } else { - // 標準出力/エラーがない場合 } - } catch (e) { + } catch (e: unknown) { console.log(e); if (e instanceof Error) { // エラーがPyodideのTracebackの場合、2行目からが出てくるまでを隠す @@ -94,35 +105,35 @@ async function runCode(id, payload) { } const updatedFiles = readAllFiles(); - const output = [...pyodideOutput]; pyodideOutput = []; // 出力をクリア self.postMessage({ id, payload: { output, updatedFiles }, - }); + } satisfies WorkerResponse["runCode"]); } -async function runFile(id, payload) { +async function runFile({ id, payload }: WorkerRequest["runFile"]) { const { name, files } = payload; if (!pyodide) { - self.postMessage({ id, error: "Pyodide not initialized" }); + self.postMessage({ + id, + error: "Pyodide not initialized", + } satisfies WorkerResponse["runFile"]); return; } try { // Use Pyodide FS API to write files to the file system for (const filename of Object.keys(files)) { if (files[filename]) { - pyodide.FS.writeFile(HOME + filename, files[filename], { - encoding: "utf8", - }); + pyodide.FS.writeFile(`${HOME}/${filename}`, files[filename]); } } - const pyExecFile = pyodide.runPython(EXECFILE_CODE); /* as PyCallable*/ - pyExecFile(HOME + name); - } catch (e) { + const pyExecFile = pyodide.runPython(EXECFILE_CODE) as PyCallable; + pyExecFile(`${HOME}/${name}`); + } catch (e: unknown) { console.log(e); if (e instanceof Error) { // エラーがPyodideのTracebackの場合、2行目からが出てくるまでを隠す @@ -155,22 +166,21 @@ async function runFile(id, payload) { } const updatedFiles = readAllFiles(); - const output = [...pyodideOutput]; pyodideOutput = []; // 出力をクリア self.postMessage({ id, payload: { output, updatedFiles }, - }); + } satisfies WorkerResponse["runFile"]); } -async function checkSyntax(id, payload) { +async function checkSyntax({ id, payload }: WorkerRequest["checkSyntax"]) { const { code } = payload; if (!pyodide) { self.postMessage({ id, payload: { status: "invalid" }, - }); + } satisfies WorkerResponse["checkSyntax"]); return; } @@ -179,40 +189,49 @@ async function checkSyntax(id, payload) { self.postMessage({ id, payload: { status: "incomplete" }, - }); + } satisfies WorkerResponse["checkSyntax"]); return; } try { // Pythonのコードを実行して結果を受け取る - const status = pyodide.runPython(CHECK_SYNTAX_CODE)(code); - self.postMessage({ id, payload: { status } }); + const status = (pyodide.runPython(CHECK_SYNTAX_CODE) as PyCallable)(code); + self.postMessage({ + id, + payload: { status }, + } satisfies WorkerResponse["checkSyntax"]); } catch (e) { console.error("Syntax check error:", e); self.postMessage({ id, payload: { status: "invalid" }, - }); + } satisfies WorkerResponse["checkSyntax"]); } } -self.onmessage = async (event) => { - const { id, type, payload } = event.data; - switch (type) { +self.onmessage = async (event: MessageEvent) => { + switch (event.data.type) { case "init": - await init(id, payload); + await init(event.data); return; case "runCode": - await runCode(id, payload); + await runCode(event.data); return; case "runFile": - await runFile(id, payload); + await runFile(event.data); return; case "checkSyntax": - await checkSyntax(id, payload); + await checkSyntax(event.data); + return; + case "restoreState": + self.postMessage({ + id: event.data.id, + error: "not implemented", + } satisfies WorkerResponse["restoreState"]); return; default: - console.error(`Unknown message type: ${type}`); + event.data satisfies never; + console.error(`Unknown message: ${event.data}`); return; } }; diff --git a/public/ruby.worker.js b/app/terminal/worker/ruby.worker.ts similarity index 64% rename from public/ruby.worker.js rename to app/terminal/worker/ruby.worker.ts index 4c84730..af39088 100644 --- a/public/ruby.worker.js +++ b/app/terminal/worker/ruby.worker.ts @@ -1,37 +1,36 @@ -// Ruby.wasm web worker -let rubyVM = null; -let rubyOutput = []; +import { DefaultRubyVM } from "@ruby/wasm-wasi/dist/browser"; +import type { RubyVM } from "@ruby/wasm-wasi/dist/vm"; +import type { MessageType, WorkerRequest, WorkerResponse } from "./runtime"; +import type { ReplOutput } from "../repl"; + +let rubyVM: RubyVM | null = null; +let rubyOutput: ReplOutput[] = []; let stdoutBuffer = ""; let stderrBuffer = ""; -const RUBY_JS_URL = - "https://cdn.jsdelivr.net/npm/@ruby/wasm-wasi@2.7.2/dist/browser.umd.js"; -const RUBY_WASM_URL = - "https://cdn.jsdelivr.net/npm/@ruby/3.4-wasm-wasi@2.7.2/dist/ruby+stdlib.wasm"; - +declare global { + var stdout: { write: (str: string) => void }; + var stderr: { write: (str: string) => void }; +} globalThis.stdout = { - write(str) { + write(str: string) { stdoutBuffer += str; }, }; globalThis.stderr = { - write(str) { + write(str: string) { stderrBuffer += str; }, }; -async function init(id, payload) { - // const { } = payload; - +async function init({ id }: WorkerRequest["init"]) { if (!rubyVM) { try { - importScripts(RUBY_JS_URL); - // Fetch and compile the Ruby WASM module - const rubyModule = await WebAssembly.compileStreaming( - await fetch(RUBY_WASM_URL) + const rubyWasmRes = await fetch( + "https://cdn.jsdelivr.net/npm/@ruby/3.4-wasm-wasi@latest/dist/ruby+stdlib.wasm" ); - const { DefaultRubyVM } = globalThis["ruby-wasm-wasi"]; + const rubyModule = await WebAssembly.compileStreaming(rubyWasmRes); const { vm } = await DefaultRubyVM(rubyModule); rubyVM = vm; @@ -49,20 +48,18 @@ $stderr = Object.new.tap do |obj| end end `); - } catch (e) { + self.postMessage({ + id, + payload: { capabilities: { interrupt: "restart" } }, + } satisfies WorkerResponse["init"]); + } catch (e: unknown) { console.error("Failed to initialize Ruby VM:", e); self.postMessage({ id, - error: `Failed to initialize Ruby: ${e.message}`, - }); - return; + error: `Failed to initialize Ruby: ${e}`, + } satisfies WorkerResponse["init"]); } } - - self.postMessage({ - id, - payload: { capabilities: { interrupt: "restart" } }, - }); } function flushOutput() { @@ -92,7 +89,7 @@ function flushOutput() { stderrBuffer = ""; } -function formatRubyError(error, isFile) { +function formatRubyError(error: unknown, isFile: boolean): string { if (!(error instanceof Error)) { return `予期せぬエラー: ${String(error).trim()}`; } @@ -112,11 +109,14 @@ function formatRubyError(error, isFile) { return errorMessage; } -async function runCode(id, payload) { +async function runCode({ id, payload }: WorkerRequest["runCode"]) { const { code } = payload; if (!rubyVM) { - self.postMessage({ id, error: "Ruby VM not initialized" }); + self.postMessage({ + id, + error: "Ruby VM not initialized", + } satisfies WorkerResponse["runCode"]); return; } @@ -135,7 +135,7 @@ async function runCode(id, payload) { // Add result to output if it's not nil and not empty rubyOutput.push({ type: "return", - message: resultStr, + message: resultStr.toString(), }); } catch (e) { console.log(e); @@ -154,14 +154,17 @@ async function runCode(id, payload) { self.postMessage({ id, payload: { output, updatedFiles }, - }); + } satisfies WorkerResponse["runCode"]); } -async function runFile(id, payload) { +async function runFile({ id, payload }: WorkerRequest["runFile"]) { const { name, files } = payload; if (!rubyVM) { - self.postMessage({ id, error: "Ruby VM not initialized" }); + self.postMessage({ + id, + error: "Ruby VM not initialized", + } satisfies WorkerResponse["runFile"]); return; } @@ -174,7 +177,9 @@ async function runFile(id, payload) { for (const [filename, content] of Object.entries(files)) { if (content) { rubyVM.eval( - `File.write(${JSON.stringify(filename)}, ${JSON.stringify(content).replaceAll("#", "\\#")})` + `File.write(${JSON.stringify(filename)}, ${JSON.stringify( + content + ).replaceAll("#", "\\#")})` ); } } @@ -204,17 +209,17 @@ async function runFile(id, payload) { self.postMessage({ id, payload: { output, updatedFiles }, - }); + } satisfies WorkerResponse["runFile"]); } -async function checkSyntax(id, payload) { +async function checkSyntax({ id, payload }: WorkerRequest["checkSyntax"]) { const { code } = payload; if (!rubyVM) { self.postMessage({ id, payload: { status: "invalid" }, - }); + } satisfies WorkerResponse["checkSyntax"]); return; } @@ -222,68 +227,60 @@ async function checkSyntax(id, payload) { // Try to parse the code to check syntax // Ruby doesn't have a built-in compile_command like Python // We'll use a simple heuristic - const trimmed = code.trim(); - - // // Check for incomplete syntax patterns - // const incompletePatterns = [ - // /\bif\b.*(? pattern.test(trimmed))) { - // self.postMessage({ id, payload: { status: "incomplete" } }); - // return; - // } - - // Try to compile/evaluate in check mode try { rubyVM.eval(`BEGIN { raise "check" }; ${code}`); - } catch (e) { + } catch (e: unknown) { if ( + e instanceof Error && e.message && (e.message.includes("unexpected end-of-input") || // for `if` etc. e.message.includes("expected a matching") || // for ( ), [ ] e.message.includes("expected a `}` to close the hash literal") || e.message.includes("unterminated string meets end of file")) ) { - self.postMessage({ id, payload: { status: "incomplete" } }); + self.postMessage({ + id, + payload: { status: "incomplete" }, + } satisfies WorkerResponse["checkSyntax"]); return; } // If it's our check exception, syntax is valid - if (e.message && e.message.includes("check")) { - self.postMessage({ id, payload: { status: "complete" } }); + if (e instanceof Error && e.message && e.message.includes("check")) { + self.postMessage({ + id, + payload: { status: "complete" }, + } satisfies WorkerResponse["checkSyntax"]); return; } // Otherwise it's a syntax error - self.postMessage({ id, payload: { status: "invalid" } }); + self.postMessage({ + id, + payload: { status: "invalid" }, + } satisfies WorkerResponse["checkSyntax"]); return; } - self.postMessage({ id, payload: { status: "complete" } }); + self.postMessage({ + id, + payload: { status: "complete" }, + } satisfies WorkerResponse["checkSyntax"]); } catch (e) { console.error("Syntax check error:", e); self.postMessage({ id, payload: { status: "invalid" }, - }); + } satisfies WorkerResponse["checkSyntax"]); } } // Helper function to read all files from the virtual file system -function readAllFiles() { - if (!rubyVM) return []; - const updatedFiles = []; +function readAllFiles(): Record { + if (!rubyVM) return {}; + const updatedFiles: Record = {}; try { // Get list of files in the home directory - const result = rubyVM.eval(` + const result = rubyVM.eval( + ` require 'json' files = {} Dir.glob('*').each do |filename| @@ -292,10 +289,11 @@ function readAllFiles() { end end JSON.generate(files) - `); + ` + ); const filesObj = JSON.parse(result.toString()); for (const [filename, content] of Object.entries(filesObj)) { - updatedFiles.push([filename, content]); + updatedFiles[filename] = content as string; } } catch (e) { console.error("Error reading files:", e); @@ -304,11 +302,14 @@ function readAllFiles() { return updatedFiles; } -async function restoreState(id, payload) { +async function restoreState({ id, payload }: WorkerRequest["restoreState"]) { // Re-execute all previously successful commands to restore state const { commands } = payload; if (!rubyVM) { - self.postMessage({ id, error: "Ruby VM not initialized" }); + self.postMessage({ + id, + error: "Ruby VM not initialized", + } satisfies WorkerResponse["restoreState"]); return; } @@ -329,30 +330,32 @@ async function restoreState(id, payload) { flushOutput(); rubyOutput = []; - self.postMessage({ id, payload: {} }); + self.postMessage({ + id, + payload: {}, + } satisfies WorkerResponse["restoreState"]); } -self.onmessage = async (event) => { - const { id, type, payload } = event.data; - - switch (type) { +self.onmessage = async (event: MessageEvent) => { + switch (event.data.type) { case "init": - await init(id, payload); + await init(event.data); return; case "runCode": - await runCode(id, payload); + await runCode(event.data); return; case "runFile": - await runFile(id, payload); + await runFile(event.data); return; case "checkSyntax": - await checkSyntax(id, payload); + await checkSyntax(event.data); return; case "restoreState": - await restoreState(id, payload); + await restoreState(event.data); return; default: - console.error(`Unknown message type: ${type}`); + event.data satisfies never; + console.error(`Unknown message: ${event.data}`); return; } }; diff --git a/app/terminal/worker/runtime.tsx b/app/terminal/worker/runtime.tsx index 78ed53f..e9129ec 100644 --- a/app/terminal/worker/runtime.tsx +++ b/app/terminal/worker/runtime.tsx @@ -8,45 +8,64 @@ import { useRef, useState, } from "react"; -import { RuntimeContext } from "../runtime"; +import { RuntimeContext, RuntimeLang } from "../runtime"; import { ReplOutput, SyntaxStatus } from "../repl"; import { Mutex, MutexInterface } from "async-mutex"; import { useEmbedContext } from "../embedContext"; -type MessageToWorker = - | { type: "init"; payload: { interruptBuffer: Uint8Array } } - | { type: "runCode"; payload: { code: string } } - | { type: "checkSyntax"; payload: { code: string } } - | { - type: "runFile"; - payload: { name: string; files: Record }; - } - | { type: "restoreState"; payload: { commands: string[] } }; - -type MessageFromWorker = - | { id: number; payload: unknown } - | { id: number; error: string }; - +type WorkerLang = "python" | "ruby" | "javascript"; type WorkerCapabilities = { interrupt: "buffer" | "restart"; }; -type InitPayloadFromWorker = { - capabilities: WorkerCapabilities; + +export type MessageType = keyof MessagePayload; +export type MessagePayload = { + init: { + req: { interruptBuffer: Uint8Array }; + res: { capabilities: WorkerCapabilities }; + }; + runCode: { + req: { code: string }; + res: { output: ReplOutput[]; updatedFiles: Record }; + }; + runFile: { + req: { name: string; files: Record }; + res: { output: ReplOutput[]; updatedFiles: Record }; + }; + checkSyntax: { + req: { code: string }; + res: { status: SyntaxStatus }; + }; + restoreState: { + req: { commands: string[] }; + res: object; + }; +}; +// export type WorkerRequest = { id: number; type: "init"; payload: MessagePayload["init"]["req"]; } | ... と同じ +export type WorkerRequest = { + [K in MessageType]: { + id: number; + type: K; + payload: MessagePayload[K]["req"]; + }; }; -type RunPayloadFromWorker = { - output: ReplOutput[]; - updatedFiles: [string, string][]; +export type WorkerResponse = { + [K in MessageType]: + | { + id: number; + payload: MessagePayload[MessageType]["res"]; + } + | { id: number; error: string }; }; -type StatusPayloadFromWorker = { status: SyntaxStatus }; export function WorkerProvider({ children, context, - script, + lang, }: { children: ReactNode; context: Context; - script: string; + lang: WorkerLang; }) { const workerRef = useRef(null); const [ready, setReady] = useState(false); @@ -65,23 +84,41 @@ export function WorkerProvider({ const commandHistory = useRef([]); // Generic postMessage - function postMessage(message: MessageToWorker) { + function postMessage( + type: K, + payload: MessagePayload[K]["req"] + ) { const id = nextMessageId.current++; - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { messageCallbacks.current.set(id, [resolve, reject]); - workerRef.current?.postMessage({ id, ...message }); + workerRef.current?.postMessage({ id, type, payload } as WorkerRequest[K]); }); } const initializeWorker = useCallback(async () => { - const worker = new Worker(script); + let worker: Worker; + lang satisfies RuntimeLang; + switch (lang) { + case "python": + worker = new Worker(new URL("./pyodide.worker.ts", import.meta.url)); + break; + case "ruby": + worker = new Worker(new URL("./ruby.worker.ts", import.meta.url)); + break; + case "javascript": + worker = new Worker(new URL("./jsEval.worker.ts", import.meta.url)); + break; + default: + lang satisfies never; + throw new Error(`Unknown worker language: ${lang}`); + } workerRef.current = worker; // Always create and provide the buffer interruptBuffer.current = new Uint8Array(new SharedArrayBuffer(1)); worker.onmessage = (event) => { - const data = event.data as MessageFromWorker; + const data = event.data as WorkerResponse[MessageType]; if (messageCallbacks.current.has(data.id)) { const [resolve, reject] = messageCallbacks.current.get(data.id)!; if ("error" in data) { @@ -93,13 +130,12 @@ export function WorkerProvider({ } }; - return postMessage({ - type: "init", - payload: { interruptBuffer: interruptBuffer.current }, + return postMessage("init", { + interruptBuffer: interruptBuffer.current, }).then((payload) => { capabilities.current = payload.capabilities; }); - }, [script]); + }, [lang]); // Initialization effect useEffect(() => { @@ -130,9 +166,8 @@ export function WorkerProvider({ void mutex.current.runExclusive(async () => { await initializeWorker(); if (commandHistory.current.length > 0) { - await postMessage({ - type: "restoreState", - payload: { commands: commandHistory.current }, + await postMessage("restoreState", { + commands: commandHistory.current, }); } setReady(true); @@ -167,13 +202,9 @@ export function WorkerProvider({ } try { - const { output, updatedFiles } = - await postMessage({ - type: "runCode", - payload: { code }, - }); + const { output, updatedFiles } = await postMessage("runCode", { code }); - for (const [name, content] of updatedFiles) { + for (const [name, content] of Object.entries(updatedFiles)) { writeFile(name, content); } @@ -200,10 +231,7 @@ export function WorkerProvider({ async (code: string): Promise => { if (!workerRef.current || !ready) return "invalid"; const { status } = await mutex.current.runExclusive(() => - postMessage({ - type: "checkSyntax", - payload: { code }, - }) + postMessage("checkSyntax", { code }) ); return status; }, @@ -235,12 +263,11 @@ export function WorkerProvider({ interruptBuffer.current[0] = 0; } return mutex.current.runExclusive(async () => { - const { output, updatedFiles } = - await postMessage({ - type: "runFile", - payload: { name: filenames[0], files }, - }); - for (const [newName, content] of updatedFiles) { + const { output, updatedFiles } = await postMessage("runFile", { + name: filenames[0], + files, + }); + for (const [newName, content] of Object.entries(updatedFiles)) { writeFile(newName, content); } return output; diff --git a/next.config.ts b/next.config.ts index d0cf482..d694b51 100644 --- a/next.config.ts +++ b/next.config.ts @@ -30,6 +30,16 @@ const nextConfig: NextConfig = { }, ]; }, + webpack: (config, { isServer }) => { + if (!isServer) { + config.resolve.fallback = { + "child_process": false, + "node:child_process": false, + ...config.resolve.fallback, + }; + } + return config; + }, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index cbdc49d..258111e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@fontsource-variable/noto-sans-jp": "^5.2.6", "@google/genai": "^1.21.0", "@opennextjs/cloudflare": "^1.7.1", + "@ruby/wasm-wasi": "^2.7.2", "@xterm/addon-fit": "^0.11.0-beta.115", "@xterm/xterm": "^5.6.0-beta.115", "ace-builds": "^1.43.2", @@ -26,6 +27,7 @@ "next": "<15.5", "pg": "^8.16.3", "prismjs": "^1.30.0", + "pyodide": "^0.29.0", "react": "19.1.0", "react-ace": "^14.0.1", "react-dom": "19.1.0", @@ -7649,6 +7651,12 @@ "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.18.tgz", "integrity": "sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA==" }, + "node_modules/@bjorn3/browser_wasi_shim": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@bjorn3/browser_wasi_shim/-/browser_wasi_shim-0.3.0.tgz", + "integrity": "sha512-FlRBYttPRLcWORzBe6g8nmYTafBkOEFeOqMYM4tAHJzFsQy4+xJA94z85a9BCs8S+Uzfh9LrkpII7DXr2iUVFg==", + "license": "MIT OR Apache-2.0" + }, "node_modules/@cloudflare/kv-asset-handler": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.0.tgz", @@ -10303,6 +10311,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@ruby/wasm-wasi": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@ruby/wasm-wasi/-/wasm-wasi-2.7.2.tgz", + "integrity": "sha512-KiVtLRFUC4V9hiiNV+ABzZL8tYQ0/nhBEQw9Lu+wj4zxCOw43RYcu+Yru7hwlb8ADppGRKYSv0CeDQqZ8OSqKw==", + "license": "MIT", + "dependencies": { + "@bjorn3/browser_wasi_shim": "^0.3.0", + "tslib": "^2.8.1" + } + }, "node_modules/@rushstack/eslint-patch": { "version": "1.12.0", "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.12.0.tgz", @@ -12143,6 +12161,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/emscripten": { + "version": "1.41.5", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", + "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -20160,6 +20184,19 @@ "node": ">=16.0.0" } }, + "node_modules/pyodide": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.29.0.tgz", + "integrity": "sha512-ObIvsTmcrxAWKg+FT1GjfSdDmQc5CabnYe/nn5BCuhr9BVVITeQ24DBdZuG5B2tIiAZ9YonBpnDB7cmHZyd2Rw==", + "license": "MPL-2.0", + "dependencies": { + "@types/emscripten": "^1.41.4", + "ws": "^8.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", diff --git a/package.json b/package.json index c210f10..c6bae2e 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@fontsource-variable/noto-sans-jp": "^5.2.6", "@google/genai": "^1.21.0", "@opennextjs/cloudflare": "^1.7.1", + "@ruby/wasm-wasi": "^2.7.2", "@xterm/addon-fit": "^0.11.0-beta.115", "@xterm/xterm": "^5.6.0-beta.115", "ace-builds": "^1.43.2", @@ -32,6 +33,7 @@ "next": "<15.5", "pg": "^8.16.3", "prismjs": "^1.30.0", + "pyodide": "^0.29.0", "react": "19.1.0", "react-ace": "^14.0.1", "react-dom": "19.1.0", diff --git a/public/_headers b/public/_headers index 2abdfb7..781edb6 100644 --- a/public/_headers +++ b/public/_headers @@ -1,5 +1,4 @@ /_next/static/* Cache-Control: public,max-age=31536000,immutable -/*.worker.js Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp diff --git a/tsconfig.json b/tsconfig.json index b52a47b..91a8094 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": ["dom", "dom.iterable", "esnext", "webworker"], "allowJs": true, "skipLibCheck": true, "strict": true,