From a2f0aeee511b845102b6667b5acef2c6352ea1c9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 19:03:56 +0000 Subject: [PATCH 01/24] Initial plan From 4c57151f017fc2ec6a7df51204e91a2faee8402f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 19:12:02 +0000 Subject: [PATCH 02/24] Add Ruby runtime implementation with ruby.wasm Co-authored-by: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> --- app/terminal/editor.tsx | 6 +- app/terminal/highlight.ts | 6 +- app/terminal/ruby/page.tsx | 23 +++ app/terminal/ruby/runtime.tsx | 234 +++++++++++++++++++++++++ app/terminal/runtime.tsx | 19 ++- package-lock.json | 27 +++ package.json | 2 + public/ruby.worker.js | 310 ++++++++++++++++++++++++++++++++++ 8 files changed, 623 insertions(+), 4 deletions(-) create mode 100644 app/terminal/ruby/page.tsx create mode 100644 app/terminal/ruby/runtime.tsx create mode 100644 public/ruby.worker.js diff --git a/app/terminal/editor.tsx b/app/terminal/editor.tsx index 0b8f932..4e81830 100644 --- a/app/terminal/editor.tsx +++ b/app/terminal/editor.tsx @@ -11,6 +11,7 @@ const AceEditor = dynamic( await import("ace-builds/src-min-noconflict/ext-language_tools"); await import("ace-builds/src-min-noconflict/ext-searchbox"); await import("ace-builds/src-min-noconflict/mode-python"); + await import("ace-builds/src-min-noconflict/mode-ruby"); await import("ace-builds/src-min-noconflict/mode-c_cpp"); await import("ace-builds/src-min-noconflict/mode-json"); await import("ace-builds/src-min-noconflict/mode-csv"); @@ -28,13 +29,16 @@ import { langConstants } from "./runtime"; // snippetを有効化するにはsnippetもimportする必要がある: import "ace-builds/src-min-noconflict/snippets/python"; // mode-xxxx.js のファイル名と、AceEditorの mode プロパティの値が対応する -export type AceLang = "python" | "c_cpp" | "json" | "csv" | "text"; +export type AceLang = "python" | "ruby" | "c_cpp" | "json" | "csv" | "text"; export function getAceLang(lang: string | undefined): AceLang { // Markdownで指定される可能性のある言語名からAceLangを取得 switch (lang) { case "python": case "py": return "python"; + case "ruby": + case "rb": + return "ruby"; case "cpp": case "c++": return "c_cpp"; diff --git a/app/terminal/highlight.ts b/app/terminal/highlight.ts index e5be201..f11202a 100644 --- a/app/terminal/highlight.ts +++ b/app/terminal/highlight.ts @@ -3,13 +3,17 @@ import chalk from "chalk"; import { RuntimeLang } from "./runtime"; // Python言語定義をインポート import "prismjs/components/prism-python"; +// Ruby言語定義をインポート +import "prismjs/components/prism-ruby"; -type PrismLang = "python"; +type PrismLang = "python" | "ruby"; function getPrismLanguage(language: RuntimeLang): PrismLang { switch (language) { case "python": return "python"; + case "ruby": + return "ruby"; case "cpp": throw new Error( `highlight for ${language} is disabled because it should not support REPL` diff --git a/app/terminal/ruby/page.tsx b/app/terminal/ruby/page.tsx new file mode 100644 index 0000000..a986738 --- /dev/null +++ b/app/terminal/ruby/page.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { EditorComponent } from "../editor"; +import { ExecFile } from "../exec"; +import { ReplTerminal } from "../repl"; + +export default function RubyPage() { + return ( +
+ > puts 'hello, world!'\nhello, world!"} + /> + + +
+ ); +} diff --git a/app/terminal/ruby/runtime.tsx b/app/terminal/ruby/runtime.tsx new file mode 100644 index 0000000..022d41c --- /dev/null +++ b/app/terminal/ruby/runtime.tsx @@ -0,0 +1,234 @@ +"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 { useEmbedContext } from "../embedContext"; +import { RuntimeContext } from "../runtime"; + +const RubyContext = createContext(null!); + +export function useRuby(): RuntimeContext { + const context = useContext(RubyContext); + if (!context) { + throw new Error("useRuby must be used within a RubyProvider"); + } + return context; +} + +type MessageToWorker = + | { + type: "init"; + payload: { RUBY_WASM_URL: string; interruptBuffer: Uint8Array }; + } + | { + type: "runRuby"; + payload: { code: string }; + } + | { + type: "checkSyntax"; + payload: { code: string }; + } + | { + type: "runFile"; + payload: { name: string; files: Record }; + }; + +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 RubyProvider({ children }: { children: ReactNode }) { + const workerRef = useRef(null); + const [ready, setReady] = useState(false); + const mutex = useRef(new Mutex()); + const { files, writeFile } = useEmbedContext(); + const messageCallbacks = useRef< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Map void, (error: string) => void]> + >(new Map()); + const nextMessageId = useRef(0); + const interruptBuffer = useRef(null); + + function postMessage({ type, payload }: MessageToWorker) { + const id = nextMessageId.current++; + return new Promise((resolve, reject) => { + messageCallbacks.current.set(id, [resolve, reject]); + workerRef.current?.postMessage({ id, type, payload }); + }); + } + + useEffect(() => { + const worker = new Worker("/ruby.worker.js"); + workerRef.current = worker; + + interruptBuffer.current = new Uint8Array(new SharedArrayBuffer(1)); + + 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); + } + }; + + // Use CDN URL for Ruby WASM with stdlib + const RUBY_WASM_URL = + "https://cdn.jsdelivr.net/npm/@ruby/3.3-wasm-wasi@2.7.2/dist/ruby+stdlib.wasm"; + + postMessage({ + type: "init", + payload: { RUBY_WASM_URL, interruptBuffer: interruptBuffer.current }, + }).then(({ success }) => { + if (success) { + setReady(true); + } + }); + + return () => { + workerRef.current?.terminate(); + }; + }, []); + + const interrupt = useCallback(() => { + if (interruptBuffer.current) { + interruptBuffer.current[0] = 2; + } + }, []); + + const runCommand = useCallback( + async (code: string): Promise => { + if (!mutex.current.isLocked()) { + throw new Error("mutex of RubyContext must be locked for runCommand"); + } + if (!workerRef.current || !ready) { + return [{ type: "error", message: "Ruby VM is not ready yet." }]; + } + + if (interruptBuffer.current) { + interruptBuffer.current[0] = 0; + } + + const { output, updatedFiles } = await postMessage({ + type: "runRuby", + payload: { code }, + }); + for (const [name, content] of updatedFiles) { + writeFile(name, content); + } + return output; + }, + [ready, writeFile] + ); + + const checkSyntax = useCallback( + async (code: string): Promise => { + if (!workerRef.current || !ready) return "invalid"; + const { status } = await mutex.current.runExclusive(() => + postMessage({ + type: "checkSyntax", + payload: { code }, + }) + ); + return status; + }, + [ready] + ); + + const runFiles = useCallback( + async (filenames: string[]): Promise => { + if (filenames.length !== 1) { + return [ + { + type: "error", + message: "Ruby execution requires exactly one filename", + }, + ]; + } + if (!workerRef.current || !ready) { + return [{ type: "error", message: "Ruby VM is not ready yet." }]; + } + if (interruptBuffer.current) { + 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) { + writeFile(newName, content); + } + return output; + }); + }, + [files, ready, writeFile] + ); + + const splitReplExamples = useCallback((content: string): ReplCommand[] => { + const initCommands: { command: string; output: ReplOutput[] }[] = []; + for (const line of content.split("\n")) { + if (line.startsWith(">> ")) { + // Ruby IRB uses >> as the prompt + initCommands.push({ command: line.slice(3), output: [] }); + } else if (line.startsWith("?> ")) { + // Ruby IRB uses ?> for continuation + if (initCommands.length > 0) { + initCommands[initCommands.length - 1].command += "\n" + line.slice(3); + } + } 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[]) => `ruby ${filenames[0]}`, + [] + ); + + return ( + + {children} + + ); +} diff --git a/app/terminal/runtime.tsx b/app/terminal/runtime.tsx index 7292c19..117754c 100644 --- a/app/terminal/runtime.tsx +++ b/app/terminal/runtime.tsx @@ -1,6 +1,7 @@ import { MutexInterface } from "async-mutex"; import { ReplOutput, SyntaxStatus, ReplCommand } from "./repl"; import { PyodideProvider, usePyodide } from "./python/runtime"; +import { RubyProvider, useRuby } from "./ruby/runtime"; import { useWandbox, WandboxProvider } from "./wandbox/runtime"; import { AceLang } from "./editor"; import { ReactNode } from "react"; @@ -28,7 +29,7 @@ export interface LangConstants { prompt?: string; promptMore?: string; } -export type RuntimeLang = "python" | "cpp"; +export type RuntimeLang = "python" | "ruby" | "cpp"; export function getRuntimeLang( lang: string | undefined @@ -38,6 +39,9 @@ export function getRuntimeLang( case "python": case "py": return "python"; + case "ruby": + case "rb": + return "ruby"; case "cpp": case "c++": return "cpp"; @@ -49,11 +53,14 @@ export function getRuntimeLang( export function useRuntime(language: RuntimeLang): RuntimeContext { // すべての言語のcontextをインスタンス化 const pyodide = usePyodide(); + const ruby = useRuby(); const wandboxCpp = useWandbox("cpp"); switch (language) { case "python": return pyodide; + case "ruby": + return ruby; case "cpp": return wandboxCpp; default: @@ -64,7 +71,9 @@ export function useRuntime(language: RuntimeLang): RuntimeContext { export function RuntimeProvider({ children }: { children: ReactNode }) { return ( - {children} + + {children} + ); } @@ -77,6 +86,12 @@ export function langConstants(lang: RuntimeLang | AceLang): LangConstants { prompt: ">>> ", promptMore: "... ", }; + case "ruby": + return { + tabSize: 2, + prompt: ">> ", + promptMore: "?> ", + }; case "c_cpp": case "cpp": return { diff --git a/package-lock.json b/package-lock.json index 403fe39..ead9a1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ "@fontsource-variable/noto-sans-jp": "^5.2.6", "@google/genai": "^1.21.0", "@opennextjs/cloudflare": "^1.7.1", + "@ruby/3.3-wasm-wasi": "^2.7.2", + "@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", @@ -7646,6 +7648,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", @@ -10290,6 +10298,25 @@ "dev": true, "license": "MIT" }, + "node_modules/@ruby/3.3-wasm-wasi": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@ruby/3.3-wasm-wasi/-/3.3-wasm-wasi-2.7.2.tgz", + "integrity": "sha512-NTA/PqrnOKLhjseOD4JsC7LiaNgQ6FGw3OQxKPD4EoqSh0/odIp6clWlPtaforwIKAuxpV/rOdJ+fBb3USN7xQ==", + "license": "MIT", + "dependencies": { + "@ruby/wasm-wasi": "^2.0.0" + } + }, + "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", diff --git a/package.json b/package.json index 799139b..45135b1 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "@fontsource-variable/noto-sans-jp": "^5.2.6", "@google/genai": "^1.21.0", "@opennextjs/cloudflare": "^1.7.1", + "@ruby/3.3-wasm-wasi": "^2.7.2", + "@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", diff --git a/public/ruby.worker.js b/public/ruby.worker.js new file mode 100644 index 0000000..4d26767 --- /dev/null +++ b/public/ruby.worker.js @@ -0,0 +1,310 @@ +// Ruby.wasm web worker +let rubyVM = null; +let rubyOutput = []; +let rubyModule = null; + +// Helper function to read all files from the virtual file system +function readAllFiles() { + if (!rubyVM) return []; + const updatedFiles = []; + + try { + // Get list of files in the home directory + const result = rubyVM.eval(` + require 'json' + files = {} + Dir.glob('*').each do |filename| + if File.file?(filename) + files[filename] = File.read(filename) + end + end + JSON.generate(files) + `); + const filesObj = JSON.parse(result.toString()); + for (const [filename, content] of Object.entries(filesObj)) { + updatedFiles.push([filename, content]); + } + } catch (e) { + console.error("Error reading files:", e); + } + + return updatedFiles; +} + +async function init(id, payload) { + const { RUBY_WASM_URL, interruptBuffer } = payload; + + if (!rubyVM) { + try { + // Import the browser WASI shim + importScripts('https://cdn.jsdelivr.net/npm/@bjorn3/browser_wasi_shim@0.3.0/dist/index.js'); + + // Fetch and compile the Ruby WASM module + const response = await fetch(RUBY_WASM_URL); + const buffer = await response.arrayBuffer(); + rubyModule = await WebAssembly.compile(buffer); + + // Import the Ruby WASM runtime + // We need to use the browser WASI shim + const { WASI } = self.WASI || globalThis.WASI; + + // Create WASI instance + const wasi = new WASI([], [], [ + { + path: 'stdout', + write: (buf) => { + const text = new TextDecoder().decode(buf); + rubyOutput.push({ type: 'stdout', message: text }); + return buf.byteLength; + } + }, + { + path: 'stderr', + write: (buf) => { + const text = new TextDecoder().decode(buf); + rubyOutput.push({ type: 'stderr', message: text }); + return buf.byteLength; + } + } + ]); + + // Import the RubyVM from @ruby/wasm-wasi + importScripts('https://cdn.jsdelivr.net/npm/@ruby/wasm-wasi@2.7.2/dist/browser.umd.js'); + const { DefaultRubyVM } = self.ruby; + + const { vm } = await DefaultRubyVM(rubyModule, { + consolePrint: false + }); + + rubyVM = vm; + + // Set up stdout/stderr capture + rubyVM.eval(` + class << $stdout + alias_method :original_write, :write + def write(str) + str + end + end + + class << $stderr + alias_method :original_write, :write + def write(str) + str + end + end + `); + + } catch (e) { + console.error("Failed to initialize Ruby VM:", e); + self.postMessage({ id, error: `Failed to initialize Ruby: ${e.message}` }); + return; + } + } + + self.postMessage({ id, payload: { success: true } }); +} + +async function runRuby(id, payload) { + const { code } = payload; + + if (!rubyVM) { + self.postMessage({ id, error: "Ruby VM not initialized" }); + return; + } + + try { + // Capture output + rubyOutput = []; + + const result = rubyVM.eval(code); + const resultStr = result.toString(); + + // Add result to output if it's not nil + if (resultStr !== '' && resultStr !== 'nil') { + rubyOutput.push({ + type: 'return', + message: resultStr + }); + } + } catch (e) { + console.log(e); + if (e instanceof Error) { + // Clean up Ruby error messages + let errorMessage = e.message; + + // Remove internal Ruby traceback lines for cleaner output + if (errorMessage.includes('Traceback')) { + const lines = errorMessage.split('\n'); + errorMessage = lines.filter(line => + !line.includes('(eval)') || + line.includes('Error') || + line.includes(':') + ).join('\n').trim(); + } + + rubyOutput.push({ + type: 'error', + message: errorMessage + }); + } else { + rubyOutput.push({ + type: 'error', + message: `予期せぬエラー: ${String(e).trim()}` + }); + } + } + + const updatedFiles = readAllFiles(); + const output = [...rubyOutput]; + rubyOutput = []; + + self.postMessage({ + id, + payload: { output, updatedFiles } + }); +} + +async function runFile(id, payload) { + const { name, files } = payload; + + if (!rubyVM) { + self.postMessage({ id, error: "Ruby VM not initialized" }); + return; + } + + try { + rubyOutput = []; + + // Write files to the virtual file system + for (const [filename, content] of Object.entries(files)) { + if (content) { + rubyVM.eval(`File.write(${JSON.stringify(filename)}, ${JSON.stringify(content)})`); + } + } + + // Run the specified file + const fileContent = files[name]; + if (!fileContent) { + throw new Error(`File not found: ${name}`); + } + + rubyVM.eval(fileContent); + + } catch (e) { + console.log(e); + if (e instanceof Error) { + let errorMessage = e.message; + + if (errorMessage.includes('Traceback')) { + const lines = errorMessage.split('\n'); + errorMessage = lines.filter(line => + !line.includes('(eval)') || + line.includes('Error') || + line.includes(':') + ).join('\n').trim(); + } + + rubyOutput.push({ + type: 'error', + message: errorMessage + }); + } else { + rubyOutput.push({ + type: 'error', + message: `予期せぬエラー: ${String(e).trim()}` + }); + } + } + + const updatedFiles = readAllFiles(); + const output = [...rubyOutput]; + rubyOutput = []; + + self.postMessage({ + id, + payload: { output, updatedFiles } + }); +} + +async function checkSyntax(id, payload) { + const { code } = payload; + + if (!rubyVM) { + self.postMessage({ + id, + payload: { status: "invalid" } + }); + return; + } + + try { + // 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) { + // If it's our check exception, syntax is valid + if (e.message && e.message.includes('check')) { + self.postMessage({ id, payload: { status: "complete" } }); + return; + } + // Otherwise it's a syntax error + self.postMessage({ id, payload: { status: "invalid" } }); + return; + } + + self.postMessage({ id, payload: { status: "complete" } }); + } catch (e) { + console.error("Syntax check error:", e); + self.postMessage({ + id, + payload: { status: "invalid" } + }); + } +} + +self.onmessage = async (event) => { + const { id, type, payload } = event.data; + + switch (type) { + case "init": + await init(id, payload); + return; + case "runRuby": + await runRuby(id, payload); + return; + case "runFile": + await runFile(id, payload); + return; + case "checkSyntax": + await checkSyntax(id, payload); + return; + default: + console.error(`Unknown message type: ${type}`); + return; + } +}; From 00f3e8507340f2b9f3a659aa54d8578efa6e268c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 19:14:15 +0000 Subject: [PATCH 03/24] Improve Ruby worker implementation with proper WASI stdout/stderr capture Co-authored-by: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> --- app/terminal/ruby/runtime.tsx | 6 +- public/ruby.worker.js | 137 ++++++++++++++++++++-------------- 2 files changed, 85 insertions(+), 58 deletions(-) diff --git a/app/terminal/ruby/runtime.tsx b/app/terminal/ruby/runtime.tsx index 022d41c..f1566c6 100644 --- a/app/terminal/ruby/runtime.tsx +++ b/app/terminal/ruby/runtime.tsx @@ -27,7 +27,7 @@ export function useRuby(): RuntimeContext { type MessageToWorker = | { type: "init"; - payload: { RUBY_WASM_URL: string; interruptBuffer: Uint8Array }; + payload: { RUBY_WASM_URL: string }; } | { type: "runRuby"; @@ -77,8 +77,6 @@ export function RubyProvider({ children }: { children: ReactNode }) { const worker = new Worker("/ruby.worker.js"); workerRef.current = worker; - interruptBuffer.current = new Uint8Array(new SharedArrayBuffer(1)); - worker.onmessage = (event) => { const data = event.data as MessageFromWorker; if (messageCallbacks.current.has(data.id)) { @@ -98,7 +96,7 @@ export function RubyProvider({ children }: { children: ReactNode }) { postMessage({ type: "init", - payload: { RUBY_WASM_URL, interruptBuffer: interruptBuffer.current }, + payload: { RUBY_WASM_URL }, }).then(({ success }) => { if (success) { setReady(true); diff --git a/public/ruby.worker.js b/public/ruby.worker.js index 4d26767..0179420 100644 --- a/public/ruby.worker.js +++ b/public/ruby.worker.js @@ -1,7 +1,8 @@ // Ruby.wasm web worker let rubyVM = null; let rubyOutput = []; -let rubyModule = null; +let stdoutBuffer = ""; +let stderrBuffer = ""; // Helper function to read all files from the virtual file system function readAllFiles() { @@ -32,69 +33,57 @@ function readAllFiles() { } async function init(id, payload) { - const { RUBY_WASM_URL, interruptBuffer } = payload; + const { RUBY_WASM_URL } = payload; if (!rubyVM) { try { - // Import the browser WASI shim + // Import the browser WASI shim and Ruby runtime importScripts('https://cdn.jsdelivr.net/npm/@bjorn3/browser_wasi_shim@0.3.0/dist/index.js'); + importScripts('https://cdn.jsdelivr.net/npm/@ruby/wasm-wasi@2.7.2/dist/browser.umd.js'); // Fetch and compile the Ruby WASM module const response = await fetch(RUBY_WASM_URL); const buffer = await response.arrayBuffer(); - rubyModule = await WebAssembly.compile(buffer); + const rubyModule = await WebAssembly.compile(buffer); - // Import the Ruby WASM runtime - // We need to use the browser WASI shim - const { WASI } = self.WASI || globalThis.WASI; + const { DefaultRubyVM } = rubyWasmWasi; - // Create WASI instance - const wasi = new WASI([], [], [ + // Create custom WASI to capture stdout/stderr + const { WASI } = globalThis.WASI; + const fds = [ { - path: 'stdout', - write: (buf) => { - const text = new TextDecoder().decode(buf); - rubyOutput.push({ type: 'stdout', message: text }); - return buf.byteLength; - } + // stdin + path: "stdin", + read: () => new Uint8Array([]), }, { - path: 'stderr', - write: (buf) => { - const text = new TextDecoder().decode(buf); - rubyOutput.push({ type: 'stderr', message: text }); - return buf.byteLength; - } - } - ]); - - // Import the RubyVM from @ruby/wasm-wasi - importScripts('https://cdn.jsdelivr.net/npm/@ruby/wasm-wasi@2.7.2/dist/browser.umd.js'); - const { DefaultRubyVM } = self.ruby; + // stdout + path: "stdout", + write: (buffer) => { + const text = new TextDecoder().decode(buffer); + stdoutBuffer += text; + return buffer.byteLength; + }, + }, + { + // stderr + path: "stderr", + write: (buffer) => { + const text = new TextDecoder().decode(buffer); + stderrBuffer += text; + return buffer.byteLength; + }, + }, + ]; - const { vm } = await DefaultRubyVM(rubyModule, { - consolePrint: false + const wasi = new WASI([], [], fds); + const { vm } = await rubyWasmWasi.RubyVM.instantiateModule({ + module: rubyModule, + wasip1: wasi, }); rubyVM = vm; - // Set up stdout/stderr capture - rubyVM.eval(` - class << $stdout - alias_method :original_write, :write - def write(str) - str - end - end - - class << $stderr - alias_method :original_write, :write - def write(str) - str - end - end - `); - } catch (e) { console.error("Failed to initialize Ruby VM:", e); self.postMessage({ id, error: `Failed to initialize Ruby: ${e.message}` }); @@ -105,6 +94,32 @@ async function init(id, payload) { self.postMessage({ id, payload: { success: true } }); } +function flushOutput() { + if (stdoutBuffer) { + const lines = stdoutBuffer.split('\n'); + for (let i = 0; i < lines.length - 1; i++) { + rubyOutput.push({ type: 'stdout', message: lines[i] }); + } + stdoutBuffer = lines[lines.length - 1]; + } + if (stderrBuffer) { + const lines = stderrBuffer.split('\n'); + for (let i = 0; i < lines.length - 1; i++) { + rubyOutput.push({ type: 'stderr', message: lines[i] }); + } + stderrBuffer = lines[lines.length - 1]; + } + // Final flush if there's remaining text + if (stdoutBuffer) { + rubyOutput.push({ type: 'stdout', message: stdoutBuffer }); + stdoutBuffer = ""; + } + if (stderrBuffer) { + rubyOutput.push({ type: 'stderr', message: stderrBuffer }); + stderrBuffer = ""; + } +} + async function runRuby(id, payload) { const { code } = payload; @@ -114,13 +129,18 @@ async function runRuby(id, payload) { } try { - // Capture output rubyOutput = []; + stdoutBuffer = ""; + stderrBuffer = ""; const result = rubyVM.eval(code); + + // Flush any buffered output + flushOutput(); + const resultStr = result.toString(); - // Add result to output if it's not nil + // Add result to output if it's not nil and not empty if (resultStr !== '' && resultStr !== 'nil') { rubyOutput.push({ type: 'return', @@ -129,17 +149,19 @@ async function runRuby(id, payload) { } } catch (e) { console.log(e); + flushOutput(); + if (e instanceof Error) { - // Clean up Ruby error messages let errorMessage = e.message; - // Remove internal Ruby traceback lines for cleaner output - if (errorMessage.includes('Traceback')) { + // Clean up Ruby error messages + if (errorMessage.includes('Traceback') || errorMessage.includes('Error')) { const lines = errorMessage.split('\n'); + // Filter out internal lines errorMessage = lines.filter(line => !line.includes('(eval)') || line.includes('Error') || - line.includes(':') + line.match(/:\d+:/) ).join('\n').trim(); } @@ -175,6 +197,8 @@ async function runFile(id, payload) { try { rubyOutput = []; + stdoutBuffer = ""; + stderrBuffer = ""; // Write files to the virtual file system for (const [filename, content] of Object.entries(files)) { @@ -191,17 +215,22 @@ async function runFile(id, payload) { rubyVM.eval(fileContent); + // Flush any buffered output + flushOutput(); + } catch (e) { console.log(e); + flushOutput(); + if (e instanceof Error) { let errorMessage = e.message; - if (errorMessage.includes('Traceback')) { + if (errorMessage.includes('Traceback') || errorMessage.includes('Error')) { const lines = errorMessage.split('\n'); errorMessage = lines.filter(line => !line.includes('(eval)') || line.includes('Error') || - line.includes(':') + line.match(/:\d+:/) ).join('\n').trim(); } From 0b686e97066c1f9f08a110596c3a8bfa0834aeff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 19:18:43 +0000 Subject: [PATCH 04/24] Address code review feedback - fix interrupt buffer and error handling Co-authored-by: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> --- app/terminal/ruby/runtime.tsx | 14 ++---- public/ruby.worker.js | 86 ++++++++++++++--------------------- 2 files changed, 38 insertions(+), 62 deletions(-) diff --git a/app/terminal/ruby/runtime.tsx b/app/terminal/ruby/runtime.tsx index f1566c6..18b71d1 100644 --- a/app/terminal/ruby/runtime.tsx +++ b/app/terminal/ruby/runtime.tsx @@ -63,7 +63,6 @@ export function RubyProvider({ children }: { children: ReactNode }) { Map void, (error: string) => void]> >(new Map()); const nextMessageId = useRef(0); - const interruptBuffer = useRef(null); function postMessage({ type, payload }: MessageToWorker) { const id = nextMessageId.current++; @@ -109,9 +108,9 @@ export function RubyProvider({ children }: { children: ReactNode }) { }, []); const interrupt = useCallback(() => { - if (interruptBuffer.current) { - interruptBuffer.current[0] = 2; - } + // TODO: Implement interrupt functionality for Ruby + // Ruby WASM doesn't currently support interrupts like Pyodide does + console.warn("Ruby interrupt is not yet implemented"); }, []); const runCommand = useCallback( @@ -123,10 +122,6 @@ export function RubyProvider({ children }: { children: ReactNode }) { return [{ type: "error", message: "Ruby VM is not ready yet." }]; } - if (interruptBuffer.current) { - interruptBuffer.current[0] = 0; - } - const { output, updatedFiles } = await postMessage({ type: "runRuby", payload: { code }, @@ -166,9 +161,6 @@ export function RubyProvider({ children }: { children: ReactNode }) { if (!workerRef.current || !ready) { return [{ type: "error", message: "Ruby VM is not ready yet." }]; } - if (interruptBuffer.current) { - interruptBuffer.current[0] = 0; - } return mutex.current.runExclusive(async () => { const { output, updatedFiles } = await postMessage({ diff --git a/public/ruby.worker.js b/public/ruby.worker.js index 0179420..4143058 100644 --- a/public/ruby.worker.js +++ b/public/ruby.worker.js @@ -109,15 +109,37 @@ function flushOutput() { } stderrBuffer = lines[lines.length - 1]; } - // Final flush if there's remaining text - if (stdoutBuffer) { + // Final flush if there's remaining non-empty text + if (stdoutBuffer && stdoutBuffer.trim()) { rubyOutput.push({ type: 'stdout', message: stdoutBuffer }); - stdoutBuffer = ""; } - if (stderrBuffer) { + stdoutBuffer = ""; + + if (stderrBuffer && stderrBuffer.trim()) { rubyOutput.push({ type: 'stderr', message: stderrBuffer }); - stderrBuffer = ""; } + stderrBuffer = ""; +} + +function formatRubyError(error) { + if (!(error instanceof Error)) { + return `予期せぬエラー: ${String(error).trim()}`; + } + + let errorMessage = error.message; + + // Clean up Ruby error messages by filtering out internal eval lines + if (errorMessage.includes('Traceback') || errorMessage.includes('Error')) { + const lines = errorMessage.split('\n'); + // Keep lines that either don't contain (eval), or contain Error, or have line numbers + errorMessage = lines.filter(line => + !line.includes('(eval)') || + line.includes('Error') || + line.match(/:\d+:/) + ).join('\n').trim(); + } + + return errorMessage; } async function runRuby(id, payload) { @@ -151,30 +173,10 @@ async function runRuby(id, payload) { console.log(e); flushOutput(); - if (e instanceof Error) { - let errorMessage = e.message; - - // Clean up Ruby error messages - if (errorMessage.includes('Traceback') || errorMessage.includes('Error')) { - const lines = errorMessage.split('\n'); - // Filter out internal lines - errorMessage = lines.filter(line => - !line.includes('(eval)') || - line.includes('Error') || - line.match(/:\d+:/) - ).join('\n').trim(); - } - - rubyOutput.push({ - type: 'error', - message: errorMessage - }); - } else { - rubyOutput.push({ - type: 'error', - message: `予期せぬエラー: ${String(e).trim()}` - }); - } + rubyOutput.push({ + type: 'error', + message: formatRubyError(e) + }); } const updatedFiles = readAllFiles(); @@ -222,28 +224,10 @@ async function runFile(id, payload) { console.log(e); flushOutput(); - if (e instanceof Error) { - let errorMessage = e.message; - - if (errorMessage.includes('Traceback') || errorMessage.includes('Error')) { - const lines = errorMessage.split('\n'); - errorMessage = lines.filter(line => - !line.includes('(eval)') || - line.includes('Error') || - line.match(/:\d+:/) - ).join('\n').trim(); - } - - rubyOutput.push({ - type: 'error', - message: errorMessage - }); - } else { - rubyOutput.push({ - type: 'error', - message: `予期せぬエラー: ${String(e).trim()}` - }); - } + rubyOutput.push({ + type: 'error', + message: formatRubyError(e) + }); } const updatedFiles = readAllFiles(); From bb921eff5738687a758df4d95a83de7a874fda3e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 02:05:09 +0000 Subject: [PATCH 05/24] Implement interrupt with command history and VM restart Co-authored-by: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> --- app/terminal/ruby/runtime.tsx | 53 +++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/app/terminal/ruby/runtime.tsx b/app/terminal/ruby/runtime.tsx index 18b71d1..75970c4 100644 --- a/app/terminal/ruby/runtime.tsx +++ b/app/terminal/ruby/runtime.tsx @@ -63,6 +63,7 @@ export function RubyProvider({ children }: { children: ReactNode }) { Map void, (error: string) => void]> >(new Map()); const nextMessageId = useRef(0); + const commandHistory = useRef([]); function postMessage({ type, payload }: MessageToWorker) { const id = nextMessageId.current++; @@ -72,7 +73,7 @@ export function RubyProvider({ children }: { children: ReactNode }) { }); } - useEffect(() => { + const initializeWorker = useCallback(() => { const worker = new Worker("/ruby.worker.js"); workerRef.current = worker; @@ -93,10 +94,14 @@ export function RubyProvider({ children }: { children: ReactNode }) { const RUBY_WASM_URL = "https://cdn.jsdelivr.net/npm/@ruby/3.3-wasm-wasi@2.7.2/dist/ruby+stdlib.wasm"; - postMessage({ + return postMessage({ type: "init", payload: { RUBY_WASM_URL }, - }).then(({ success }) => { + }); + }, []); + + useEffect(() => { + initializeWorker().then(({ success }) => { if (success) { setReady(true); } @@ -105,13 +110,35 @@ export function RubyProvider({ children }: { children: ReactNode }) { return () => { workerRef.current?.terminate(); }; - }, []); + }, [initializeWorker]); - const interrupt = useCallback(() => { - // TODO: Implement interrupt functionality for Ruby - // Ruby WASM doesn't currently support interrupts like Pyodide does - console.warn("Ruby interrupt is not yet implemented"); - }, []); + const interrupt = useCallback(async () => { + // Terminate the current worker + if (workerRef.current) { + workerRef.current.terminate(); + } + + // Mark as not ready during reinitialization + setReady(false); + + // Reinitialize the worker + const { success } = await initializeWorker(); + + if (success) { + // Re-execute all saved commands to restore state + for (const cmd of commandHistory.current) { + try { + await postMessage({ + type: "runRuby", + payload: { code: cmd }, + }); + } catch (e) { + console.error("Error restoring command:", cmd, e); + } + } + setReady(true); + } + }, [initializeWorker]); const runCommand = useCallback( async (code: string): Promise => { @@ -126,6 +153,14 @@ export function RubyProvider({ children }: { children: ReactNode }) { type: "runRuby", payload: { code }, }); + + // Check if the command succeeded (no errors) + const hasError = output.some((o) => o.type === "error"); + if (!hasError) { + // Save successful command to history + commandHistory.current.push(code); + } + for (const [name, content] of updatedFiles) { writeFile(name, content); } From 6fd45b749932565d8aae78649ab05980294adce1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 02:09:34 +0000 Subject: [PATCH 06/24] Remove unused Ruby npm dependencies (loaded from CDN instead) Co-authored-by: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> --- package-lock.json | 27 --------------------------- package.json | 2 -- 2 files changed, 29 deletions(-) diff --git a/package-lock.json b/package-lock.json index ead9a1d..403fe39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,6 @@ "@fontsource-variable/noto-sans-jp": "^5.2.6", "@google/genai": "^1.21.0", "@opennextjs/cloudflare": "^1.7.1", - "@ruby/3.3-wasm-wasi": "^2.7.2", - "@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", @@ -7648,12 +7646,6 @@ "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", @@ -10298,25 +10290,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@ruby/3.3-wasm-wasi": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/@ruby/3.3-wasm-wasi/-/3.3-wasm-wasi-2.7.2.tgz", - "integrity": "sha512-NTA/PqrnOKLhjseOD4JsC7LiaNgQ6FGw3OQxKPD4EoqSh0/odIp6clWlPtaforwIKAuxpV/rOdJ+fBb3USN7xQ==", - "license": "MIT", - "dependencies": { - "@ruby/wasm-wasi": "^2.0.0" - } - }, - "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", diff --git a/package.json b/package.json index 45135b1..799139b 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,6 @@ "@fontsource-variable/noto-sans-jp": "^5.2.6", "@google/genai": "^1.21.0", "@opennextjs/cloudflare": "^1.7.1", - "@ruby/3.3-wasm-wasi": "^2.7.2", - "@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", From 43d5db46f588f8239306b03765c34821a8dde2c7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 07:43:59 +0000 Subject: [PATCH 07/24] Add Ruby runtime tests to test suite Co-authored-by: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> --- app/terminal/page.tsx | 3 +++ app/terminal/tests.ts | 15 +++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/app/terminal/page.tsx b/app/terminal/page.tsx index 857523b..4bfd2d2 100644 --- a/app/terminal/page.tsx +++ b/app/terminal/page.tsx @@ -4,6 +4,7 @@ import "mocha/mocha.js"; import "mocha/mocha.css"; import { useEffect, useRef, useState } from "react"; import { usePyodide } from "./python/runtime"; +import { useRuby } from "./ruby/runtime"; import { useWandbox } from "./wandbox/runtime"; import { RuntimeContext, RuntimeLang } from "./runtime"; import { useEmbedContext } from "./embedContext"; @@ -11,10 +12,12 @@ import { defineTests } from "./tests"; export default function RuntimeTestPage() { const pyodide = usePyodide(); + const ruby = useRuby(); const wandboxCpp = useWandbox("cpp"); const runtimeRef = useRef>(null!); runtimeRef.current = { python: pyodide, + ruby: ruby, cpp: wandboxCpp, }; const { files, writeFile } = useEmbedContext(); diff --git a/app/terminal/tests.ts b/app/terminal/tests.ts index 82620ae..1233723 100644 --- a/app/terminal/tests.ts +++ b/app/terminal/tests.ts @@ -12,6 +12,7 @@ export function defineTests( ( { python: 2000, + ruby: 2000, cpp: 10000, } as Record )[lang] @@ -30,6 +31,7 @@ export function defineTests( const printCode = ( { python: `print("${msg}")`, + ruby: `puts "${msg}"`, cpp: null, } satisfies Record )[lang]; @@ -54,6 +56,7 @@ export function defineTests( const [setIntVarCode, printIntVarCode] = ( { python: [`${varName} = ${value}`, `print(${varName})`], + ruby: [`${varName} = ${value}`, `puts ${varName}`], cpp: [null, null], } satisfies Record )[lang]; @@ -80,6 +83,7 @@ export function defineTests( const errorCode = ( { python: `raise Exception("${errorMsg}")`, + ruby: `raise "${errorMsg}"`, cpp: null, } satisfies Record )[lang]; @@ -99,6 +103,7 @@ export function defineTests( const [setIntVarCode, infLoopCode, printIntVarCode] = ( { python: [`testVar = 42`, `while True:\n pass`, `print(testVar)`], + ruby: [`testVar = 42`, `loop do\nend`, `puts testVar`], cpp: [null, null, null], } satisfies Record )[lang]; @@ -132,6 +137,7 @@ export function defineTests( const [filename, code] = ( { python: ["test.py", `print("${msg}")`], + ruby: ["test.rb", `puts "${msg}"`], cpp: [ "test.cpp", `#include \nint main() {\n std::cout << "${msg}" << std::endl;\n return 0;\n}\n`, @@ -156,6 +162,7 @@ export function defineTests( const [filename, code] = ( { python: ["test_error.py", `raise Exception("${errorMsg}")\n`], + ruby: ["test_error.rb", `raise "${errorMsg}"\n`], cpp: [ "test_error.cpp", `#include \nint main() {\n throw std::runtime_error("${errorMsg}");\n return 0;\n}\n`, @@ -183,6 +190,14 @@ export function defineTests( }, ["test_multi_main.py"], ], + ruby: [ + { + "test_multi_main.rb": + "require_relative 'test_multi_sub'\nprint_message\n", + "test_multi_sub.rb": `def print_message\n puts "${msg}"\nend\n`, + }, + ["test_multi_main.rb"], + ], cpp: [ { "test_multi_main.cpp": From e0eaed0eef1ebc77f29d40127d61c517bdfd49f7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 07:47:00 +0000 Subject: [PATCH 08/24] Add Ruby worker headers configuration Co-authored-by: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> --- public/_headers | 3 +++ 1 file changed, 3 insertions(+) diff --git a/public/_headers b/public/_headers index 2472c04..c88c393 100644 --- a/public/_headers +++ b/public/_headers @@ -3,3 +3,6 @@ /pyodide.worker.js Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp +/ruby.worker.js + Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Embedder-Policy: require-corp From 7cd8b2a928e8dd7d93e60bcdd7b9c60066656103 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 08:00:33 +0000 Subject: [PATCH 09/24] Initial plan From f0b20049104887bb25b5a987b99706989bc80495 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 08:09:14 +0000 Subject: [PATCH 10/24] Add JavaScript runtime implementation Co-authored-by: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> --- app/terminal/editor.tsx | 6 +- app/terminal/javascript/page.tsx | 21 +++ app/terminal/javascript/runtime.tsx | 240 ++++++++++++++++++++++++++++ app/terminal/runtime.tsx | 18 ++- app/terminal/tests.ts | 23 ++- public/_headers | 3 + public/javascript.worker.js | 149 +++++++++++++++++ 7 files changed, 454 insertions(+), 6 deletions(-) create mode 100644 app/terminal/javascript/page.tsx create mode 100644 app/terminal/javascript/runtime.tsx create mode 100644 public/javascript.worker.js diff --git a/app/terminal/editor.tsx b/app/terminal/editor.tsx index 0b8f932..566a229 100644 --- a/app/terminal/editor.tsx +++ b/app/terminal/editor.tsx @@ -12,6 +12,7 @@ const AceEditor = dynamic( await import("ace-builds/src-min-noconflict/ext-searchbox"); await import("ace-builds/src-min-noconflict/mode-python"); await import("ace-builds/src-min-noconflict/mode-c_cpp"); + await import("ace-builds/src-min-noconflict/mode-javascript"); await import("ace-builds/src-min-noconflict/mode-json"); await import("ace-builds/src-min-noconflict/mode-csv"); await import("ace-builds/src-min-noconflict/mode-text"); @@ -28,7 +29,7 @@ import { langConstants } from "./runtime"; // snippetを有効化するにはsnippetもimportする必要がある: import "ace-builds/src-min-noconflict/snippets/python"; // mode-xxxx.js のファイル名と、AceEditorの mode プロパティの値が対応する -export type AceLang = "python" | "c_cpp" | "json" | "csv" | "text"; +export type AceLang = "python" | "c_cpp" | "javascript" | "json" | "csv" | "text"; export function getAceLang(lang: string | undefined): AceLang { // Markdownで指定される可能性のある言語名からAceLangを取得 switch (lang) { @@ -38,6 +39,9 @@ export function getAceLang(lang: string | undefined): AceLang { case "cpp": case "c++": return "c_cpp"; + case "javascript": + case "js": + return "javascript"; case "json": return "json"; case "csv": diff --git a/app/terminal/javascript/page.tsx b/app/terminal/javascript/page.tsx new file mode 100644 index 0000000..7f9861c --- /dev/null +++ b/app/terminal/javascript/page.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { EditorComponent } from "../editor"; +import { ReplTerminal } from "../repl"; + +export default function JavaScriptPage() { + return ( +
+ console.log('hello, world!')\nhello, world!"} + /> + +
+ ); +} diff --git a/app/terminal/javascript/runtime.tsx b/app/terminal/javascript/runtime.tsx new file mode 100644 index 0000000..55967c5 --- /dev/null +++ b/app/terminal/javascript/runtime.tsx @@ -0,0 +1,240 @@ +"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(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(null); + const [ready, setReady] = useState(false); + const mutex = useRef(new Mutex()); + const messageCallbacks = useRef< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Map void, (error: string) => void]> + >(new Map()); + const nextMessageId = useRef(0); + const isInterrupted = useRef(false); + + function postMessage({ type, payload }: MessageToWorker) { + const id = nextMessageId.current++; + return new Promise((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({ + 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; + + // Terminate the current worker + workerRef.current?.terminate(); + + // Clear pending callbacks + messageCallbacks.current.clear(); + + // Reset ready state + setReady(false); + + // Create a new worker + initializeWorker(); + + // Wait for initialization + await new Promise((resolve) => { + const checkReady = () => { + if (ready) { + resolve(); + } else { + setTimeout(checkReady, 50); + } + }; + checkReady(); + }); + + // Restore state by re-executing previous commands + await postMessage({ + type: "restoreState", + }); + + isInterrupted.current = false; + }, [initializeWorker, ready]); + + const runCommand = useCallback( + async (code: string): Promise => { + 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({ + type: "runJavaScript", + payload: { code }, + }); + return output; + } catch (error) { + // If interrupted, return a message indicating interruption + if (isInterrupted.current) { + return [{ type: "error", message: "実行が中断されました" }]; + } + throw error; + } + }, + [ready] + ); + + const checkSyntax = useCallback( + async (code: string): Promise => { + if (!workerRef.current || !ready) return "invalid"; + const { status } = await mutex.current.runExclusive(() => + postMessage({ + type: "checkSyntax", + payload: { code }, + }) + ); + return status; + }, + [ready] + ); + + const runFiles = useCallback( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async (_filenames: string[]): Promise => { + 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 ( + + {children} + + ); +} diff --git a/app/terminal/runtime.tsx b/app/terminal/runtime.tsx index 7292c19..fb94e45 100644 --- a/app/terminal/runtime.tsx +++ b/app/terminal/runtime.tsx @@ -2,6 +2,7 @@ import { MutexInterface } from "async-mutex"; import { ReplOutput, SyntaxStatus, ReplCommand } from "./repl"; import { PyodideProvider, usePyodide } from "./python/runtime"; import { useWandbox, WandboxProvider } from "./wandbox/runtime"; +import { JavaScriptProvider, useJavaScript } from "./javascript/runtime"; import { AceLang } from "./editor"; import { ReactNode } from "react"; @@ -28,7 +29,7 @@ export interface LangConstants { prompt?: string; promptMore?: string; } -export type RuntimeLang = "python" | "cpp"; +export type RuntimeLang = "python" | "cpp" | "javascript"; export function getRuntimeLang( lang: string | undefined @@ -41,6 +42,9 @@ export function getRuntimeLang( case "cpp": case "c++": return "cpp"; + case "javascript": + case "js": + return "javascript"; default: console.warn(`Unsupported language for runtime: ${lang}`); return undefined; @@ -50,12 +54,15 @@ export function useRuntime(language: RuntimeLang): RuntimeContext { // すべての言語のcontextをインスタンス化 const pyodide = usePyodide(); const wandboxCpp = useWandbox("cpp"); + const javascript = useJavaScript(); switch (language) { case "python": return pyodide; case "cpp": return wandboxCpp; + case "javascript": + return javascript; default: language satisfies never; throw new Error(`Runtime not implemented for language: ${language}`); @@ -64,7 +71,9 @@ export function useRuntime(language: RuntimeLang): RuntimeContext { export function RuntimeProvider({ children }: { children: ReactNode }) { return ( - {children} + + {children} + ); } @@ -77,6 +86,11 @@ export function langConstants(lang: RuntimeLang | AceLang): LangConstants { prompt: ">>> ", promptMore: "... ", }; + case "javascript": + return { + tabSize: 2, + prompt: "> ", + }; case "c_cpp": case "cpp": return { diff --git a/app/terminal/tests.ts b/app/terminal/tests.ts index 82620ae..d80b4a6 100644 --- a/app/terminal/tests.ts +++ b/app/terminal/tests.ts @@ -13,6 +13,7 @@ export function defineTests( { python: 2000, cpp: 10000, + javascript: 2000, } as Record )[lang] ); @@ -31,6 +32,7 @@ export function defineTests( { python: `print("${msg}")`, cpp: null, + javascript: `console.log("${msg}")`, } satisfies Record )[lang]; if (!printCode) { @@ -55,6 +57,7 @@ export function defineTests( { python: [`${varName} = ${value}`, `print(${varName})`], cpp: [null, null], + javascript: [`var ${varName} = ${value}`, `console.log(${varName})`], } satisfies Record )[lang]; if (!setIntVarCode || !printIntVarCode) { @@ -81,6 +84,7 @@ export function defineTests( { python: `raise Exception("${errorMsg}")`, cpp: null, + javascript: `throw new Error("${errorMsg}")`, } satisfies Record )[lang]; if (!errorCode) { @@ -100,6 +104,7 @@ export function defineTests( { python: [`testVar = 42`, `while True:\n pass`, `print(testVar)`], cpp: [null, null, null], + javascript: [`var testVar = 42`, `while(true) {}`, `console.log(testVar)`], } satisfies Record )[lang]; if (!setIntVarCode || !infLoopCode || !printIntVarCode) { @@ -136,8 +141,12 @@ export function defineTests( "test.cpp", `#include \nint main() {\n std::cout << "${msg}" << std::endl;\n return 0;\n}\n`, ], - } satisfies Record + javascript: [null, null], + } satisfies Record )[lang]; + if (!filename || !code) { + this.skip(); + } writeFile(filename, code); // use setTimeout to wait for writeFile to propagate. await new Promise((resolve) => setTimeout(resolve, 100)); @@ -160,8 +169,12 @@ export function defineTests( "test_error.cpp", `#include \nint main() {\n throw std::runtime_error("${errorMsg}");\n return 0;\n}\n`, ], - } satisfies Record + javascript: [null, null], + } satisfies Record )[lang]; + if (!filename || !code) { + this.skip(); + } writeFile(filename, code); await new Promise((resolve) => setTimeout(resolve, 100)); const result = await runtimeRef.current[lang].runFiles([filename]); @@ -192,8 +205,12 @@ export function defineTests( }, ["test_multi_main.cpp", "test_multi_sub.cpp"], ], - } satisfies Record, string[]]> + javascript: [null, null], + } satisfies Record, string[]] | [null, null]> )[lang]; + if (!codes || !execFiles) { + this.skip(); + } for (const [filename, code] of Object.entries(codes)) { writeFile(filename, code); } diff --git a/public/_headers b/public/_headers index 2472c04..68f3e33 100644 --- a/public/_headers +++ b/public/_headers @@ -3,3 +3,6 @@ /pyodide.worker.js Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp +/javascript.worker.js + Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Embedder-Policy: require-corp diff --git a/public/javascript.worker.js b/public/javascript.worker.js new file mode 100644 index 0000000..fd2045d --- /dev/null +++ b/public/javascript.worker.js @@ -0,0 +1,149 @@ +// JavaScript web worker +let jsOutput = []; +let executedCommands = []; // Store successfully executed commands for state recovery +let globalScope = {}; // Store global variables and functions + +// Helper function to capture console output +function createConsoleProxy() { + return { + log: (...args) => { + jsOutput.push({ type: "stdout", message: args.join(" ") }); + }, + error: (...args) => { + jsOutput.push({ type: "stderr", message: args.join(" ") }); + }, + warn: (...args) => { + jsOutput.push({ type: "stderr", message: args.join(" ") }); + }, + info: (...args) => { + jsOutput.push({ type: "stdout", message: args.join(" ") }); + }, + }; +} + +async function init(id) { + // Initialize the worker + executedCommands = []; + globalScope = {}; + self.postMessage({ id, payload: { success: true } }); +} + +async function runJavaScript(id, payload) { + const { code } = payload; + try { + // Create a console proxy to capture output + const console = createConsoleProxy(); + + // Execute the code with eval in the global scope + // Use Function constructor with global scope to maintain state across calls + const func = new Function('console', 'globalScope', ` + with (globalScope) { + return eval(${JSON.stringify(code)}); + } + `); + const result = func(console, globalScope); + + if (result !== undefined) { + jsOutput.push({ + type: "return", + message: String(result), + }); + } + + // Save the successfully executed command for state recovery + executedCommands.push(code); + } catch (e) { + console.log(e); + if (e instanceof Error) { + jsOutput.push({ + type: "error", + message: `${e.name}: ${e.message}`, + }); + } else { + jsOutput.push({ + type: "error", + message: `予期せぬエラー: ${String(e)}`, + }); + } + } + + const output = [...jsOutput]; + jsOutput = []; // Clear output + + self.postMessage({ + id, + payload: { output, updatedFiles: [] }, + }); +} + +async function checkSyntax(id, payload) { + const { code } = payload; + + try { + // Try to create a Function to check syntax + new Function(code); + self.postMessage({ id, payload: { status: "complete" } }); + } catch (e) { + // Check if it's a syntax error or if more input is expected + if (e instanceof SyntaxError) { + // Simple heuristic: check for "Unexpected end of input" + if (e.message.includes("Unexpected end of input") || + e.message.includes("expected expression")) { + self.postMessage({ id, payload: { status: "incomplete" } }); + } else { + self.postMessage({ id, payload: { status: "invalid" } }); + } + } else { + self.postMessage({ id, payload: { status: "invalid" } }); + } + } +} + +async function restoreState(id) { + // Re-execute all previously successful commands to restore state + const commandsToRestore = [...executedCommands]; + executedCommands = []; // Clear before re-executing + jsOutput = []; // Clear output for restoration + const newGlobalScope = {}; // Create a fresh global scope + + for (const command of commandsToRestore) { + try { + const console = createConsoleProxy(); + const func = new Function('console', 'globalScope', ` + with (globalScope) { + return eval(${JSON.stringify(command)}); + } + `); + func(console, newGlobalScope); + executedCommands.push(command); + } catch (e) { + // If restoration fails, we still continue with other commands + console.error("Failed to restore command:", command, e); + } + } + + globalScope = newGlobalScope; // Update the global scope + jsOutput = []; // Clear any output from restoration + self.postMessage({ id, payload: { success: true } }); +} + +self.onmessage = async (event) => { + const { id, type, payload } = event.data; + switch (type) { + case "init": + await init(id); + return; + case "runJavaScript": + await runJavaScript(id, payload); + return; + case "checkSyntax": + await checkSyntax(id, payload); + return; + case "restoreState": + await restoreState(id); + return; + default: + console.error(`Unknown message type: ${type}`); + return; + } +}; From 6d2a86ff102b8977a5995a130e21db6713cb76e3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 08:10:48 +0000 Subject: [PATCH 11/24] Fix interrupt implementation to properly wait for worker restart Co-authored-by: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> --- app/terminal/javascript/runtime.tsx | 31 ++++++++++++++--------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/app/terminal/javascript/runtime.tsx b/app/terminal/javascript/runtime.tsx index 55967c5..810ad9d 100644 --- a/app/terminal/javascript/runtime.tsx +++ b/app/terminal/javascript/runtime.tsx @@ -122,25 +122,24 @@ export function JavaScriptProvider({ children }: { children: ReactNode }) { // Create a new worker initializeWorker(); - // Wait for initialization + // Wait for initialization - use a different approach await new Promise((resolve) => { - const checkReady = () => { - if (ready) { - resolve(); - } else { - setTimeout(checkReady, 50); + const checkInterval = setInterval(() => { + if (workerRef.current) { + // Try to initialize and restore + postMessage({ + type: "restoreState", + }).then(() => { + clearInterval(checkInterval); + isInterrupted.current = false; + resolve(); + }).catch(() => { + // Keep trying + }); } - }; - checkReady(); - }); - - // Restore state by re-executing previous commands - await postMessage({ - type: "restoreState", + }, 100); }); - - isInterrupted.current = false; - }, [initializeWorker, ready]); + }, [initializeWorker]); const runCommand = useCallback( async (code: string): Promise => { From dc2e3d22a55fa2d99e8a9a176b34ffc332cf2ade Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 08:12:12 +0000 Subject: [PATCH 12/24] Improve error handling in JavaScript runtime Co-authored-by: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> --- app/terminal/javascript/runtime.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/app/terminal/javascript/runtime.tsx b/app/terminal/javascript/runtime.tsx index 810ad9d..c86bc00 100644 --- a/app/terminal/javascript/runtime.tsx +++ b/app/terminal/javascript/runtime.tsx @@ -110,12 +110,14 @@ export function JavaScriptProvider({ children }: { children: ReactNode }) { // 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(); - // Clear pending callbacks - messageCallbacks.current.clear(); - // Reset ready state setReady(false); @@ -159,11 +161,15 @@ export function JavaScriptProvider({ children }: { children: ReactNode }) { }); return output; } catch (error) { - // If interrupted, return a message indicating interruption + // If interrupted or worker was terminated, return appropriate message if (isInterrupted.current) { return [{ type: "error", message: "実行が中断されました" }]; } - throw error; + // Handle other errors + if (error instanceof Error) { + return [{ type: "error", message: error.message }]; + } + return [{ type: "error", message: String(error) }]; } }, [ready] From 6b0ede3d5100adf7d617f2a0d8001a301a2cd83e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 08:16:07 +0000 Subject: [PATCH 13/24] Add JavaScript syntax highlighting support Co-authored-by: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> --- app/terminal/highlight.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/terminal/highlight.ts b/app/terminal/highlight.ts index e5be201..2232cc3 100644 --- a/app/terminal/highlight.ts +++ b/app/terminal/highlight.ts @@ -3,13 +3,16 @@ import chalk from "chalk"; import { RuntimeLang } from "./runtime"; // Python言語定義をインポート import "prismjs/components/prism-python"; +import "prismjs/components/prism-javascript"; -type PrismLang = "python"; +type PrismLang = "python" | "javascript"; function getPrismLanguage(language: RuntimeLang): PrismLang { switch (language) { case "python": return "python"; + case "javascript": + return "javascript"; case "cpp": throw new Error( `highlight for ${language} is disabled because it should not support REPL` From 4730d8ac0e246bbe0d116edef4ae1796de848ff8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 08:19:18 +0000 Subject: [PATCH 14/24] Fix JavaScript worker to use direct eval for state persistence Co-authored-by: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> --- public/javascript.worker.js | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/public/javascript.worker.js b/public/javascript.worker.js index fd2045d..b486dcf 100644 --- a/public/javascript.worker.js +++ b/public/javascript.worker.js @@ -1,7 +1,6 @@ // JavaScript web worker let jsOutput = []; let executedCommands = []; // Store successfully executed commands for state recovery -let globalScope = {}; // Store global variables and functions // Helper function to capture console output function createConsoleProxy() { @@ -24,7 +23,6 @@ function createConsoleProxy() { async function init(id) { // Initialize the worker executedCommands = []; - globalScope = {}; self.postMessage({ id, payload: { success: true } }); } @@ -34,14 +32,9 @@ async function runJavaScript(id, payload) { // Create a console proxy to capture output const console = createConsoleProxy(); - // Execute the code with eval in the global scope - // Use Function constructor with global scope to maintain state across calls - const func = new Function('console', 'globalScope', ` - with (globalScope) { - return eval(${JSON.stringify(code)}); - } - `); - const result = func(console, globalScope); + // Execute code directly with eval in the worker global scope + // This will preserve variables across calls + const result = eval(code); if (result !== undefined) { jsOutput.push({ @@ -104,17 +97,11 @@ async function restoreState(id) { const commandsToRestore = [...executedCommands]; executedCommands = []; // Clear before re-executing jsOutput = []; // Clear output for restoration - const newGlobalScope = {}; // Create a fresh global scope for (const command of commandsToRestore) { try { const console = createConsoleProxy(); - const func = new Function('console', 'globalScope', ` - with (globalScope) { - return eval(${JSON.stringify(command)}); - } - `); - func(console, newGlobalScope); + eval(command); executedCommands.push(command); } catch (e) { // If restoration fails, we still continue with other commands @@ -122,7 +109,6 @@ async function restoreState(id) { } } - globalScope = newGlobalScope; // Update the global scope jsOutput = []; // Clear any output from restoration self.postMessage({ id, payload: { success: true } }); } From d3dd4f4dd549d48f171fa013d9c829abf25350b6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 08:21:30 +0000 Subject: [PATCH 15/24] Fix code review issues: prevent infinite recursion and add timeout to interrupt Co-authored-by: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> --- app/terminal/javascript/runtime.tsx | 18 ++++++++++++++---- public/javascript.worker.js | 6 ++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/app/terminal/javascript/runtime.tsx b/app/terminal/javascript/runtime.tsx index c86bc00..c0fc667 100644 --- a/app/terminal/javascript/runtime.tsx +++ b/app/terminal/javascript/runtime.tsx @@ -124,12 +124,22 @@ export function JavaScriptProvider({ children }: { children: ReactNode }) { // Create a new worker initializeWorker(); - // Wait for initialization - use a different approach - await new Promise((resolve) => { + // Wait for initialization with timeout + const maxRetries = 50; // 5 seconds total + let retries = 0; + + await new Promise((resolve, reject) => { const checkInterval = setInterval(() => { + retries++; + if (retries > maxRetries) { + clearInterval(checkInterval); + reject(new Error("Worker initialization timeout")); + return; + } + if (workerRef.current) { - // Try to initialize and restore - postMessage({ + // Try to restore state + postMessage<{ success: boolean }>({ type: "restoreState", }).then(() => { clearInterval(checkInterval); diff --git a/public/javascript.worker.js b/public/javascript.worker.js index b486dcf..104b391 100644 --- a/public/javascript.worker.js +++ b/public/javascript.worker.js @@ -46,7 +46,8 @@ async function runJavaScript(id, payload) { // Save the successfully executed command for state recovery executedCommands.push(code); } catch (e) { - console.log(e); + // Use self.console to avoid recursion with our console proxy + self.console.log(e); if (e instanceof Error) { jsOutput.push({ type: "error", @@ -105,7 +106,8 @@ async function restoreState(id) { executedCommands.push(command); } catch (e) { // If restoration fails, we still continue with other commands - console.error("Failed to restore command:", command, e); + // Use self.console to avoid recursion with our console proxy + self.console.error("Failed to restore command:", command, e); } } From bba92bd4f865b3645320387c1edaadd50c794307 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sat, 1 Nov 2025 04:02:53 +0900 Subject: [PATCH 16/24] =?UTF-8?q?ruby=20runtime=E3=81=AE=E3=82=A8=E3=83=A9?= =?UTF-8?q?=E3=83=BC=E3=82=92=E4=BF=AE=E6=AD=A3,=20wasip1=E6=8C=87?= =?UTF-8?q?=E5=AE=9A=E3=82=92=E6=B6=88=E3=81=97defaultrubyvm=E3=81=AB?= =?UTF-8?q?=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/terminal/ruby/runtime.tsx | 65 +++++--- app/terminal/tests.ts | 25 +++- public/ruby.worker.js | 272 +++++++++++++++++----------------- 3 files changed, 192 insertions(+), 170 deletions(-) diff --git a/app/terminal/ruby/runtime.tsx b/app/terminal/ruby/runtime.tsx index 75970c4..716b09b 100644 --- a/app/terminal/ruby/runtime.tsx +++ b/app/terminal/ruby/runtime.tsx @@ -27,7 +27,7 @@ export function useRuby(): RuntimeContext { type MessageToWorker = | { type: "init"; - payload: { RUBY_WASM_URL: string }; + payload: {}; } | { type: "runRuby"; @@ -90,13 +90,9 @@ export function RubyProvider({ children }: { children: ReactNode }) { } }; - // Use CDN URL for Ruby WASM with stdlib - const RUBY_WASM_URL = - "https://cdn.jsdelivr.net/npm/@ruby/3.3-wasm-wasi@2.7.2/dist/ruby+stdlib.wasm"; - return postMessage({ type: "init", - payload: { RUBY_WASM_URL }, + payload: {}, }); }, []); @@ -112,32 +108,39 @@ export function RubyProvider({ children }: { children: ReactNode }) { }; }, [initializeWorker]); - const interrupt = useCallback(async () => { + const interrupt = useCallback(() => { // Terminate the current worker if (workerRef.current) { workerRef.current.terminate(); } + // reject all pending messages + for (const [, [, reject]] of messageCallbacks.current) { + reject("Execution interrupted"); + } + // Mark as not ready during reinitialization setReady(false); - // Reinitialize the worker - const { success } = await initializeWorker(); - - if (success) { - // Re-execute all saved commands to restore state - for (const cmd of commandHistory.current) { - try { - await postMessage({ - type: "runRuby", - payload: { code: cmd }, - }); - } catch (e) { - console.error("Error restoring command:", cmd, e); + void mutex.current.runExclusive(async () => { + // Reinitialize the worker + const { success } = await initializeWorker(); + + if (success) { + // Re-execute all saved commands to restore state + for (const cmd of commandHistory.current) { + try { + await postMessage({ + type: "runRuby", + payload: { code: cmd }, + }); + } catch (e) { + console.error("Error restoring command:", cmd, e); + } } + setReady(true); } - setReady(true); - } + }); }, [initializeWorker]); const runCommand = useCallback( @@ -152,15 +155,22 @@ export function RubyProvider({ children }: { children: ReactNode }) { const { output, updatedFiles } = await postMessage({ type: "runRuby", payload: { code }, + }).catch((error) => { + return { + output: [ + { type: "error", message: `Execution error: ${error}` }, + ] as ReplOutput[], + updatedFiles: [] as [string, string][], + }; }); - + // Check if the command succeeded (no errors) const hasError = output.some((o) => o.type === "error"); if (!hasError) { // Save successful command to history commandHistory.current.push(code); } - + for (const [name, content] of updatedFiles) { writeFile(name, content); } @@ -201,6 +211,13 @@ export function RubyProvider({ children }: { children: ReactNode }) { await postMessage({ type: "runFile", payload: { name: filenames[0], files }, + }).catch((error) => { + return { + output: [ + { type: "error", message: `Execution error: ${error}` }, + ] as ReplOutput[], + updatedFiles: [] as [string, string][], + }; }); for (const [newName, content] of updatedFiles) { writeFile(newName, content); diff --git a/app/terminal/tests.ts b/app/terminal/tests.ts index 1233723..8cf6fae 100644 --- a/app/terminal/tests.ts +++ b/app/terminal/tests.ts @@ -12,7 +12,7 @@ export function defineTests( ( { python: 2000, - ruby: 2000, + ruby: 5000, cpp: 10000, } as Record )[lang] @@ -110,17 +110,26 @@ export function defineTests( if (!setIntVarCode || !infLoopCode || !printIntVarCode) { this.skip(); } - const result = await ( + const runPromise = ( runtimeRef.current[lang].mutex || emptyMutex ).runExclusive(async () => { await runtimeRef.current[lang].runCommand!(setIntVarCode); - const runPromise = runtimeRef.current[lang].runCommand!(infLoopCode); - // Wait a bit to ensure the infinite loop has started - await new Promise((resolve) => setTimeout(resolve, 1000)); - runtimeRef.current[lang].interrupt!(); - await runPromise; - return runtimeRef.current[lang].runCommand!(printIntVarCode); + return runtimeRef.current[lang].runCommand!(infLoopCode); }); + // Wait a bit to ensure the infinite loop has started + await new Promise((resolve) => setTimeout(resolve, 1000)); + runtimeRef.current[lang].interrupt!(); + await new Promise((resolve) => setTimeout(resolve, 100)); + await runPromise; + while (!runtimeRef.current[lang].ready) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + const result = await ( + runtimeRef.current[lang].mutex || emptyMutex + ).runExclusive(() => + runtimeRef.current[lang].runCommand!(printIntVarCode) + ); console.log(`${lang} REPL interrupt recovery test: `, result); expect(result).to.be.deep.equal([ { diff --git a/public/ruby.worker.js b/public/ruby.worker.js index 4143058..286fecf 100644 --- a/public/ruby.worker.js +++ b/public/ruby.worker.js @@ -4,119 +4,87 @@ let rubyOutput = []; let stdoutBuffer = ""; let stderrBuffer = ""; -// Helper function to read all files from the virtual file system -function readAllFiles() { - if (!rubyVM) return []; - const updatedFiles = []; - - try { - // Get list of files in the home directory - const result = rubyVM.eval(` - require 'json' - files = {} - Dir.glob('*').each do |filename| - if File.file?(filename) - files[filename] = File.read(filename) - end - end - JSON.generate(files) - `); - const filesObj = JSON.parse(result.toString()); - for (const [filename, content] of Object.entries(filesObj)) { - updatedFiles.push([filename, content]); - } - } catch (e) { - console.error("Error reading files:", e); - } - - return updatedFiles; -} +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"; + +globalThis.stdout = { + write(str) { + stdoutBuffer += str; + }, +}; +globalThis.stderr = { + write(str) { + stderrBuffer += str; + }, +}; async function init(id, payload) { - const { RUBY_WASM_URL } = payload; - + // const { } = payload; + if (!rubyVM) { try { - // Import the browser WASI shim and Ruby runtime - importScripts('https://cdn.jsdelivr.net/npm/@bjorn3/browser_wasi_shim@0.3.0/dist/index.js'); - importScripts('https://cdn.jsdelivr.net/npm/@ruby/wasm-wasi@2.7.2/dist/browser.umd.js'); - + importScripts(RUBY_JS_URL); + // Fetch and compile the Ruby WASM module - const response = await fetch(RUBY_WASM_URL); - const buffer = await response.arrayBuffer(); - const rubyModule = await WebAssembly.compile(buffer); - - const { DefaultRubyVM } = rubyWasmWasi; - - // Create custom WASI to capture stdout/stderr - const { WASI } = globalThis.WASI; - const fds = [ - { - // stdin - path: "stdin", - read: () => new Uint8Array([]), - }, - { - // stdout - path: "stdout", - write: (buffer) => { - const text = new TextDecoder().decode(buffer); - stdoutBuffer += text; - return buffer.byteLength; - }, - }, - { - // stderr - path: "stderr", - write: (buffer) => { - const text = new TextDecoder().decode(buffer); - stderrBuffer += text; - return buffer.byteLength; - }, - }, - ]; - - const wasi = new WASI([], [], fds); - const { vm } = await rubyWasmWasi.RubyVM.instantiateModule({ - module: rubyModule, - wasip1: wasi, - }); - + const rubyModule = await WebAssembly.compileStreaming( + await fetch(RUBY_WASM_URL) + ); + const { DefaultRubyVM } = globalThis["ruby-wasm-wasi"]; + const { vm } = await DefaultRubyVM(rubyModule); rubyVM = vm; - + + rubyVM.eval(` +$stdout = Object.new.tap do |obj| + def obj.write(str) + require "js" + JS.global[:stdout].write(str) + end +end +$stderr = Object.new.tap do |obj| + def obj.write(str) + require "js" + JS.global[:stderr].write(str) + end +end +`); } catch (e) { console.error("Failed to initialize Ruby VM:", e); - self.postMessage({ id, error: `Failed to initialize Ruby: ${e.message}` }); + self.postMessage({ + id, + error: `Failed to initialize Ruby: ${e.message}`, + }); return; } } - + self.postMessage({ id, payload: { success: true } }); } function flushOutput() { if (stdoutBuffer) { - const lines = stdoutBuffer.split('\n'); + const lines = stdoutBuffer.split("\n"); for (let i = 0; i < lines.length - 1; i++) { - rubyOutput.push({ type: 'stdout', message: lines[i] }); + rubyOutput.push({ type: "stdout", message: lines[i] }); } stdoutBuffer = lines[lines.length - 1]; } + // Final flush if there's remaining non-empty text + if (stdoutBuffer && stdoutBuffer.trim()) { + rubyOutput.push({ type: "stdout", message: stdoutBuffer }); + } + stdoutBuffer = ""; + if (stderrBuffer) { - const lines = stderrBuffer.split('\n'); + const lines = stderrBuffer.split("\n"); for (let i = 0; i < lines.length - 1; i++) { - rubyOutput.push({ type: 'stderr', message: lines[i] }); + rubyOutput.push({ type: "stderr", message: lines[i] }); } stderrBuffer = lines[lines.length - 1]; } - // Final flush if there's remaining non-empty text - if (stdoutBuffer && stdoutBuffer.trim()) { - rubyOutput.push({ type: 'stdout', message: stdoutBuffer }); - } - stdoutBuffer = ""; - if (stderrBuffer && stderrBuffer.trim()) { - rubyOutput.push({ type: 'stderr', message: stderrBuffer }); + rubyOutput.push({ type: "stderr", message: stderrBuffer }); } stderrBuffer = ""; } @@ -125,138 +93,138 @@ function formatRubyError(error) { if (!(error instanceof Error)) { return `予期せぬエラー: ${String(error).trim()}`; } - + let errorMessage = error.message; - + // Clean up Ruby error messages by filtering out internal eval lines - if (errorMessage.includes('Traceback') || errorMessage.includes('Error')) { - const lines = errorMessage.split('\n'); - // Keep lines that either don't contain (eval), or contain Error, or have line numbers - errorMessage = lines.filter(line => - !line.includes('(eval)') || - line.includes('Error') || - line.match(/:\d+:/) - ).join('\n').trim(); + if (errorMessage.includes("Traceback") || errorMessage.includes("Error")) { + const lines = errorMessage.split("\n"); + // Keep lines that either don't contain eval, or contain Error, or have line numbers + errorMessage = lines + .filter((line) => !line.includes("in 'Kernel.eval'")) + .join("\n") + .trim(); } - + return errorMessage; } async function runRuby(id, payload) { const { code } = payload; - + if (!rubyVM) { self.postMessage({ id, error: "Ruby VM not initialized" }); return; } - + try { rubyOutput = []; stdoutBuffer = ""; stderrBuffer = ""; - + const result = rubyVM.eval(code); - + // Flush any buffered output flushOutput(); - + const resultStr = result.toString(); - + // Add result to output if it's not nil and not empty - if (resultStr !== '' && resultStr !== 'nil') { + if (resultStr !== "" && resultStr !== "nil") { rubyOutput.push({ - type: 'return', - message: resultStr + type: "return", + message: resultStr, }); } } catch (e) { console.log(e); flushOutput(); - + rubyOutput.push({ - type: 'error', - message: formatRubyError(e) + type: "error", + message: formatRubyError(e), }); } - + const updatedFiles = readAllFiles(); const output = [...rubyOutput]; rubyOutput = []; - + self.postMessage({ id, - payload: { output, updatedFiles } + payload: { output, updatedFiles }, }); } async function runFile(id, payload) { const { name, files } = payload; - + if (!rubyVM) { self.postMessage({ id, error: "Ruby VM not initialized" }); return; } - + try { rubyOutput = []; stdoutBuffer = ""; stderrBuffer = ""; - + // Write files to the virtual file system for (const [filename, content] of Object.entries(files)) { if (content) { - rubyVM.eval(`File.write(${JSON.stringify(filename)}, ${JSON.stringify(content)})`); + rubyVM.eval( + `File.write(${JSON.stringify(filename)}, ${JSON.stringify(content)})` + ); } } - + // Run the specified file const fileContent = files[name]; if (!fileContent) { throw new Error(`File not found: ${name}`); } - + rubyVM.eval(fileContent); - + // Flush any buffered output flushOutput(); - } catch (e) { console.log(e); flushOutput(); - + rubyOutput.push({ - type: 'error', - message: formatRubyError(e) + type: "error", + message: formatRubyError(e), }); } - + const updatedFiles = readAllFiles(); const output = [...rubyOutput]; rubyOutput = []; - + self.postMessage({ id, - payload: { output, updatedFiles } + payload: { output, updatedFiles }, }); } async function checkSyntax(id, payload) { const { code } = payload; - + if (!rubyVM) { self.postMessage({ id, - payload: { status: "invalid" } + payload: { status: "invalid" }, }); return; } - + try { // 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))) { + if (incompletePatterns.some((pattern) => 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) { // If it's our check exception, syntax is valid - if (e.message && e.message.includes('check')) { + if (e.message && e.message.includes("check")) { self.postMessage({ id, payload: { status: "complete" } }); return; } @@ -289,20 +257,48 @@ async function checkSyntax(id, payload) { self.postMessage({ id, payload: { status: "invalid" } }); return; } - + self.postMessage({ id, payload: { status: "complete" } }); } catch (e) { console.error("Syntax check error:", e); self.postMessage({ id, - payload: { status: "invalid" } + payload: { status: "invalid" }, }); } } +// Helper function to read all files from the virtual file system +function readAllFiles() { + if (!rubyVM) return []; + const updatedFiles = []; + + try { + // Get list of files in the home directory + const result = rubyVM.eval(` + require 'json' + files = {} + Dir.glob('*').each do |filename| + if File.file?(filename) + files[filename] = File.read(filename) + end + end + JSON.generate(files) + `); + const filesObj = JSON.parse(result.toString()); + for (const [filename, content] of Object.entries(filesObj)) { + updatedFiles.push([filename, content]); + } + } catch (e) { + console.error("Error reading files:", e); + } + + return updatedFiles; +} + self.onmessage = async (event) => { const { id, type, payload } = event.data; - + switch (type) { case "init": await init(id, payload); From 6dc8c9f892368d276746f8165a32f94383efb0b6 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sat, 1 Nov 2025 04:04:54 +0900 Subject: [PATCH 17/24] =?UTF-8?q?eval=E8=A1=8C=E6=B6=88=E3=81=95=E3=81=AA?= =?UTF-8?q?=E3=81=8F=E3=81=A6=E3=81=84=E3=81=84=E3=82=88=E3=81=86=E3=81=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/ruby.worker.js | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/public/ruby.worker.js b/public/ruby.worker.js index 286fecf..e4d2522 100644 --- a/public/ruby.worker.js +++ b/public/ruby.worker.js @@ -94,19 +94,7 @@ function formatRubyError(error) { return `予期せぬエラー: ${String(error).trim()}`; } - let errorMessage = error.message; - - // Clean up Ruby error messages by filtering out internal eval lines - if (errorMessage.includes("Traceback") || errorMessage.includes("Error")) { - const lines = errorMessage.split("\n"); - // Keep lines that either don't contain eval, or contain Error, or have line numbers - errorMessage = lines - .filter((line) => !line.includes("in 'Kernel.eval'")) - .join("\n") - .trim(); - } - - return errorMessage; + return error.message; } async function runRuby(id, payload) { From bdfa2a1921e4de972613b779e6f6ce30eaef7b40 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 19:25:36 +0000 Subject: [PATCH 18/24] Address PR feedback: fix payload types, move executedCommands to runtime, use mutex for interrupt, remove isInterrupted Co-authored-by: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> --- app/terminal/javascript/runtime.tsx | 88 ++++++++++++----------------- public/javascript.worker.js | 15 ++--- 2 files changed, 39 insertions(+), 64 deletions(-) diff --git a/app/terminal/javascript/runtime.tsx b/app/terminal/javascript/runtime.tsx index c0fc667..3c5313a 100644 --- a/app/terminal/javascript/runtime.tsx +++ b/app/terminal/javascript/runtime.tsx @@ -26,6 +26,7 @@ export function useJavaScript(): RuntimeContext { type MessageToWorker = | { type: "init"; + payload?: undefined; } | { type: "runJavaScript"; @@ -37,6 +38,7 @@ type MessageToWorker = } | { type: "restoreState"; + payload: { commands: string[] }; }; type MessageFromWorker = @@ -59,7 +61,7 @@ export function JavaScriptProvider({ children }: { children: ReactNode }) { Map void, (error: string) => void]> >(new Map()); const nextMessageId = useRef(0); - const isInterrupted = useRef(false); + const executedCommands = useRef([]); function postMessage({ type, payload }: MessageToWorker) { const id = nextMessageId.current++; @@ -86,70 +88,52 @@ export function JavaScriptProvider({ children }: { children: ReactNode }) { } }; - postMessage({ + return postMessage({ type: "init", }).then(({ success }) => { if (success) { setReady(true); } + return worker; }); - - return worker; }, []); useEffect(() => { - const worker = initializeWorker(); + let worker: Worker | null = null; + initializeWorker().then((w) => { + worker = w; + }); return () => { - worker.terminate(); + 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((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); + await mutex.current.runExclusive(async () => { + // 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 and wait for it to be ready + await initializeWorker(); + + // Restore state by re-executing previous commands + if (executedCommands.current.length > 0) { + await postMessage<{ success: boolean }>({ + type: "restoreState", + payload: { commands: executedCommands.current }, + }); + } }); }, [initializeWorker]); @@ -169,13 +153,11 @@ export function JavaScriptProvider({ children }: { children: ReactNode }) { type: "runJavaScript", payload: { code }, }); + // Save successfully executed command + executedCommands.current.push(code); return output; } catch (error) { - // If interrupted or worker was terminated, return appropriate message - if (isInterrupted.current) { - return [{ type: "error", message: "実行が中断されました" }]; - } - // Handle other errors + // Handle errors (including "Worker interrupted") if (error instanceof Error) { return [{ type: "error", message: error.message }]; } diff --git a/public/javascript.worker.js b/public/javascript.worker.js index 104b391..e32326d 100644 --- a/public/javascript.worker.js +++ b/public/javascript.worker.js @@ -1,6 +1,5 @@ // JavaScript web worker let jsOutput = []; -let executedCommands = []; // Store successfully executed commands for state recovery // Helper function to capture console output function createConsoleProxy() { @@ -22,7 +21,6 @@ function createConsoleProxy() { async function init(id) { // Initialize the worker - executedCommands = []; self.postMessage({ id, payload: { success: true } }); } @@ -42,9 +40,6 @@ async function runJavaScript(id, payload) { message: String(result), }); } - - // Save the successfully executed command for state recovery - executedCommands.push(code); } catch (e) { // Use self.console to avoid recursion with our console proxy self.console.log(e); @@ -93,17 +88,15 @@ async function checkSyntax(id, payload) { } } -async function restoreState(id) { +async function restoreState(id, payload) { // Re-execute all previously successful commands to restore state - const commandsToRestore = [...executedCommands]; - executedCommands = []; // Clear before re-executing + const { commands } = payload; jsOutput = []; // Clear output for restoration - for (const command of commandsToRestore) { + for (const command of commands) { try { const console = createConsoleProxy(); eval(command); - executedCommands.push(command); } catch (e) { // If restoration fails, we still continue with other commands // Use self.console to avoid recursion with our console proxy @@ -128,7 +121,7 @@ self.onmessage = async (event) => { await checkSyntax(id, payload); return; case "restoreState": - await restoreState(id); + await restoreState(id, payload); return; default: console.error(`Unknown message type: ${type}`); From 8b04d28fc03e650df717a2f64bc25839996e8355 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sat, 1 Nov 2025 04:26:15 +0900 Subject: [PATCH 19/24] =?UTF-8?q?interrupt()=E3=81=AEmutex=E3=81=AB?= =?UTF-8?q?=E3=81=A4=E3=81=84=E3=81=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/terminal/README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/terminal/README.md b/app/terminal/README.md index 9e44cd4..f0044d1 100644 --- a/app/terminal/README.md +++ b/app/terminal/README.md @@ -14,8 +14,10 @@ runtime.tsx の `useRuntime(lang)` は各言語のフックを呼び出し、そ * ランタイムの初期化が完了したか、不要である場合true * mutex?: `MutexInterface` * ランタイムに排他制御が必要な場合、MutexInterfaceのインスタンスを返してください。 -* interrupt?: `() => Promise` - * 実行中のコマンドを中断します。呼び出し側でmutexのロックはされません +* interrupt?: `() => void` + * 実行中のコマンドを中断します。 + * 呼び出し側でmutexのロックはしません。interrupt()を呼ぶ際にはrunCommand()やrunFiles()が実行中であるためmutexはすでにロックされているはずです。 + * interrupt()内で実行中の処理のPromiseをrejectしたあと、runtimeを再開する際の処理に必要であればmutexをロックすることも可能です。 ### REPL用 @@ -25,7 +27,7 @@ runtime.tsx の `useRuntime(lang)` は各言語のフックを呼び出し、そ * checkSyntax?: `(code: string) => Promise` * コードの構文チェックを行います。行がコマンドとして完結していれば`complete`、次の行に続く場合(if文の条件式の途中など)は`incomplete`を返してください。 * REPLでEnterを押した際の動作に影響します。 - * 呼び出し側でmutexのロックはされません + * 呼び出し側でmutexのロックはせず、必要であればcheckSyntax()内でロックします。 * splitReplExamples?: `(code: string) => ReplCommands[]` * markdown内に記述されているREPLのサンプルコードをパースします。例えば ``` @@ -51,7 +53,7 @@ runtime.tsx の `useRuntime(lang)` は各言語のフックを呼び出し、そ * runFiles: `(filenames: string[]) => Promise` * 指定されたファイルを実行します。ファイルの中身はEmbedContextから取得されます。 - * 呼び出し側でmutexのロックはされません + * 呼び出し側でmutexのロックはせず、必要であればrunFiles()内でロックします。 * getCommandlineStr: `(filenames: string[]) => string` * 指定されたファイルを実行するためのコマンドライン引数文字列を返します。表示用です。 From 35244c81fc8d3e019af7e4e0bf19b089db2f7afb Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sat, 1 Nov 2025 04:35:07 +0900 Subject: [PATCH 20/24] =?UTF-8?q?console=E3=81=A8interrupt=E3=82=92?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/terminal/javascript/runtime.tsx | 27 +++++++++-------- public/javascript.worker.js | 47 ++++++++++++----------------- 2 files changed, 34 insertions(+), 40 deletions(-) diff --git a/app/terminal/javascript/runtime.tsx b/app/terminal/javascript/runtime.tsx index 3c5313a..ba80a90 100644 --- a/app/terminal/javascript/runtime.tsx +++ b/app/terminal/javascript/runtime.tsx @@ -109,21 +109,22 @@ export function JavaScriptProvider({ children }: { children: ReactNode }) { }; }, [initializeWorker]); - const interrupt = useCallback(async () => { + const interrupt = useCallback(() => { // Since we can't interrupt JavaScript execution directly, // we terminate the worker and restart it, then restore state - await mutex.current.runExclusive(async () => { - // 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); - + + // 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); + + mutex.current.runExclusive(async () => { // Create a new worker and wait for it to be ready await initializeWorker(); diff --git a/public/javascript.worker.js b/public/javascript.worker.js index e32326d..2bec3bc 100644 --- a/public/javascript.worker.js +++ b/public/javascript.worker.js @@ -2,22 +2,21 @@ let jsOutput = []; // Helper function to capture console output -function createConsoleProxy() { - return { - log: (...args) => { - jsOutput.push({ type: "stdout", message: args.join(" ") }); - }, - error: (...args) => { - jsOutput.push({ type: "stderr", message: args.join(" ") }); - }, - warn: (...args) => { - jsOutput.push({ type: "stderr", message: args.join(" ") }); - }, - info: (...args) => { - jsOutput.push({ type: "stdout", message: args.join(" ") }); - }, - }; -} +const originalConsole = globalThis.console; +globalThis.console = { + log: (...args) => { + jsOutput.push({ type: "stdout", message: args.join(" ") }); + }, + error: (...args) => { + jsOutput.push({ type: "stderr", message: args.join(" ") }); + }, + warn: (...args) => { + jsOutput.push({ type: "stderr", message: args.join(" ") }); + }, + info: (...args) => { + jsOutput.push({ type: "stdout", message: args.join(" ") }); + }, +}; async function init(id) { // Initialize the worker @@ -27,12 +26,9 @@ async function init(id) { async function runJavaScript(id, payload) { const { code } = payload; try { - // Create a console proxy to capture output - const console = createConsoleProxy(); - // Execute code directly with eval in the worker global scope // This will preserve variables across calls - const result = eval(code); + const result = globalThis.eval(code); if (result !== undefined) { jsOutput.push({ @@ -41,8 +37,7 @@ async function runJavaScript(id, payload) { }); } } catch (e) { - // Use self.console to avoid recursion with our console proxy - self.console.log(e); + originalConsole.log(e); if (e instanceof Error) { jsOutput.push({ type: "error", @@ -95,12 +90,10 @@ async function restoreState(id, payload) { for (const command of commands) { try { - const console = createConsoleProxy(); - eval(command); + globalThis.eval(command); } catch (e) { // If restoration fails, we still continue with other commands - // Use self.console to avoid recursion with our console proxy - self.console.error("Failed to restore command:", command, e); + originalConsole.error("Failed to restore command:", command, e); } } @@ -124,7 +117,7 @@ self.onmessage = async (event) => { await restoreState(id, payload); return; default: - console.error(`Unknown message type: ${type}`); + originalConsole.error(`Unknown message type: ${type}`); return; } }; From 46b6161903ff7d4f877e113a14eab28a791c0d0d Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sat, 1 Nov 2025 19:09:51 +0900 Subject: [PATCH 21/24] =?UTF-8?q?worker=E3=82=92=E4=BD=BF=E3=81=86runtime?= =?UTF-8?q?=E5=90=84=E7=A8=AE=E3=82=92=E3=83=AA=E3=83=95=E3=82=A1=E3=82=AF?= =?UTF-8?q?=E3=82=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/terminal/javascript/runtime.tsx | 238 ++--------------------- app/terminal/page.tsx | 3 + app/terminal/python/runtime.tsx | 230 ++-------------------- app/terminal/ruby/runtime.tsx | 275 ++------------------------ app/terminal/worker-runtime.tsx | 292 ++++++++++++++++++++++++++++ public/javascript.worker.js | 47 +++-- public/pyodide.worker.js | 15 +- public/ruby.worker.js | 44 ++++- 8 files changed, 432 insertions(+), 712 deletions(-) create mode 100644 app/terminal/worker-runtime.tsx diff --git a/app/terminal/javascript/runtime.tsx b/app/terminal/javascript/runtime.tsx index ba80a90..36c355c 100644 --- a/app/terminal/javascript/runtime.tsx +++ b/app/terminal/javascript/runtime.tsx @@ -1,238 +1,34 @@ -"use client"; +'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"; +import { ReplCommand, ReplOutput } from '../repl'; +import { createWorkerRuntime } from '../worker-runtime'; -const JavaScriptContext = createContext(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"; - payload?: undefined; - } - | { - type: "runJavaScript"; - payload: { code: string }; - } - | { - type: "checkSyntax"; - payload: { code: string }; - } - | { - type: "restoreState"; - payload: { commands: string[] }; - }; - -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(null); - const [ready, setReady] = useState(false); - const mutex = useRef(new Mutex()); - const messageCallbacks = useRef< - // eslint-disable-next-line @typescript-eslint/no-explicit-any - Map void, (error: string) => void]> - >(new Map()); - const nextMessageId = useRef(0); - const executedCommands = useRef([]); - - function postMessage({ type, payload }: MessageToWorker) { - const id = nextMessageId.current++; - return new Promise((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); - } - }; - - return postMessage({ - type: "init", - }).then(({ success }) => { - if (success) { - setReady(true); - } - return worker; - }); - }, []); - - useEffect(() => { - let worker: Worker | null = null; - initializeWorker().then((w) => { - worker = w; - }); - - return () => { - worker?.terminate(); - }; - }, [initializeWorker]); - - const interrupt = useCallback(() => { - // Since we can't interrupt JavaScript execution directly, - // we terminate the worker and restart it, then restore state - - // 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); - - mutex.current.runExclusive(async () => { - // Create a new worker and wait for it to be ready - await initializeWorker(); - - // Restore state by re-executing previous commands - if (executedCommands.current.length > 0) { - await postMessage<{ success: boolean }>({ - type: "restoreState", - payload: { commands: executedCommands.current }, - }); - } - }); - }, [initializeWorker]); - - const runCommand = useCallback( - async (code: string): Promise => { - 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({ - type: "runJavaScript", - payload: { code }, - }); - // Save successfully executed command - executedCommands.current.push(code); - return output; - } catch (error) { - // Handle errors (including "Worker interrupted") - if (error instanceof Error) { - return [{ type: "error", message: error.message }]; - } - return [{ type: "error", message: String(error) }]; - } - }, - [ready] - ); - - const checkSyntax = useCallback( - async (code: string): Promise => { - if (!workerRef.current || !ready) return "invalid"; - const { status } = await mutex.current.runExclusive(() => - postMessage({ - type: "checkSyntax", - payload: { code }, - }) - ); - return status; - }, - [ready] - ); - - const runFiles = useCallback( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async (_filenames: string[]): Promise => { - return [ - { - type: "error", - message: "JavaScript file execution is not supported in this runtime", - }, - ]; - }, - [] - ); - - const splitReplExamples = useCallback((content: string): ReplCommand[] => { +const config = { + languageName: 'JavaScript', + providerName: 'JavaScriptProvider', + workerUrl: '/javascript.worker.js', + splitReplExamples: (content: string): ReplCommand[] => { const initCommands: { command: string; output: ReplOutput[] }[] = []; - for (const line of content.split("\n")) { - if (line.startsWith("> ")) { + 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", + type: 'stdout', message: line, }); } } } return initCommands; - }, []); + }, + getCommandlineStr: (filenames: string[]) => `node ${filenames[0]}`, +}; - const getCommandlineStr = useCallback( - (filenames: string[]) => `node ${filenames[0]}`, - [] - ); +const { Provider, useRuntime } = createWorkerRuntime(config); - return ( - - {children} - - ); -} +export const JavaScriptProvider = Provider; +export const useJavaScript = useRuntime; \ No newline at end of file diff --git a/app/terminal/page.tsx b/app/terminal/page.tsx index 4bfd2d2..93a51b8 100644 --- a/app/terminal/page.tsx +++ b/app/terminal/page.tsx @@ -9,15 +9,18 @@ import { useWandbox } from "./wandbox/runtime"; import { RuntimeContext, RuntimeLang } from "./runtime"; import { useEmbedContext } from "./embedContext"; import { defineTests } from "./tests"; +import { useJavaScript } from "./javascript/runtime"; export default function RuntimeTestPage() { const pyodide = usePyodide(); const ruby = useRuby(); + const javascript = useJavaScript(); const wandboxCpp = useWandbox("cpp"); const runtimeRef = useRef>(null!); runtimeRef.current = { python: pyodide, ruby: ruby, + javascript: javascript, cpp: wandboxCpp, }; const { files, writeFile } = useEmbedContext(); diff --git a/app/terminal/python/runtime.tsx b/app/terminal/python/runtime.tsx index 84996af..af24c6b 100644 --- a/app/terminal/python/runtime.tsx +++ b/app/terminal/python/runtime.tsx @@ -1,230 +1,38 @@ -"use client"; +'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 { useEmbedContext } from "../embedContext"; -import { RuntimeContext } from "../runtime"; +import { ReplCommand, ReplOutput } from '../repl'; +import { createWorkerRuntime } from '../worker-runtime'; -const PyodideContext = createContext(null!); - -export function usePyodide(): RuntimeContext { - const context = useContext(PyodideContext); - if (!context) { - throw new Error("usePyodide must be used within a PyodideProvider"); - } - return context; -} - -type MessageToWorker = - | { - type: "init"; - payload: { interruptBuffer: Uint8Array }; - } - | { - type: "runPython"; - payload: { code: string }; - } - | { - type: "checkSyntax"; - payload: { code: string }; - } - | { - type: "runFile"; - payload: { name: string; files: Record }; - }; -type MessageFromWorker = - | { id: number; payload: unknown } - | { id: number; error: string }; -type InitPayloadFromWorker = { success: boolean }; -type RunPayloadFromWorker = { - output: ReplOutput[]; - updatedFiles: [string, string][]; // Recordではない -}; -type StatusPayloadFromWorker = { status: SyntaxStatus }; - -export function PyodideProvider({ children }: { children: ReactNode }) { - const workerRef = useRef(null); - const [ready, setReady] = useState(false); - const mutex = useRef(new Mutex()); - const { files, writeFile } = useEmbedContext(); - const messageCallbacks = useRef< - // eslint-disable-next-line @typescript-eslint/no-explicit-any - Map void, (error: string) => void]> - >(new Map()); - const nextMessageId = useRef(0); - const interruptBuffer = useRef(null); - - function postMessage({ type, payload }: MessageToWorker) { - const id = nextMessageId.current++; - return new Promise((resolve, reject) => { - messageCallbacks.current.set(id, [resolve, reject]); - workerRef.current?.postMessage({ id, type, payload }); - }); - } - - useEffect(() => { - const worker = new Worker("/pyodide.worker.js"); - workerRef.current = worker; - - interruptBuffer.current = new Uint8Array(new SharedArrayBuffer(1)); - - 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({ - type: "init", - payload: { interruptBuffer: interruptBuffer.current }, - }).then(({ success }) => { - if (success) { - setReady(true); - } - }); - - return () => { - workerRef.current?.terminate(); - }; - }, []); - - const interrupt = useCallback(() => { - if (interruptBuffer.current) { - interruptBuffer.current[0] = 2; - } - }, []); - - const runCommand = useCallback( - async (code: string): Promise => { - if (!mutex.current.isLocked()) { - throw new Error( - "mutex of PyodideContext must be locked for runCommand" - ); - } - if (!workerRef.current || !ready) { - return [{ type: "error", message: "Pyodide is not ready yet." }]; - } - - if (interruptBuffer.current) { - interruptBuffer.current[0] = 0; - } - - const { output, updatedFiles } = await postMessage({ - type: "runPython", - payload: { code }, - }); - for (const [name, content] of updatedFiles) { - writeFile(name, content); - } - return output; - }, - [ready, writeFile] - ); - - const checkSyntax = useCallback( - async (code: string): Promise => { - if (!workerRef.current || !ready) return "invalid"; - const { status } = await mutex.current.runExclusive(() => - postMessage({ - type: "checkSyntax", - payload: { code }, - }) - ); - return status; - }, - [ready] - ); - - const runFiles = useCallback( - async (filenames: string[]): Promise => { - if (filenames.length !== 1) { - return [ - { - type: "error", - message: "Python execution requires exactly one filename", - }, - ]; - } - // Incorporate runFile logic directly - if (!workerRef.current || !ready) { - return [{ type: "error", message: "Pyodide is not ready yet." }]; - } - if (interruptBuffer.current) { - 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) { - writeFile(newName, content); - } - return output; - }); - }, - [files, ready, writeFile] - ); - - const splitReplExamples = useCallback((content: string): ReplCommand[] => { +const config = { + languageName: 'Python', + providerName: 'PyodideProvider', + workerUrl: '/pyodide.worker.js', + splitReplExamples: (content: string): ReplCommand[] => { const initCommands: { command: string; output: ReplOutput[] }[] = []; - for (const line of content.split("\n")) { - if (line.startsWith(">>> ")) { + for (const line of content.split('\n')) { + if (line.startsWith('>>> ')) { // Remove the prompt from the command initCommands.push({ command: line.slice(4), output: [] }); - } else if (line.startsWith("... ")) { + } else if (line.startsWith('... ')) { if (initCommands.length > 0) { - initCommands[initCommands.length - 1].command += "\n" + line.slice(4); + initCommands[initCommands.length - 1].command += '\n' + line.slice(4); } } else { // Lines without prompt are output from the previous command if (initCommands.length > 0) { initCommands[initCommands.length - 1].output.push({ - type: "stdout", + type: 'stdout', message: line, }); } } } return initCommands; - }, []); + }, + getCommandlineStr: (filenames: string[]) => `python ${filenames[0]}`, +}; - const getCommandlineStr = useCallback( - (filenames: string[]) => `python ${filenames[0]}`, - [] - ); +const { Provider, useRuntime } = createWorkerRuntime(config); - return ( - - {children} - - ); -} +export const PyodideProvider = Provider; +export const usePyodide = useRuntime; \ No newline at end of file diff --git a/app/terminal/ruby/runtime.tsx b/app/terminal/ruby/runtime.tsx index 716b09b..d752644 100644 --- a/app/terminal/ruby/runtime.tsx +++ b/app/terminal/ruby/runtime.tsx @@ -1,276 +1,39 @@ -"use client"; +'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 { useEmbedContext } from "../embedContext"; -import { RuntimeContext } from "../runtime"; +import { ReplCommand, ReplOutput } from '../repl'; +import { createWorkerRuntime } from '../worker-runtime'; -const RubyContext = createContext(null!); - -export function useRuby(): RuntimeContext { - const context = useContext(RubyContext); - if (!context) { - throw new Error("useRuby must be used within a RubyProvider"); - } - return context; -} - -type MessageToWorker = - | { - type: "init"; - payload: {}; - } - | { - type: "runRuby"; - payload: { code: string }; - } - | { - type: "checkSyntax"; - payload: { code: string }; - } - | { - type: "runFile"; - payload: { name: string; files: Record }; - }; - -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 RubyProvider({ children }: { children: ReactNode }) { - const workerRef = useRef(null); - const [ready, setReady] = useState(false); - const mutex = useRef(new Mutex()); - const { files, writeFile } = useEmbedContext(); - const messageCallbacks = useRef< - // eslint-disable-next-line @typescript-eslint/no-explicit-any - Map void, (error: string) => void]> - >(new Map()); - const nextMessageId = useRef(0); - const commandHistory = useRef([]); - - function postMessage({ type, payload }: MessageToWorker) { - const id = nextMessageId.current++; - return new Promise((resolve, reject) => { - messageCallbacks.current.set(id, [resolve, reject]); - workerRef.current?.postMessage({ id, type, payload }); - }); - } - - const initializeWorker = useCallback(() => { - const worker = new Worker("/ruby.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); - } - }; - - return postMessage({ - type: "init", - payload: {}, - }); - }, []); - - useEffect(() => { - initializeWorker().then(({ success }) => { - if (success) { - setReady(true); - } - }); - - return () => { - workerRef.current?.terminate(); - }; - }, [initializeWorker]); - - const interrupt = useCallback(() => { - // Terminate the current worker - if (workerRef.current) { - workerRef.current.terminate(); - } - - // reject all pending messages - for (const [, [, reject]] of messageCallbacks.current) { - reject("Execution interrupted"); - } - - // Mark as not ready during reinitialization - setReady(false); - - void mutex.current.runExclusive(async () => { - // Reinitialize the worker - const { success } = await initializeWorker(); - - if (success) { - // Re-execute all saved commands to restore state - for (const cmd of commandHistory.current) { - try { - await postMessage({ - type: "runRuby", - payload: { code: cmd }, - }); - } catch (e) { - console.error("Error restoring command:", cmd, e); - } - } - setReady(true); - } - }); - }, [initializeWorker]); - - const runCommand = useCallback( - async (code: string): Promise => { - if (!mutex.current.isLocked()) { - throw new Error("mutex of RubyContext must be locked for runCommand"); - } - if (!workerRef.current || !ready) { - return [{ type: "error", message: "Ruby VM is not ready yet." }]; - } - - const { output, updatedFiles } = await postMessage({ - type: "runRuby", - payload: { code }, - }).catch((error) => { - return { - output: [ - { type: "error", message: `Execution error: ${error}` }, - ] as ReplOutput[], - updatedFiles: [] as [string, string][], - }; - }); - - // Check if the command succeeded (no errors) - const hasError = output.some((o) => o.type === "error"); - if (!hasError) { - // Save successful command to history - commandHistory.current.push(code); - } - - for (const [name, content] of updatedFiles) { - writeFile(name, content); - } - return output; - }, - [ready, writeFile] - ); - - const checkSyntax = useCallback( - async (code: string): Promise => { - if (!workerRef.current || !ready) return "invalid"; - const { status } = await mutex.current.runExclusive(() => - postMessage({ - type: "checkSyntax", - payload: { code }, - }) - ); - return status; - }, - [ready] - ); - - const runFiles = useCallback( - async (filenames: string[]): Promise => { - if (filenames.length !== 1) { - return [ - { - type: "error", - message: "Ruby execution requires exactly one filename", - }, - ]; - } - if (!workerRef.current || !ready) { - return [{ type: "error", message: "Ruby VM is not ready yet." }]; - } - return mutex.current.runExclusive(async () => { - const { output, updatedFiles } = - await postMessage({ - type: "runFile", - payload: { name: filenames[0], files }, - }).catch((error) => { - return { - output: [ - { type: "error", message: `Execution error: ${error}` }, - ] as ReplOutput[], - updatedFiles: [] as [string, string][], - }; - }); - for (const [newName, content] of updatedFiles) { - writeFile(newName, content); - } - return output; - }); - }, - [files, ready, writeFile] - ); - - const splitReplExamples = useCallback((content: string): ReplCommand[] => { +const config = { + languageName: 'Ruby', + providerName: 'RubyProvider', + workerUrl: '/ruby.worker.js', + splitReplExamples: (content: string): ReplCommand[] => { const initCommands: { command: string; output: ReplOutput[] }[] = []; - for (const line of content.split("\n")) { - if (line.startsWith(">> ")) { + for (const line of content.split('\n')) { + if (line.startsWith('>> ')) { // Ruby IRB uses >> as the prompt initCommands.push({ command: line.slice(3), output: [] }); - } else if (line.startsWith("?> ")) { + } else if (line.startsWith('?> ')) { // Ruby IRB uses ?> for continuation if (initCommands.length > 0) { - initCommands[initCommands.length - 1].command += "\n" + line.slice(3); + initCommands[initCommands.length - 1].command += '\n' + line.slice(3); } } else { // Lines without prompt are output from the previous command if (initCommands.length > 0) { initCommands[initCommands.length - 1].output.push({ - type: "stdout", + type: 'stdout', message: line, }); } } } return initCommands; - }, []); + }, + getCommandlineStr: (filenames: string[]) => `ruby ${filenames[0]}`, +}; - const getCommandlineStr = useCallback( - (filenames: string[]) => `ruby ${filenames[0]}`, - [] - ); +const { Provider, useRuntime } = createWorkerRuntime(config); - return ( - - {children} - - ); -} +export const RubyProvider = Provider; +export const useRuby = useRuntime; \ No newline at end of file diff --git a/app/terminal/worker-runtime.tsx b/app/terminal/worker-runtime.tsx new file mode 100644 index 0000000..e6961be --- /dev/null +++ b/app/terminal/worker-runtime.tsx @@ -0,0 +1,292 @@ +"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 { useEmbedContext } from "./embedContext"; +import { RuntimeContext } from "./runtime"; + +// --- Type Definitions --- + +type WorkerCapabilities = { + interrupt: "buffer" | "restart"; +}; + +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 InitPayloadFromWorker = { + success: boolean; + capabilities: WorkerCapabilities; +}; +type RunPayloadFromWorker = { + output: ReplOutput[]; + updatedFiles: [string, string][]; +}; +type StatusPayloadFromWorker = { status: SyntaxStatus }; + +export interface WorkerRuntimeConfig { + languageName: string; + providerName: string; + workerUrl: string; + splitReplExamples: (content: string) => ReplCommand[]; + getCommandlineStr: (filenames: string[]) => string; +} + +// --- Factory Function --- + +export function createWorkerRuntime(config: WorkerRuntimeConfig) { + const RuntimeContextInternal = createContext(null!); + + const useRuntime = (): RuntimeContext => { + const context = useContext(RuntimeContextInternal); + if (!context) { + throw new Error( + `use${config.languageName} must be used within a ${config.providerName}` + ); + } + return context; + }; + + const Provider = ({ children }: { children: ReactNode }) => { + const workerRef = useRef(null); + const [ready, setReady] = useState(false); + const mutex = useRef(new Mutex()); + const { files, writeFile } = useEmbedContext(); + + const messageCallbacks = useRef< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Map void, (error: string) => void]> + >(new Map()); + const nextMessageId = useRef(0); + + // Worker-specific state + const interruptBuffer = useRef(null); + const capabilities = useRef(null); + const commandHistory = useRef([]); + + // Generic postMessage + function postMessage(message: Omit & { payload: MessageToWorker['payload']}) { + const id = nextMessageId.current++; + return new Promise((resolve, reject) => { + messageCallbacks.current.set(id, [resolve, reject]); + workerRef.current?.postMessage({ id, ...message }); + }); + } + + const initializeWorker = useCallback(() => { + const worker = new Worker(config.workerUrl); + 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; + 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); + } + }; + + return postMessage({ + type: "init", + payload: { interruptBuffer: interruptBuffer.current }, + }).then((payload) => { + if (payload.success) { + capabilities.current = payload.capabilities; + setReady(true); + } + return worker; + }); + }, []); // config.workerUrl is constant + + // Initialization effect + useEffect(() => { + let worker: Worker | null = null; + initializeWorker().then((w) => { + worker = w; + }); + return () => { + worker?.terminate(); + }; + }, [initializeWorker]); + + const interrupt = useCallback(() => { + if (!capabilities.current) return; + + const method = capabilities.current.interrupt; + + if (method === "buffer" && interruptBuffer.current) { + interruptBuffer.current[0] = 2; + } else if (method === "restart") { + // Reject all pending promises + const error = "Worker interrupted"; + messageCallbacks.current.forEach(([, reject]) => reject(error)); + messageCallbacks.current.clear(); + + workerRef.current?.terminate(); + setReady(false); + + void mutex.current.runExclusive(async () => { + await initializeWorker(); + if (commandHistory.current.length > 0) { + await postMessage<{ success: boolean }>({ + type: "restoreState", + payload: { commands: commandHistory.current }, + }); + } + }); + } + }, [initializeWorker]); + + const runCommand = useCallback( + async (code: string): Promise => { + if (!mutex.current.isLocked()) { + throw new Error( + `mutex of ${config.providerName} must be locked for runCommand` + ); + } + if (!workerRef.current || !ready) { + return [ + { + type: "error", + message: `${config.languageName} runtime is not ready yet.`, + }, + ]; + } + + if ( + capabilities.current?.interrupt === "buffer" && + interruptBuffer.current + ) { + interruptBuffer.current[0] = 0; + } + + try { + const { output, updatedFiles } = + await postMessage({ + type: "runCode", + payload: { code }, + }); + + for (const [name, content] of updatedFiles) { + writeFile(name, content); + } + + // Save command to history if interrupt method is 'restart' + if (capabilities.current?.interrupt === "restart") { + const hasError = output.some((o) => o.type === "error"); + if (!hasError) { + commandHistory.current.push(code); + } + } + + return output; + } catch (error) { + if (error instanceof Error) { + return [{ type: "error", message: error.message }]; + } + return [{ type: "error", message: String(error) }]; + } + }, + [ready, writeFile] // config is constant + ); + + const checkSyntax = useCallback( + async (code: string): Promise => { + if (!workerRef.current || !ready) return "invalid"; + const { status } = await mutex.current.runExclusive(() => + postMessage({ + type: "checkSyntax", + payload: { code }, + }) + ); + return status; + }, + [ready] + ); + + const runFiles = useCallback( + async (filenames: string[]): Promise => { + if (filenames.length !== 1) { + return [ + { + type: "error", + message: `${config.languageName} execution requires exactly one filename.`, + }, + ]; + } + if (!workerRef.current || !ready) { + return [ + { + type: "error", + message: `${config.languageName} runtime is not ready yet.`, + }, + ]; + } + if ( + capabilities.current?.interrupt === "buffer" && + interruptBuffer.current + ) { + 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) { + writeFile(newName, content); + } + return output; + }); + }, + [files, ready, writeFile] // config is constant + ); + + return ( + + {children} + + ); + }; + + return { Provider, useRuntime }; +} diff --git a/public/javascript.worker.js b/public/javascript.worker.js index 2bec3bc..fcb2a02 100644 --- a/public/javascript.worker.js +++ b/public/javascript.worker.js @@ -18,18 +18,21 @@ globalThis.console = { }, }; -async function init(id) { - // Initialize the worker - self.postMessage({ id, payload: { success: true } }); +async function init(id, payload) { + // Initialize the worker and report capabilities + self.postMessage({ + id, + payload: { success: true, capabilities: { interrupt: "restart" } }, + }); } -async function runJavaScript(id, payload) { +async function runCode(id, payload) { const { code } = payload; try { // Execute code directly with eval in the worker global scope // This will preserve variables across calls const result = globalThis.eval(code); - + if (result !== undefined) { jsOutput.push({ type: "return", @@ -60,9 +63,22 @@ async function runJavaScript(id, payload) { }); } +function runFile(id, payload) { + const output = [ + { + type: "error", + message: "File execution is not supported in this runtime", + }, + ]; + self.postMessage({ + id, + payload: { output, updatedFiles: [] }, + }); +} + async function checkSyntax(id, payload) { const { code } = payload; - + try { // Try to create a Function to check syntax new Function(code); @@ -71,8 +87,10 @@ async function checkSyntax(id, payload) { // Check if it's a syntax error or if more input is expected if (e instanceof SyntaxError) { // Simple heuristic: check for "Unexpected end of input" - if (e.message.includes("Unexpected end of input") || - e.message.includes("expected expression")) { + if ( + e.message.includes("Unexpected end of input") || + e.message.includes("expected expression") + ) { self.postMessage({ id, payload: { status: "incomplete" } }); } else { self.postMessage({ id, payload: { status: "invalid" } }); @@ -87,7 +105,7 @@ async function restoreState(id, payload) { // Re-execute all previously successful commands to restore state const { commands } = payload; jsOutput = []; // Clear output for restoration - + for (const command of commands) { try { globalThis.eval(command); @@ -96,7 +114,7 @@ async function restoreState(id, payload) { originalConsole.error("Failed to restore command:", command, e); } } - + jsOutput = []; // Clear any output from restoration self.postMessage({ id, payload: { success: true } }); } @@ -105,10 +123,13 @@ self.onmessage = async (event) => { const { id, type, payload } = event.data; switch (type) { case "init": - await init(id); + await init(id, payload); + return; + case "runCode": + await runCode(id, payload); return; - case "runJavaScript": - await runJavaScript(id, payload); + case "runFile": + runFile(id, payload); return; case "checkSyntax": await checkSyntax(id, payload); diff --git a/public/pyodide.worker.js b/public/pyodide.worker.js index 2d6a714..c05dbef 100644 --- a/public/pyodide.worker.js +++ b/public/pyodide.worker.js @@ -37,13 +37,16 @@ async function init(id, payload) { pyodideOutput.push({ type: "stderr", message: str }); }, }); - + pyodide.setInterruptBuffer(interruptBuffer); } - self.postMessage({ id, payload: { success: true } }); + self.postMessage({ + id, + payload: { success: true, capabilities: { interrupt: "buffer" } }, + }); } -async function runPython(id, payload) { +async function runCode(id, payload) { const { code } = payload; if (!pyodide) { self.postMessage({ id, error: "Pyodide not initialized" }); @@ -190,8 +193,8 @@ self.onmessage = async (event) => { case "init": await init(id, payload); return; - case "runPython": - await runPython(id, payload); + case "runCode": + await runCode(id, payload); return; case "runFile": await runFile(id, payload); @@ -239,4 +242,4 @@ def __execfile(filepath): exec(compile(file.read(), filepath, 'exec'), exec_globals) __execfile -`; +`; \ No newline at end of file diff --git a/public/ruby.worker.js b/public/ruby.worker.js index e4d2522..0b3cf43 100644 --- a/public/ruby.worker.js +++ b/public/ruby.worker.js @@ -59,7 +59,10 @@ end } } - self.postMessage({ id, payload: { success: true } }); + self.postMessage({ + id, + payload: { success: true, capabilities: { interrupt: "restart" } }, + }); } function flushOutput() { @@ -97,7 +100,7 @@ function formatRubyError(error) { return error.message; } -async function runRuby(id, payload) { +async function runCode(id, payload) { const { code } = payload; if (!rubyVM) { @@ -284,6 +287,34 @@ function readAllFiles() { return updatedFiles; } +async function restoreState(id, payload) { + // Re-execute all previously successful commands to restore state + const { commands } = payload; + if (!rubyVM) { + self.postMessage({ id, error: "Ruby VM not initialized" }); + return; + } + + rubyOutput = []; // Clear output for restoration + stdoutBuffer = ""; + stderrBuffer = ""; + + for (const command of commands) { + try { + rubyVM.eval(command); + } catch (e) { + // If restoration fails, we still continue with other commands + console.error("Failed to restore command:", command, e); + } + } + + // Clear any output from restoration + flushOutput(); + rubyOutput = []; + + self.postMessage({ id, payload: { success: true } }); +} + self.onmessage = async (event) => { const { id, type, payload } = event.data; @@ -291,8 +322,8 @@ self.onmessage = async (event) => { case "init": await init(id, payload); return; - case "runRuby": - await runRuby(id, payload); + case "runCode": + await runCode(id, payload); return; case "runFile": await runFile(id, payload); @@ -300,8 +331,11 @@ self.onmessage = async (event) => { case "checkSyntax": await checkSyntax(id, payload); return; + case "restoreState": + await restoreState(id, payload); + return; default: console.error(`Unknown message type: ${type}`); return; } -}; +}; \ No newline at end of file From dfee1eb90535de480ab4af948dac89765872e658 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sat, 1 Nov 2025 20:20:07 +0900 Subject: [PATCH 22/24] =?UTF-8?q?worker/=E3=83=87=E3=82=A3=E3=83=AC?= =?UTF-8?q?=E3=82=AF=E3=83=88=E3=83=AA=E3=81=AB=E7=A7=BB=E5=8B=95=E3=81=97?= =?UTF-8?q?=E3=80=81createRuntime()=E3=81=A7=E3=81=AF=E3=81=AA=E3=81=8FPro?= =?UTF-8?q?vider=E3=82=92=E7=9B=B4=E6=8E=A5=E5=AE=9A=E7=BE=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/terminal/exec.tsx | 2 +- app/terminal/javascript/page.tsx | 21 -- app/terminal/javascript/runtime.tsx | 34 ---- app/terminal/page.tsx | 8 +- app/terminal/python/page.tsx | 23 --- app/terminal/python/runtime.tsx | 38 ---- app/terminal/ruby/page.tsx | 23 --- app/terminal/ruby/runtime.tsx | 39 ---- app/terminal/runtime.tsx | 29 +-- app/terminal/worker-runtime.tsx | 292 ---------------------------- app/terminal/worker/jsEval.ts | 38 ++++ app/terminal/worker/pyodide.ts | 46 +++++ app/terminal/worker/ruby.ts | 47 +++++ app/terminal/worker/runtime.tsx | 266 +++++++++++++++++++++++++ public/javascript.worker.js | 4 +- public/pyodide.worker.js | 2 +- public/ruby.worker.js | 4 +- 17 files changed, 422 insertions(+), 494 deletions(-) delete mode 100644 app/terminal/javascript/page.tsx delete mode 100644 app/terminal/javascript/runtime.tsx delete mode 100644 app/terminal/python/page.tsx delete mode 100644 app/terminal/python/runtime.tsx delete mode 100644 app/terminal/ruby/page.tsx delete mode 100644 app/terminal/ruby/runtime.tsx delete mode 100644 app/terminal/worker-runtime.tsx create mode 100644 app/terminal/worker/jsEval.ts create mode 100644 app/terminal/worker/pyodide.ts create mode 100644 app/terminal/worker/ruby.ts create mode 100644 app/terminal/worker/runtime.tsx diff --git a/app/terminal/exec.tsx b/app/terminal/exec.tsx index 870e816..3caa615 100644 --- a/app/terminal/exec.tsx +++ b/app/terminal/exec.tsx @@ -83,7 +83,7 @@ export function ExecFile(props: ExecProps) { ▶ 実行 - {getCommandlineStr(props.filenames)} + {getCommandlineStr?.(props.filenames)}
diff --git a/app/terminal/javascript/page.tsx b/app/terminal/javascript/page.tsx deleted file mode 100644 index 7f9861c..0000000 --- a/app/terminal/javascript/page.tsx +++ /dev/null @@ -1,21 +0,0 @@ -"use client"; - -import { EditorComponent } from "../editor"; -import { ReplTerminal } from "../repl"; - -export default function JavaScriptPage() { - return ( -
- console.log('hello, world!')\nhello, world!"} - /> - -
- ); -} diff --git a/app/terminal/javascript/runtime.tsx b/app/terminal/javascript/runtime.tsx deleted file mode 100644 index 36c355c..0000000 --- a/app/terminal/javascript/runtime.tsx +++ /dev/null @@ -1,34 +0,0 @@ -'use client'; - -import { ReplCommand, ReplOutput } from '../repl'; -import { createWorkerRuntime } from '../worker-runtime'; - -const config = { - languageName: 'JavaScript', - providerName: 'JavaScriptProvider', - workerUrl: '/javascript.worker.js', - splitReplExamples: (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; - }, - getCommandlineStr: (filenames: string[]) => `node ${filenames[0]}`, -}; - -const { Provider, useRuntime } = createWorkerRuntime(config); - -export const JavaScriptProvider = Provider; -export const useJavaScript = useRuntime; \ No newline at end of file diff --git a/app/terminal/page.tsx b/app/terminal/page.tsx index 93a51b8..fc6cb9a 100644 --- a/app/terminal/page.tsx +++ b/app/terminal/page.tsx @@ -3,18 +3,18 @@ import { Heading } from "@/[docs_id]/markdown"; import "mocha/mocha.js"; import "mocha/mocha.css"; import { useEffect, useRef, useState } from "react"; -import { usePyodide } from "./python/runtime"; -import { useRuby } from "./ruby/runtime"; import { useWandbox } from "./wandbox/runtime"; import { RuntimeContext, RuntimeLang } from "./runtime"; import { useEmbedContext } from "./embedContext"; import { defineTests } from "./tests"; -import { useJavaScript } from "./javascript/runtime"; +import { usePyodide } from "./worker/pyodide"; +import { useRuby } from "./worker/ruby"; +import { useJSEval } from "./worker/jsEval"; export default function RuntimeTestPage() { const pyodide = usePyodide(); const ruby = useRuby(); - const javascript = useJavaScript(); + const javascript = useJSEval(); const wandboxCpp = useWandbox("cpp"); const runtimeRef = useRef>(null!); runtimeRef.current = { diff --git a/app/terminal/python/page.tsx b/app/terminal/python/page.tsx deleted file mode 100644 index 093b100..0000000 --- a/app/terminal/python/page.tsx +++ /dev/null @@ -1,23 +0,0 @@ -"use client"; - -import { EditorComponent } from "../editor"; -import { ExecFile } from "../exec"; -import { ReplTerminal } from "../repl"; - -export default function PythonPage() { - return ( -
- >> print('hello, world!')\nhello, world!"} - /> - - -
- ); -} diff --git a/app/terminal/python/runtime.tsx b/app/terminal/python/runtime.tsx deleted file mode 100644 index af24c6b..0000000 --- a/app/terminal/python/runtime.tsx +++ /dev/null @@ -1,38 +0,0 @@ -'use client'; - -import { ReplCommand, ReplOutput } from '../repl'; -import { createWorkerRuntime } from '../worker-runtime'; - -const config = { - languageName: 'Python', - providerName: 'PyodideProvider', - workerUrl: '/pyodide.worker.js', - splitReplExamples: (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(4), output: [] }); - } else if (line.startsWith('... ')) { - if (initCommands.length > 0) { - initCommands[initCommands.length - 1].command += '\n' + line.slice(4); - } - } 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; - }, - getCommandlineStr: (filenames: string[]) => `python ${filenames[0]}`, -}; - -const { Provider, useRuntime } = createWorkerRuntime(config); - -export const PyodideProvider = Provider; -export const usePyodide = useRuntime; \ No newline at end of file diff --git a/app/terminal/ruby/page.tsx b/app/terminal/ruby/page.tsx deleted file mode 100644 index a986738..0000000 --- a/app/terminal/ruby/page.tsx +++ /dev/null @@ -1,23 +0,0 @@ -"use client"; - -import { EditorComponent } from "../editor"; -import { ExecFile } from "../exec"; -import { ReplTerminal } from "../repl"; - -export default function RubyPage() { - return ( -
- > puts 'hello, world!'\nhello, world!"} - /> - - -
- ); -} diff --git a/app/terminal/ruby/runtime.tsx b/app/terminal/ruby/runtime.tsx deleted file mode 100644 index d752644..0000000 --- a/app/terminal/ruby/runtime.tsx +++ /dev/null @@ -1,39 +0,0 @@ -'use client'; - -import { ReplCommand, ReplOutput } from '../repl'; -import { createWorkerRuntime } from '../worker-runtime'; - -const config = { - languageName: 'Ruby', - providerName: 'RubyProvider', - workerUrl: '/ruby.worker.js', - splitReplExamples: (content: string): ReplCommand[] => { - const initCommands: { command: string; output: ReplOutput[] }[] = []; - for (const line of content.split('\n')) { - if (line.startsWith('>> ')) { - // Ruby IRB uses >> as the prompt - initCommands.push({ command: line.slice(3), output: [] }); - } else if (line.startsWith('?> ')) { - // Ruby IRB uses ?> for continuation - if (initCommands.length > 0) { - initCommands[initCommands.length - 1].command += '\n' + line.slice(3); - } - } 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; - }, - getCommandlineStr: (filenames: string[]) => `ruby ${filenames[0]}`, -}; - -const { Provider, useRuntime } = createWorkerRuntime(config); - -export const RubyProvider = Provider; -export const useRuby = useRuntime; \ No newline at end of file diff --git a/app/terminal/runtime.tsx b/app/terminal/runtime.tsx index 2f7d624..d72c7a0 100644 --- a/app/terminal/runtime.tsx +++ b/app/terminal/runtime.tsx @@ -1,11 +1,12 @@ import { MutexInterface } from "async-mutex"; import { ReplOutput, SyntaxStatus, ReplCommand } from "./repl"; -import { PyodideProvider, usePyodide } from "./python/runtime"; -import { RubyProvider, useRuby } from "./ruby/runtime"; import { useWandbox, WandboxProvider } from "./wandbox/runtime"; -import { JavaScriptProvider, useJavaScript } from "./javascript/runtime"; import { AceLang } from "./editor"; import { ReactNode } from "react"; +import { PyodideContext, usePyodide } from "./worker/pyodide"; +import { RubyContext, useRuby } from "./worker/ruby"; +import { JSEvalContext, useJSEval } from "./worker/jsEval"; +import { WorkerProvider } from "./worker/runtime"; /** * Common runtime context interface for different languages @@ -23,7 +24,7 @@ export interface RuntimeContext { splitReplExamples?: (content: string) => ReplCommand[]; // file runFiles: (filenames: string[]) => Promise; - getCommandlineStr: (filenames: string[]) => string; + getCommandlineStr?: (filenames: string[]) => string; } export interface LangConstants { tabSize: number; @@ -58,18 +59,18 @@ export function useRuntime(language: RuntimeLang): RuntimeContext { // すべての言語のcontextをインスタンス化 const pyodide = usePyodide(); const ruby = useRuby(); + const jsEval = useJSEval(); const wandboxCpp = useWandbox("cpp"); - const javascript = useJavaScript(); switch (language) { case "python": return pyodide; case "ruby": return ruby; + case "javascript": + return jsEval; case "cpp": return wandboxCpp; - case "javascript": - return javascript; default: language satisfies never; throw new Error(`Runtime not implemented for language: ${language}`); @@ -77,13 +78,13 @@ export function useRuntime(language: RuntimeLang): RuntimeContext { } export function RuntimeProvider({ children }: { children: ReactNode }) { return ( - - - - {children} - - - + + + + {children} + + + ); } diff --git a/app/terminal/worker-runtime.tsx b/app/terminal/worker-runtime.tsx deleted file mode 100644 index e6961be..0000000 --- a/app/terminal/worker-runtime.tsx +++ /dev/null @@ -1,292 +0,0 @@ -"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 { useEmbedContext } from "./embedContext"; -import { RuntimeContext } from "./runtime"; - -// --- Type Definitions --- - -type WorkerCapabilities = { - interrupt: "buffer" | "restart"; -}; - -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 InitPayloadFromWorker = { - success: boolean; - capabilities: WorkerCapabilities; -}; -type RunPayloadFromWorker = { - output: ReplOutput[]; - updatedFiles: [string, string][]; -}; -type StatusPayloadFromWorker = { status: SyntaxStatus }; - -export interface WorkerRuntimeConfig { - languageName: string; - providerName: string; - workerUrl: string; - splitReplExamples: (content: string) => ReplCommand[]; - getCommandlineStr: (filenames: string[]) => string; -} - -// --- Factory Function --- - -export function createWorkerRuntime(config: WorkerRuntimeConfig) { - const RuntimeContextInternal = createContext(null!); - - const useRuntime = (): RuntimeContext => { - const context = useContext(RuntimeContextInternal); - if (!context) { - throw new Error( - `use${config.languageName} must be used within a ${config.providerName}` - ); - } - return context; - }; - - const Provider = ({ children }: { children: ReactNode }) => { - const workerRef = useRef(null); - const [ready, setReady] = useState(false); - const mutex = useRef(new Mutex()); - const { files, writeFile } = useEmbedContext(); - - const messageCallbacks = useRef< - // eslint-disable-next-line @typescript-eslint/no-explicit-any - Map void, (error: string) => void]> - >(new Map()); - const nextMessageId = useRef(0); - - // Worker-specific state - const interruptBuffer = useRef(null); - const capabilities = useRef(null); - const commandHistory = useRef([]); - - // Generic postMessage - function postMessage(message: Omit & { payload: MessageToWorker['payload']}) { - const id = nextMessageId.current++; - return new Promise((resolve, reject) => { - messageCallbacks.current.set(id, [resolve, reject]); - workerRef.current?.postMessage({ id, ...message }); - }); - } - - const initializeWorker = useCallback(() => { - const worker = new Worker(config.workerUrl); - 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; - 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); - } - }; - - return postMessage({ - type: "init", - payload: { interruptBuffer: interruptBuffer.current }, - }).then((payload) => { - if (payload.success) { - capabilities.current = payload.capabilities; - setReady(true); - } - return worker; - }); - }, []); // config.workerUrl is constant - - // Initialization effect - useEffect(() => { - let worker: Worker | null = null; - initializeWorker().then((w) => { - worker = w; - }); - return () => { - worker?.terminate(); - }; - }, [initializeWorker]); - - const interrupt = useCallback(() => { - if (!capabilities.current) return; - - const method = capabilities.current.interrupt; - - if (method === "buffer" && interruptBuffer.current) { - interruptBuffer.current[0] = 2; - } else if (method === "restart") { - // Reject all pending promises - const error = "Worker interrupted"; - messageCallbacks.current.forEach(([, reject]) => reject(error)); - messageCallbacks.current.clear(); - - workerRef.current?.terminate(); - setReady(false); - - void mutex.current.runExclusive(async () => { - await initializeWorker(); - if (commandHistory.current.length > 0) { - await postMessage<{ success: boolean }>({ - type: "restoreState", - payload: { commands: commandHistory.current }, - }); - } - }); - } - }, [initializeWorker]); - - const runCommand = useCallback( - async (code: string): Promise => { - if (!mutex.current.isLocked()) { - throw new Error( - `mutex of ${config.providerName} must be locked for runCommand` - ); - } - if (!workerRef.current || !ready) { - return [ - { - type: "error", - message: `${config.languageName} runtime is not ready yet.`, - }, - ]; - } - - if ( - capabilities.current?.interrupt === "buffer" && - interruptBuffer.current - ) { - interruptBuffer.current[0] = 0; - } - - try { - const { output, updatedFiles } = - await postMessage({ - type: "runCode", - payload: { code }, - }); - - for (const [name, content] of updatedFiles) { - writeFile(name, content); - } - - // Save command to history if interrupt method is 'restart' - if (capabilities.current?.interrupt === "restart") { - const hasError = output.some((o) => o.type === "error"); - if (!hasError) { - commandHistory.current.push(code); - } - } - - return output; - } catch (error) { - if (error instanceof Error) { - return [{ type: "error", message: error.message }]; - } - return [{ type: "error", message: String(error) }]; - } - }, - [ready, writeFile] // config is constant - ); - - const checkSyntax = useCallback( - async (code: string): Promise => { - if (!workerRef.current || !ready) return "invalid"; - const { status } = await mutex.current.runExclusive(() => - postMessage({ - type: "checkSyntax", - payload: { code }, - }) - ); - return status; - }, - [ready] - ); - - const runFiles = useCallback( - async (filenames: string[]): Promise => { - if (filenames.length !== 1) { - return [ - { - type: "error", - message: `${config.languageName} execution requires exactly one filename.`, - }, - ]; - } - if (!workerRef.current || !ready) { - return [ - { - type: "error", - message: `${config.languageName} runtime is not ready yet.`, - }, - ]; - } - if ( - capabilities.current?.interrupt === "buffer" && - interruptBuffer.current - ) { - 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) { - writeFile(newName, content); - } - return output; - }); - }, - [files, ready, writeFile] // config is constant - ); - - return ( - - {children} - - ); - }; - - return { Provider, useRuntime }; -} diff --git a/app/terminal/worker/jsEval.ts b/app/terminal/worker/jsEval.ts new file mode 100644 index 0000000..0023a9f --- /dev/null +++ b/app/terminal/worker/jsEval.ts @@ -0,0 +1,38 @@ +"use client"; + +import { createContext, useContext } from "react"; +import { RuntimeContext } from "../runtime"; +import { ReplCommand, ReplOutput } from "../repl"; + +export const JSEvalContext = createContext(null!); + +export function useJSEval() { + const context = useContext(JSEvalContext); + if (!context) { + throw new Error("useJSEval must be used within a JSEvalProvider"); + } + return { + ...context, + splitReplExamples, + // getCommandlineStr, + }; +} + +function splitReplExamples(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; +} diff --git a/app/terminal/worker/pyodide.ts b/app/terminal/worker/pyodide.ts new file mode 100644 index 0000000..eb616b7 --- /dev/null +++ b/app/terminal/worker/pyodide.ts @@ -0,0 +1,46 @@ +"use client"; + +import { createContext, useContext } from "react"; +import { RuntimeContext } from "../runtime"; +import { ReplCommand, ReplOutput } from "../repl"; + +export const PyodideContext = createContext(null!); + +export function usePyodide() { + const context = useContext(PyodideContext); + if (!context) { + throw new Error("usePyodide must be used within a PyodideProvider"); + } + return { + ...context, + splitReplExamples, + getCommandlineStr, + }; +} + +function splitReplExamples(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(4), output: [] }); + } else if (line.startsWith("... ")) { + if (initCommands.length > 0) { + initCommands[initCommands.length - 1].command += "\n" + line.slice(4); + } + } 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; +} + +function getCommandlineStr(filenames: string[]) { + return `python ${filenames[0]}`; +} diff --git a/app/terminal/worker/ruby.ts b/app/terminal/worker/ruby.ts new file mode 100644 index 0000000..a8e1ff2 --- /dev/null +++ b/app/terminal/worker/ruby.ts @@ -0,0 +1,47 @@ +"use client"; + +import { createContext, useContext } from "react"; +import { RuntimeContext } from "../runtime"; +import { ReplCommand, ReplOutput } from "../repl"; + +export const RubyContext = createContext(null!); + +export function useRuby() { + const context = useContext(RubyContext); + if (!context) { + throw new Error("useRuby must be used within a RubyProvider"); + } + return { + ...context, + splitReplExamples, + getCommandlineStr, + }; +} + +function splitReplExamples(content: string): ReplCommand[] { + const initCommands: { command: string; output: ReplOutput[] }[] = []; + for (const line of content.split("\n")) { + if (line.startsWith(">> ")) { + // Ruby IRB uses >> as the prompt + initCommands.push({ command: line.slice(3), output: [] }); + } else if (line.startsWith("?> ")) { + // Ruby IRB uses ?> for continuation + if (initCommands.length > 0) { + initCommands[initCommands.length - 1].command += "\n" + line.slice(3); + } + } 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; +} + +function getCommandlineStr(filenames: string[]) { + return `ruby ${filenames[0]}`; +} diff --git a/app/terminal/worker/runtime.tsx b/app/terminal/worker/runtime.tsx new file mode 100644 index 0000000..78ed53f --- /dev/null +++ b/app/terminal/worker/runtime.tsx @@ -0,0 +1,266 @@ +"use client"; + +import { + Context, + ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { RuntimeContext } 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 WorkerCapabilities = { + interrupt: "buffer" | "restart"; +}; +type InitPayloadFromWorker = { + capabilities: WorkerCapabilities; +}; +type RunPayloadFromWorker = { + output: ReplOutput[]; + updatedFiles: [string, string][]; +}; +type StatusPayloadFromWorker = { status: SyntaxStatus }; + +export function WorkerProvider({ + children, + context, + script, +}: { + children: ReactNode; + context: Context; + script: string; +}) { + const workerRef = useRef(null); + const [ready, setReady] = useState(false); + const mutex = useRef(new Mutex()); + const { files, writeFile } = useEmbedContext(); + + const messageCallbacks = useRef< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Map void, (error: string) => void]> + >(new Map()); + const nextMessageId = useRef(0); + + // Worker-specific state + const interruptBuffer = useRef(null); + const capabilities = useRef(null); + const commandHistory = useRef([]); + + // Generic postMessage + function postMessage(message: MessageToWorker) { + const id = nextMessageId.current++; + return new Promise((resolve, reject) => { + messageCallbacks.current.set(id, [resolve, reject]); + workerRef.current?.postMessage({ id, ...message }); + }); + } + + const initializeWorker = useCallback(async () => { + const worker = new Worker(script); + 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; + 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); + } + }; + + return postMessage({ + type: "init", + payload: { interruptBuffer: interruptBuffer.current }, + }).then((payload) => { + capabilities.current = payload.capabilities; + }); + }, [script]); + + // Initialization effect + useEffect(() => { + initializeWorker().then(() => setReady(true)); + return () => { + workerRef.current?.terminate(); + }; + }, [initializeWorker]); + + const interrupt = useCallback(() => { + if (!capabilities.current) return; + + switch (capabilities.current?.interrupt) { + case "buffer": + if (interruptBuffer.current) { + interruptBuffer.current[0] = 2; + } + break; + case "restart": { + // Reject all pending promises + const error = "Worker interrupted"; + messageCallbacks.current.forEach(([, reject]) => reject(error)); + messageCallbacks.current.clear(); + + workerRef.current?.terminate(); + setReady(false); + + void mutex.current.runExclusive(async () => { + await initializeWorker(); + if (commandHistory.current.length > 0) { + await postMessage({ + type: "restoreState", + payload: { commands: commandHistory.current }, + }); + } + setReady(true); + }); + break; + } + default: + capabilities.current?.interrupt satisfies never; + break; + } + }, [initializeWorker]); + + const runCommand = useCallback( + async (code: string): Promise => { + if (!mutex.current.isLocked()) { + throw new Error(`mutex of context must be locked for runCommand`); + } + if (!workerRef.current || !ready) { + return [ + { + type: "error", + message: `worker runtime is not ready yet.`, + }, + ]; + } + + if ( + capabilities.current?.interrupt === "buffer" && + interruptBuffer.current + ) { + interruptBuffer.current[0] = 0; + } + + try { + const { output, updatedFiles } = + await postMessage({ + type: "runCode", + payload: { code }, + }); + + for (const [name, content] of updatedFiles) { + writeFile(name, content); + } + + // Save command to history if interrupt method is 'restart' + if (capabilities.current?.interrupt === "restart") { + const hasError = output.some((o) => o.type === "error"); + if (!hasError) { + commandHistory.current.push(code); + } + } + + return output; + } catch (error) { + if (error instanceof Error) { + return [{ type: "error", message: error.message }]; + } + return [{ type: "error", message: String(error) }]; + } + }, + [ready, writeFile] + ); + + const checkSyntax = useCallback( + async (code: string): Promise => { + if (!workerRef.current || !ready) return "invalid"; + const { status } = await mutex.current.runExclusive(() => + postMessage({ + type: "checkSyntax", + payload: { code }, + }) + ); + return status; + }, + [ready] + ); + + const runFiles = useCallback( + async (filenames: string[]): Promise => { + if (filenames.length !== 1) { + return [ + { + type: "error", + message: `worker runtime requires exactly one filename.`, + }, + ]; + } + if (!workerRef.current || !ready) { + return [ + { + type: "error", + message: `worker runtime is not ready yet.`, + }, + ]; + } + if ( + capabilities.current?.interrupt === "buffer" && + interruptBuffer.current + ) { + 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) { + writeFile(newName, content); + } + return output; + }); + }, + [files, ready, writeFile] + ); + + return ( + + {children} + + ); +} diff --git a/public/javascript.worker.js b/public/javascript.worker.js index fcb2a02..98a59c0 100644 --- a/public/javascript.worker.js +++ b/public/javascript.worker.js @@ -22,7 +22,7 @@ async function init(id, payload) { // Initialize the worker and report capabilities self.postMessage({ id, - payload: { success: true, capabilities: { interrupt: "restart" } }, + payload: { capabilities: { interrupt: "restart" } }, }); } @@ -116,7 +116,7 @@ async function restoreState(id, payload) { } jsOutput = []; // Clear any output from restoration - self.postMessage({ id, payload: { success: true } }); + self.postMessage({ id, payload: {} }); } self.onmessage = async (event) => { diff --git a/public/pyodide.worker.js b/public/pyodide.worker.js index c05dbef..6135577 100644 --- a/public/pyodide.worker.js +++ b/public/pyodide.worker.js @@ -42,7 +42,7 @@ async function init(id, payload) { } self.postMessage({ id, - payload: { success: true, capabilities: { interrupt: "buffer" } }, + payload: { capabilities: { interrupt: "buffer" } }, }); } diff --git a/public/ruby.worker.js b/public/ruby.worker.js index 0b3cf43..94271a4 100644 --- a/public/ruby.worker.js +++ b/public/ruby.worker.js @@ -61,7 +61,7 @@ end self.postMessage({ id, - payload: { success: true, capabilities: { interrupt: "restart" } }, + payload: { capabilities: { interrupt: "restart" } }, }); } @@ -312,7 +312,7 @@ async function restoreState(id, payload) { flushOutput(); rubyOutput = []; - self.postMessage({ id, payload: { success: true } }); + self.postMessage({ id, payload: {} }); } self.onmessage = async (event) => { From d3394fff4a3e13acf676a59b8b2a57d45fe6d623 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sat, 1 Nov 2025 20:24:56 +0900 Subject: [PATCH 23/24] update readme --- app/terminal/README.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/app/terminal/README.md b/app/terminal/README.md index f0044d1..fd4329c 100644 --- a/app/terminal/README.md +++ b/app/terminal/README.md @@ -124,11 +124,20 @@ EditorComponent コンポーネントを提供します。 ## 各言語の実装 -### Pyodide (Python) +### Worker -Pyodide を web worker で動かしています。worker側のスクリプトは /public/python.worker.js にあります。 +web worker でコードを実行する実装です。worker側のスクリプトは /public にあります。 +workerとの通信部分は言語によらず共通なので、それをworker/runtime.tsxで定義しています。 +Contextは言語ごとに分けて(worker/pyodide.ts などで)定義しています。 -### Wandbox (C++) +Pythonの実行環境にはPyodideを使用しています。 +PyodideにはKeyboardInterruptを送信する機能があるのでinterrupt()でそれを利用しています。 + +Rubyの実行環境にはruby.wasmを使用しています。 + +JavaScriptはeval()を使用しています。runFiles()のAPIだけ実装していません。 + +### Wandbox wandbox.org のAPIを利用してC++コードを実行しています。C++以外にもいろいろな言語に対応しています。 From 6644621ad67d8a6213acc3b27c6861d52952c723 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sat, 1 Nov 2025 21:04:27 +0900 Subject: [PATCH 24/24] =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E3=83=9A?= =?UTF-8?q?=E3=83=BC=E3=82=B8=E3=82=92terminal/page.tsx=E3=81=AB=E7=B5=B1?= =?UTF-8?q?=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/terminal/page.tsx | 222 +++++++++++++++++++++++++--------- app/terminal/wandbox/page.tsx | 31 ----- 2 files changed, 163 insertions(+), 90 deletions(-) delete mode 100644 app/terminal/wandbox/page.tsx diff --git a/app/terminal/page.tsx b/app/terminal/page.tsx index fc6cb9a..94bace1 100644 --- a/app/terminal/page.tsx +++ b/app/terminal/page.tsx @@ -2,7 +2,7 @@ import { Heading } from "@/[docs_id]/markdown"; import "mocha/mocha.js"; import "mocha/mocha.css"; -import { useEffect, useRef, useState } from "react"; +import { Fragment, useEffect, useRef, useState } from "react"; import { useWandbox } from "./wandbox/runtime"; import { RuntimeContext, RuntimeLang } from "./runtime"; import { useEmbedContext } from "./embedContext"; @@ -10,8 +10,116 @@ import { defineTests } from "./tests"; import { usePyodide } from "./worker/pyodide"; import { useRuby } from "./worker/ruby"; import { useJSEval } from "./worker/jsEval"; +import { ReplTerminal } from "./repl"; +import { EditorComponent, getAceLang } from "./editor"; +import { ExecFile } from "./exec"; export default function RuntimeTestPage() { + return ( +
+ Runtime Test Page + + REPLとコード実行のサンプル + {/* name of each tab group should be unique */} +
+ {Object.entries(sampleConfig).map(([lang, config]) => ( + + +
+ +
+
+ ))} +
+ + 自動テスト + +
+ ); +} + +interface SampleConfig { + repl: boolean; + replInitContent?: string; // ReplOutput[] ではない。stringのパースはruntimeが行う + editor: Record | false; + exec: string[] | false; +} +const sampleConfig: Record = { + python: { + repl: true, + replInitContent: '>>> print("Hello, World!")\nHello, World!', + editor: { + "main.py": 'print("Hello, World!")', + }, + exec: ["main.py"], + }, + ruby: { + repl: true, + replInitContent: '>> puts "Hello, World!"\nHello, World!', + editor: { + "main.rb": 'puts "Hello, World!"', + }, + exec: ["main.rb"], + }, + javascript: { + repl: true, + replInitContent: '> console.log("Hello, World!");\nHello, World!', + editor: false, + exec: false, + }, + cpp: { + repl: false, + editor: { + "main.cpp": `#include +#include "sub.h" + +int main() { + std::cout << "Hello, World!" << std::endl; +}`, + "sub.h": ``, + "sub.cpp": ``, + }, + exec: ["main.cpp", "sub.cpp"], + }, +}; +function RuntimeSample({ + lang, + config, +}: { + lang: RuntimeLang; + config: SampleConfig; +}) { + return ( +
+ {config.repl && ( + + )} + {config.editor && + Object.entries(config.editor).map(([filename, initContent]) => ( + + ))} + {config.exec && ( + + )} +
+ ); +} + +function MochaTest() { const pyodide = usePyodide(); const ruby = useRuby(); const javascript = useJSEval(); @@ -23,16 +131,15 @@ export default function RuntimeTestPage() { javascript: javascript, cpp: wandboxCpp, }; - const { files, writeFile } = useEmbedContext(); - const filesRef = useRef>({}); - filesRef.current = files; - const [mochaState, setMochaState] = useState<"idle" | "running" | "finished">( - "idle" - ); + const [searchParams, setSearchParams] = useState(""); useEffect(() => { setSearchParams(window.location.search); }, []); + const [mochaState, setMochaState] = useState<"idle" | "running" | "finished">( + "idle" + ); + const { writeFile } = useEmbedContext(); const runTest = () => { setMochaState("running"); @@ -50,59 +157,56 @@ export default function RuntimeTestPage() { }; return ( -
- Runtime Test Page -
- {/* margin collapseさせない & fixedの対象をviewportではなくこのdivにする */} - {mochaState === "idle" ? ( - - ) : mochaState === "running" ? ( -
- - テストを実行中です... -
- ) : ( -
- - - - テストが完了しました -
+
+ {/* margin collapseさせない & fixedの対象をviewportではなくこのdivにする */} + {mochaState === "idle" ? ( + + ) : mochaState === "running" ? ( +
+ + テストを実行中です... +
+ ) : ( +
+ + + + テストが完了しました +
+ )} +

+ {new URLSearchParams(searchParams).has("grep") && ( + <> + 一部のテストだけを実行します: + + {new URLSearchParams(searchParams).get("grep")} + + {/* eslint-disable-next-line @next/next/no-html-link-for-pages */} + + {/* aタグでページをリロードしないと動作しない。 */} + フィルタを解除 + + )} -

- {new URLSearchParams(searchParams).has("grep") && ( - <> - 一部のテストだけを実行します: - - {new URLSearchParams(searchParams).get("grep")} - - {/* eslint-disable-next-line @next/next/no-html-link-for-pages */} - - {/* aタグでページをリロードしないと動作しない。 */} - フィルタを解除 - - - )} -

-
-
+

+
); } diff --git a/app/terminal/wandbox/page.tsx b/app/terminal/wandbox/page.tsx deleted file mode 100644 index 86677f4..0000000 --- a/app/terminal/wandbox/page.tsx +++ /dev/null @@ -1,31 +0,0 @@ -"use client"; - -import { EditorComponent } from "../editor"; -import { ExecFile } from "../exec"; - -export default function WandboxPage() { - return ( -
- - - - -
- ); -}