diff --git a/app/terminal/README.md b/app/terminal/README.md index 9e44cd4..fd4329c 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` * 指定されたファイルを実行するためのコマンドライン引数文字列を返します。表示用です。 @@ -122,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++以外にもいろいろな言語に対応しています。 diff --git a/app/terminal/editor.tsx b/app/terminal/editor.tsx index 0b8f932..386587d 100644 --- a/app/terminal/editor.tsx +++ b/app/terminal/editor.tsx @@ -11,7 +11,9 @@ 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-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,16 +30,22 @@ 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" | "javascript" | "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"; + case "javascript": + case "js": + return "javascript"; case "json": return "json"; case "csv": 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/highlight.ts b/app/terminal/highlight.ts index e5be201..5bc7069 100644 --- a/app/terminal/highlight.ts +++ b/app/terminal/highlight.ts @@ -1,15 +1,21 @@ import Prism from "prismjs"; import chalk from "chalk"; import { RuntimeLang } from "./runtime"; -// Python言語定義をインポート +// 言語定義をインポート import "prismjs/components/prism-python"; +import "prismjs/components/prism-ruby"; +import "prismjs/components/prism-javascript"; -type PrismLang = "python"; +type PrismLang = "python" | "ruby" | "javascript"; function getPrismLanguage(language: RuntimeLang): PrismLang { switch (language) { case "python": return "python"; + case "ruby": + return "ruby"; + case "javascript": + return "javascript"; case "cpp": throw new Error( `highlight for ${language} is disabled because it should not support REPL` diff --git a/app/terminal/page.tsx b/app/terminal/page.tsx index 857523b..94bace1 100644 --- a/app/terminal/page.tsx +++ b/app/terminal/page.tsx @@ -2,31 +2,144 @@ 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 { Fragment, useEffect, useRef, useState } from "react"; import { useWandbox } from "./wandbox/runtime"; import { RuntimeContext, RuntimeLang } from "./runtime"; import { useEmbedContext } from "./embedContext"; 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(); const wandboxCpp = useWandbox("cpp"); const runtimeRef = useRef>(null!); runtimeRef.current = { python: pyodide, + ruby: ruby, + 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"); @@ -44,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/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 84996af..0000000 --- a/app/terminal/python/runtime.tsx +++ /dev/null @@ -1,230 +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"; - -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 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; - }, []); - - const getCommandlineStr = useCallback( - (filenames: string[]) => `python ${filenames[0]}`, - [] - ); - - return ( - - {children} - - ); -} diff --git a/app/terminal/runtime.tsx b/app/terminal/runtime.tsx index 7292c19..d72c7a0 100644 --- a/app/terminal/runtime.tsx +++ b/app/terminal/runtime.tsx @@ -1,9 +1,12 @@ import { MutexInterface } from "async-mutex"; import { ReplOutput, SyntaxStatus, ReplCommand } from "./repl"; -import { PyodideProvider, usePyodide } from "./python/runtime"; import { useWandbox, WandboxProvider } from "./wandbox/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 @@ -21,14 +24,14 @@ 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; prompt?: string; promptMore?: string; } -export type RuntimeLang = "python" | "cpp"; +export type RuntimeLang = "python" | "ruby" | "cpp" | "javascript"; export function getRuntimeLang( lang: string | undefined @@ -38,9 +41,15 @@ export function getRuntimeLang( case "python": case "py": return "python"; + case "ruby": + case "rb": + return "ruby"; case "cpp": case "c++": return "cpp"; + case "javascript": + case "js": + return "javascript"; default: console.warn(`Unsupported language for runtime: ${lang}`); return undefined; @@ -49,11 +58,17 @@ export function getRuntimeLang( export function useRuntime(language: RuntimeLang): RuntimeContext { // すべての言語のcontextをインスタンス化 const pyodide = usePyodide(); + const ruby = useRuby(); + const jsEval = useJSEval(); const wandboxCpp = useWandbox("cpp"); switch (language) { case "python": return pyodide; + case "ruby": + return ruby; + case "javascript": + return jsEval; case "cpp": return wandboxCpp; default: @@ -63,9 +78,13 @@ export function useRuntime(language: RuntimeLang): RuntimeContext { } export function RuntimeProvider({ children }: { children: ReactNode }) { return ( - - {children} - + + + + {children} + + + ); } @@ -77,6 +96,17 @@ export function langConstants(lang: RuntimeLang | AceLang): LangConstants { prompt: ">>> ", promptMore: "... ", }; + case "ruby": + return { + tabSize: 2, + 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..52fbc70 100644 --- a/app/terminal/tests.ts +++ b/app/terminal/tests.ts @@ -12,7 +12,9 @@ export function defineTests( ( { python: 2000, + ruby: 5000, cpp: 10000, + javascript: 2000, } as Record )[lang] ); @@ -30,7 +32,9 @@ export function defineTests( const printCode = ( { python: `print("${msg}")`, + ruby: `puts "${msg}"`, cpp: null, + javascript: `console.log("${msg}")`, } satisfies Record )[lang]; if (!printCode) { @@ -54,7 +58,9 @@ export function defineTests( const [setIntVarCode, printIntVarCode] = ( { python: [`${varName} = ${value}`, `print(${varName})`], + ruby: [`${varName} = ${value}`, `puts ${varName}`], cpp: [null, null], + javascript: [`var ${varName} = ${value}`, `console.log(${varName})`], } satisfies Record )[lang]; if (!setIntVarCode || !printIntVarCode) { @@ -80,7 +86,9 @@ export function defineTests( const errorCode = ( { python: `raise Exception("${errorMsg}")`, + ruby: `raise "${errorMsg}"`, cpp: null, + javascript: `throw new Error("${errorMsg}")`, } satisfies Record )[lang]; if (!errorCode) { @@ -99,23 +107,34 @@ 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], + javascript: [`var testVar = 42`, `while(true) {}`, `console.log(testVar)`], } satisfies Record )[lang]; 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([ { @@ -132,12 +151,17 @@ 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`, ], - } 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)); @@ -156,12 +180,17 @@ 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`, ], - } 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]); @@ -183,6 +212,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": @@ -192,8 +229,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/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 ( -
- - - - -
- ); -} 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/_headers b/public/_headers index 2472c04..2abdfb7 100644 --- a/public/_headers +++ b/public/_headers @@ -1,5 +1,5 @@ /_next/static/* Cache-Control: public,max-age=31536000,immutable -/pyodide.worker.js +/*.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..98a59c0 --- /dev/null +++ b/public/javascript.worker.js @@ -0,0 +1,144 @@ +// JavaScript web worker +let jsOutput = []; + +// Helper function to capture console output +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, payload) { + // Initialize the worker and report capabilities + self.postMessage({ + id, + payload: { capabilities: { interrupt: "restart" } }, + }); +} + +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", + message: String(result), + }); + } + } catch (e) { + originalConsole.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: [] }, + }); +} + +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); + 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, 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); + } catch (e) { + // If restoration fails, we still continue with other commands + originalConsole.error("Failed to restore command:", command, e); + } + } + + jsOutput = []; // Clear any output from restoration + self.postMessage({ id, payload: {} }); +} + +self.onmessage = async (event) => { + const { id, type, payload } = event.data; + switch (type) { + case "init": + await init(id, payload); + return; + case "runCode": + await runCode(id, payload); + return; + case "runFile": + runFile(id, payload); + return; + case "checkSyntax": + await checkSyntax(id, payload); + return; + case "restoreState": + await restoreState(id, payload); + return; + default: + originalConsole.error(`Unknown message type: ${type}`); + return; + } +}; diff --git a/public/pyodide.worker.js b/public/pyodide.worker.js index 2d6a714..6135577 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: { 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 new file mode 100644 index 0000000..94271a4 --- /dev/null +++ b/public/ruby.worker.js @@ -0,0 +1,341 @@ +// Ruby.wasm web worker +let rubyVM = null; +let rubyOutput = []; +let stdoutBuffer = ""; +let stderrBuffer = ""; + +const RUBY_JS_URL = + "https://cdn.jsdelivr.net/npm/@ruby/wasm-wasi@2.7.2/dist/browser.umd.js"; +const RUBY_WASM_URL = + "https://cdn.jsdelivr.net/npm/@ruby/3.4-wasm-wasi@2.7.2/dist/ruby+stdlib.wasm"; + +globalThis.stdout = { + write(str) { + stdoutBuffer += str; + }, +}; +globalThis.stderr = { + write(str) { + stderrBuffer += str; + }, +}; + +async function init(id, payload) { + // const { } = payload; + + if (!rubyVM) { + try { + importScripts(RUBY_JS_URL); + + // Fetch and compile the Ruby WASM module + const rubyModule = await WebAssembly.compileStreaming( + await fetch(RUBY_WASM_URL) + ); + const { 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}`, + }); + return; + } + } + + self.postMessage({ + id, + payload: { capabilities: { interrupt: "restart" } }, + }); +} + +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]; + } + // 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"); + for (let i = 0; i < lines.length - 1; i++) { + rubyOutput.push({ type: "stderr", message: lines[i] }); + } + stderrBuffer = lines[lines.length - 1]; + } + if (stderrBuffer && stderrBuffer.trim()) { + rubyOutput.push({ type: "stderr", message: stderrBuffer }); + } + stderrBuffer = ""; +} + +function formatRubyError(error) { + if (!(error instanceof Error)) { + return `予期せぬエラー: ${String(error).trim()}`; + } + + return error.message; +} + +async function runCode(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") { + rubyOutput.push({ + type: "return", + message: resultStr, + }); + } + } catch (e) { + console.log(e); + flushOutput(); + + rubyOutput.push({ + type: "error", + message: formatRubyError(e), + }); + } + + 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 = []; + 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)})` + ); + } + } + + // 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), + }); + } + + 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" }, + }); + } +} + +// 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 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: {} }); +} + +self.onmessage = async (event) => { + const { id, type, payload } = event.data; + + switch (type) { + case "init": + await init(id, payload); + return; + case "runCode": + await runCode(id, payload); + return; + case "runFile": + await runFile(id, payload); + return; + 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