diff --git a/app/terminal/repl.tsx b/app/terminal/repl.tsx index cc31d14..f1a9b46 100644 --- a/app/terminal/repl.tsx +++ b/app/terminal/repl.tsx @@ -17,8 +17,9 @@ import { useEmbedContext } from "./embedContext"; import { emptyMutex, langConstants, RuntimeLang, useRuntime } from "./runtime"; import clsx from "clsx"; +export type ReplOutputType = "stdout" | "stderr" | "error" | "return" | "trace" | "system"; export interface ReplOutput { - type: "stdout" | "stderr" | "error" | "return" | "trace" | "system"; // 出力の種類 + type: ReplOutputType; // 出力の種類 message: string; // 出力メッセージ } export interface ReplCommand { diff --git a/app/terminal/worker/jsEval.worker.ts b/app/terminal/worker/jsEval.worker.ts index 1377410..64e2302 100644 --- a/app/terminal/worker/jsEval.worker.ts +++ b/app/terminal/worker/jsEval.worker.ts @@ -10,24 +10,20 @@ function format(...args: unknown[]): string { // https://nodejs.org/api/util.html#utilformatformat-args return args.map((a) => (typeof a === "string" ? a : inspect(a))).join(" "); } -let jsOutput: ReplOutput[] = []; +let currentOutputCallback: ((output: ReplOutput) => void) | null = null; // Helper function to capture console output const originalConsole = self.console; self.console = { ...originalConsole, - log: (...args: unknown[]) => { - jsOutput.push({ type: "stdout", message: format(...args) }); - }, - error: (...args: unknown[]) => { - jsOutput.push({ type: "stderr", message: format(...args) }); - }, - warn: (...args: unknown[]) => { - jsOutput.push({ type: "stderr", message: format(...args) }); - }, - info: (...args: unknown[]) => { - jsOutput.push({ type: "stdout", message: format(...args) }); - }, + log: (...args: unknown[]) => + currentOutputCallback?.({ type: "stdout", message: format(...args) }), + error: (...args: unknown[]) => + currentOutputCallback?.({ type: "stderr", message: format(...args) }), + warn: (...args: unknown[]) => + currentOutputCallback?.({ type: "stderr", message: format(...args) }), + info: (...args: unknown[]) => + currentOutputCallback?.({ type: "stdout", message: format(...args) }), }; async function init(/*_interruptBuffer?: Uint8Array*/): Promise<{ @@ -73,13 +69,16 @@ async function replLikeEval(code: string): Promise { } } -async function runCode(code: string): Promise<{ - output: ReplOutput[]; +async function runCode( + code: string, + onOutput: (output: ReplOutput) => void +): Promise<{ updatedFiles: Record; }> { + currentOutputCallback = onOutput; try { const result = await replLikeEval(code); - jsOutput.push({ + onOutput({ type: "return", message: inspect(result), }); @@ -87,51 +86,51 @@ async function runCode(code: string): Promise<{ originalConsole.log(e); // TODO: stack trace? if (e instanceof Error) { - jsOutput.push({ + onOutput({ type: "error", message: `${e.name}: ${e.message}`, }); } else { - jsOutput.push({ + onOutput({ type: "error", message: `${String(e)}`, }); } + } finally { + currentOutputCallback = null; } - const output = [...jsOutput]; - jsOutput = []; // Clear output - - return { output, updatedFiles: {} as Record }; + return { updatedFiles: {} as Record }; } function runFile( name: string, - files: Record -): { output: ReplOutput[]; updatedFiles: Record } { + files: Record, + onOutput: (output: ReplOutput) => void +): { updatedFiles: Record } { // pyodide worker などと異なり、複数ファイルを読み込んでimportのようなことをするのには対応していません。 + currentOutputCallback = onOutput; try { self.eval(files[name]); } catch (e) { originalConsole.log(e); // TODO: stack trace? if (e instanceof Error) { - jsOutput.push({ + onOutput({ type: "error", message: `${e.name}: ${e.message}`, }); } else { - jsOutput.push({ + onOutput({ type: "error", message: `${String(e)}`, }); } + } finally { + currentOutputCallback = null; } - const output = [...jsOutput]; - jsOutput = []; // Clear output - - return { output, updatedFiles: {} as Record }; + return { updatedFiles: {} as Record }; } async function checkSyntax( @@ -162,8 +161,6 @@ async function checkSyntax( async function restoreState(commands: string[]): Promise { // Re-execute all previously successful commands to restore state - jsOutput = []; // Clear output for restoration - for (const command of commands) { try { replLikeEval(command); @@ -173,7 +170,6 @@ async function restoreState(commands: string[]): Promise { } } - jsOutput = []; // Clear any output from restoration return {}; } diff --git a/app/terminal/worker/pyodide.worker.ts b/app/terminal/worker/pyodide.worker.ts index e6cb985..fbfbe7e 100644 --- a/app/terminal/worker/pyodide.worker.ts +++ b/app/terminal/worker/pyodide.worker.ts @@ -16,7 +16,7 @@ const PYODIDE_CDN = `https://cdn.jsdelivr.net/pyodide/v${pyodideVersion}/full/`; const HOME = `/home/pyodide/`; let pyodide: PyodideInterface; -let pyodideOutput: ReplOutput[] = []; +let currentOutputCallback: ((output: ReplOutput) => void) | null = null; // Helper function to read all files from the Pyodide file system function readAllFiles(): Record { @@ -47,14 +47,12 @@ async function init( pyodide = await (self as any).loadPyodide({ indexURL: PYODIDE_CDN }); pyodide.setStdout({ - batched: (str: string) => { - pyodideOutput.push({ type: "stdout", message: str }); - }, + batched: (str: string) => + currentOutputCallback?.({ type: "stdout", message: str }), }); pyodide.setStderr({ - batched: (str: string) => { - pyodideOutput.push({ type: "stderr", message: str }); - }, + batched: (str: string) => + currentOutputCallback?.({ type: "stderr", message: str }), }); pyodide.setInterruptBuffer(interruptBuffer); @@ -62,17 +60,20 @@ async function init( return { capabilities: { interrupt: "buffer" } }; } -async function runCode(code: string): Promise<{ - output: ReplOutput[]; +async function runCode( + code: string, + onOutput: (output: ReplOutput) => void +): Promise<{ updatedFiles: Record; }> { if (!pyodide) { throw new Error("Pyodide not initialized"); } + currentOutputCallback = onOutput; try { const result = await pyodide.runPythonAsync(code); if (result !== undefined) { - pyodideOutput.push({ + onOutput({ type: "return", message: String(result), }); @@ -86,7 +87,7 @@ async function runCode(code: string): Promise<{ const execLineIndex = lines.findIndex((line) => line.includes("") ); - pyodideOutput.push({ + onOutput({ type: "error", message: lines .slice(0, 1) @@ -95,33 +96,35 @@ async function runCode(code: string): Promise<{ .trim(), }); } else { - pyodideOutput.push({ + onOutput({ type: "error", message: `予期せぬエラー: ${e.message.trim()}`, }); } } else { - pyodideOutput.push({ + onOutput({ type: "error", message: `予期せぬエラー: ${String(e).trim()}`, }); } + } finally { + currentOutputCallback = null; } const updatedFiles = readAllFiles(); - const output = [...pyodideOutput]; - pyodideOutput = []; // 出力をクリア - return { output, updatedFiles }; + return { updatedFiles }; } async function runFile( name: string, - files: Record -): Promise<{ output: ReplOutput[]; updatedFiles: Record }> { + files: Record, + onOutput: (output: ReplOutput) => void +): Promise<{ updatedFiles: Record }> { if (!pyodide) { throw new Error("Pyodide not initialized"); } + currentOutputCallback = onOutput; try { // Use Pyodide FS API to write files to the file system for (const filename of Object.keys(files)) { @@ -142,7 +145,7 @@ async function runFile( const execLineIndex = lines.findLastIndex((line) => line.includes("") ); - pyodideOutput.push({ + onOutput({ type: "error", message: lines .slice(0, 1) @@ -151,23 +154,23 @@ async function runFile( .trim(), }); } else { - pyodideOutput.push({ + onOutput({ type: "error", message: `予期せぬエラー: ${e.message.trim()}`, }); } } else { - pyodideOutput.push({ + onOutput({ type: "error", message: `予期せぬエラー: ${String(e).trim()}`, }); } + } finally { + currentOutputCallback = null; } const updatedFiles = readAllFiles(); - const output = [...pyodideOutput]; - pyodideOutput = []; // 出力をクリア - return { output, updatedFiles }; + return { updatedFiles }; } async function checkSyntax( diff --git a/app/terminal/worker/ruby.worker.ts b/app/terminal/worker/ruby.worker.ts index ccc4a99..af6779d 100644 --- a/app/terminal/worker/ruby.worker.ts +++ b/app/terminal/worker/ruby.worker.ts @@ -5,12 +5,12 @@ import { expose } from "comlink"; import { DefaultRubyVM } from "@ruby/wasm-wasi/dist/browser"; import type { RubyVM } from "@ruby/wasm-wasi/dist/vm"; import type { WorkerCapabilities } from "./runtime"; -import type { ReplOutput } from "../repl"; +import type { ReplOutput, ReplOutputType } from "../repl"; import init_rb from "./ruby/init.rb?raw"; let rubyVM: RubyVM | null = null; -let rubyOutput: ReplOutput[] = []; +let currentOutputCallback: ((output: ReplOutput) => void) | null = null; let stdoutBuffer = ""; let stderrBuffer = ""; @@ -21,13 +21,26 @@ declare global { self.stdout = { write(str: string) { stdoutBuffer += str; + stdoutBuffer = handleBatchOutput(stdoutBuffer, "stdout"); }, }; self.stderr = { write(str: string) { stderrBuffer += str; + stderrBuffer = handleBatchOutput(stderrBuffer, "stderr"); }, }; +function handleBatchOutput(buffer: string, type: ReplOutputType): string { + // If buffer contains newlines, flush complete lines immediately + if (buffer.includes("\n")) { + const lines = buffer.split("\n"); + for (let i = 0; i < lines.length - 1; i++) { + currentOutputCallback?.({ type, message: lines[i] }); + } + return lines[lines.length - 1]; + } + return buffer; +} async function init(/*_interruptBuffer?: Uint8Array*/): Promise<{ capabilities: WorkerCapabilities; @@ -55,28 +68,14 @@ async function init(/*_interruptBuffer?: Uint8Array*/): Promise<{ } 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 + // Flush any remaining non-empty text without newlines if (stdoutBuffer && stdoutBuffer.trim()) { - rubyOutput.push({ type: "stdout", message: stdoutBuffer }); + currentOutputCallback?.({ 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 }); + currentOutputCallback?.({ type: "stderr", message: stderrBuffer }); } stderrBuffer = ""; } @@ -101,58 +100,63 @@ function formatRubyError(error: unknown, isFile: boolean): string { return errorMessage; } -async function runCode(code: string): Promise<{ - output: ReplOutput[]; +async function runCode( + code: string, + onOutput: (output: ReplOutput) => void +): Promise<{ updatedFiles: Record; }> { if (!rubyVM) { throw new Error("Ruby VM not initialized"); } + currentOutputCallback = onOutput; try { - rubyOutput = []; stdoutBuffer = ""; stderrBuffer = ""; const result = rubyVM.eval(code); + const resultStr = await result.callAsync("inspect"); + // Flush any buffered output flushOutput(); - const resultStr = await result.callAsync("inspect"); - // Add result to output if it's not nil and not empty - rubyOutput.push({ + onOutput({ type: "return", message: resultStr.toString(), }); } catch (e) { console.log(e); + + // Flush any buffered output flushOutput(); - rubyOutput.push({ + onOutput({ type: "error", message: formatRubyError(e, false), }); + } finally { + currentOutputCallback = null; } const updatedFiles = readAllFiles(); - const output = [...rubyOutput]; - rubyOutput = []; - return { output, updatedFiles }; + return { updatedFiles }; } async function runFile( name: string, - files: Record -): Promise<{ output: ReplOutput[]; updatedFiles: Record }> { + files: Record, + onOutput: (output: ReplOutput) => void +): Promise<{ updatedFiles: Record }> { if (!rubyVM) { throw new Error("Ruby VM not initialized"); } + currentOutputCallback = onOutput; try { - rubyOutput = []; stdoutBuffer = ""; stderrBuffer = ""; @@ -177,19 +181,21 @@ async function runFile( flushOutput(); } catch (e) { console.log(e); + + // Flush any buffered output flushOutput(); - rubyOutput.push({ + onOutput({ type: "error", message: formatRubyError(e, true), }); + } finally { + currentOutputCallback = null; } const updatedFiles = readAllFiles(); - const output = [...rubyOutput]; - rubyOutput = []; - return { output, updatedFiles }; + return { updatedFiles }; } async function checkSyntax( @@ -266,7 +272,6 @@ async function restoreState(commands: string[]): Promise { throw new Error("Ruby VM not initialized"); } - rubyOutput = []; // Clear output for restoration stdoutBuffer = ""; stderrBuffer = ""; @@ -279,9 +284,9 @@ async function restoreState(commands: string[]): Promise { } } - // Clear any output from restoration - flushOutput(); - rubyOutput = []; + // Clear any buffered output from restoration + stdoutBuffer = ""; + stderrBuffer = ""; return {}; } diff --git a/app/terminal/worker/runtime.tsx b/app/terminal/worker/runtime.tsx index 99c2bf0..abc0291 100644 --- a/app/terminal/worker/runtime.tsx +++ b/app/terminal/worker/runtime.tsx @@ -9,7 +9,7 @@ import { useRef, useState, } from "react"; -import { wrap, Remote } from "comlink"; +import { wrap, Remote, proxy } from "comlink"; import { RuntimeContext, RuntimeLang } from "../runtime"; import { ReplOutput, SyntaxStatus } from "../repl"; import { Mutex, MutexInterface } from "async-mutex"; @@ -26,12 +26,14 @@ export interface WorkerAPI { interruptBuffer: Uint8Array ): Promise<{ capabilities: WorkerCapabilities }>; runCode( - code: string - ): Promise<{ output: ReplOutput[]; updatedFiles: Record }>; + code: string, + onOutput: (output: ReplOutput) => void + ): Promise<{ updatedFiles: Record }>; runFile( name: string, - files: Record - ): Promise<{ output: ReplOutput[]; updatedFiles: Record }>; + files: Record, + onOutput: (output: ReplOutput) => void + ): Promise<{ updatedFiles: Record }>; checkSyntax(code: string): Promise<{ status: SyntaxStatus }>; restoreState(commands: string[]): Promise; } @@ -191,8 +193,14 @@ export function WorkerProvider({ } try { - const { output, updatedFiles } = await trackPromise( - workerApiRef.current.runCode(code) + const output: ReplOutput[] = []; + const { updatedFiles } = await trackPromise( + workerApiRef.current.runCode( + code, + proxy((item: ReplOutput) => { + output.push(item); + }) + ) ); writeFile(updatedFiles); @@ -255,8 +263,15 @@ export function WorkerProvider({ interruptBuffer.current[0] = 0; } return mutex.runExclusive(async () => { - const { output, updatedFiles } = await trackPromise( - workerApiRef.current!.runFile(filenames[0], files) + const output: ReplOutput[] = []; + const { updatedFiles } = await trackPromise( + workerApiRef.current!.runFile( + filenames[0], + files, + proxy((item: ReplOutput) => { + output.push(item); + }) + ) ); writeFile(updatedFiles); return output; diff --git a/package-lock.json b/package-lock.json index 94e3088..f7bd4d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,6 @@ "remark-gfm": "^4.0.1", "remark-remove-comments": "^1.1.1", "swr": "^2.3.6", - "typescript": "^5.9.3", "zod": "^4.0.17" }, "devDependencies": { @@ -63,6 +62,7 @@ "prisma": "^6.18.0", "tailwindcss": "^4", "tsx": "^4.20.6", + "typescript": "5.9.3", "wrangler": "^4.27.0" } }, diff --git a/package.json b/package.json index 383b17c..0f7993c 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,6 @@ "remark-gfm": "^4.0.1", "remark-remove-comments": "^1.1.1", "swr": "^2.3.6", - "typescript": "^5.9.3", "zod": "^4.0.17" }, "devDependencies": { @@ -71,6 +70,7 @@ "prisma": "^6.18.0", "tailwindcss": "^4", "tsx": "^4.20.6", + "typescript": "5.9.3", "wrangler": "^4.27.0" } }