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/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` 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..ba80a90 --- /dev/null +++ b/app/terminal/javascript/runtime.tsx @@ -0,0 +1,238 @@ +"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"; + 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 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..2bec3bc --- /dev/null +++ b/public/javascript.worker.js @@ -0,0 +1,123 @@ +// 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) { + // Initialize the worker + self.postMessage({ id, payload: { success: true } }); +} + +async function runJavaScript(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: [] }, + }); +} + +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: { 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, payload); + return; + default: + originalConsole.error(`Unknown message type: ${type}`); + return; + } +};