diff --git a/app/terminal/python/embedded.tsx b/app/terminal/python/embedded.tsx index 26e9788..2dd91f0 100644 --- a/app/terminal/python/embedded.tsx +++ b/app/terminal/python/embedded.tsx @@ -12,8 +12,15 @@ export function PythonEmbeddedTerminal({ content: string; }) { const initCommands = useMemo(() => splitContents(content), [content]); - const { init, initializing, ready, runPython, checkSyntax, mutex } = - usePyodide(); + const { + init, + initializing, + ready, + runPython, + checkSyntax, + mutex, + interrupt, + } = usePyodide(); return ( ); } diff --git a/app/terminal/python/page.tsx b/app/terminal/python/page.tsx index 6155142..8297b27 100644 --- a/app/terminal/python/page.tsx +++ b/app/terminal/python/page.tsx @@ -6,7 +6,7 @@ import { ReplTerminal } from "../repl"; import { usePyodide } from "./pyodide"; export default function PythonPage() { - const { init, ready, initializing, runPython, checkSyntax, mutex } = + const { init, ready, initializing, runPython, checkSyntax, mutex, interrupt } = usePyodide(); return (
@@ -23,6 +23,7 @@ export default function PythonPage() { mutex={mutex} sendCommand={runPython} checkSyntax={checkSyntax} + interrupt={interrupt} /> Promise; - } -} - interface IPyodideContext { - init: () => Promise; // Pyodideを初期化する - initializing: boolean; // Pyodideの初期化が実行中 - ready: boolean; // Pyodideの初期化が完了した - // runPython() などを複数の場所から同時実行すると結果が混ざる。 - // コードブロックの実行全体を mutex.runExclusive() で囲うことで同時実行を防ぐ必要がある + init: () => Promise; + initializing: boolean; + ready: boolean; mutex: MutexInterface; runPython: (code: string) => Promise; runFile: (name: string) => Promise; checkSyntax: (code: string) => Promise; + interrupt: () => void; } + const PyodideContext = createContext(null!); export function usePyodide() { @@ -42,280 +34,159 @@ export function usePyodide() { return context; } -// Python側で実行する構文チェックのコード -// codeop.compile_commandは、コードが不完全な場合はNoneを返します。 -const CHECK_SYNTAX_CODE = ` -def __check_syntax(code): - import codeop - - compiler = codeop.compile_command - try: - # compile_commandは、コードが完結していればコンパイルオブジェクトを、 - # 不完全(まだ続きがある)であればNoneを返す - if compiler(code) is not None: - return "complete" - else: - return "incomplete" - except (SyntaxError, ValueError, OverflowError): - # 明らかな構文エラーの場合 - return "invalid" - -__check_syntax -`; - -const HOME = `/home/pyodide/`; - -// https://stackoverflow.com/questions/436198/what-alternative-is-there-to-execfile-in-python-3-how-to-include-a-python-fil -const EXECFILE_CODE = ` -def __execfile(filepath): - with open(filepath, 'rb') as file: - exec_globals = { - "__file__": filepath, - "__name__": "__main__", - } - exec(compile(file.read(), filepath, 'exec'), exec_globals) - -__execfile -`; -const WRITEFILE_CODE = ` -def __writefile(filepath, content): - with open(filepath, 'w') as f: - f.write(content) - -__writefile -`; -const READALLFILE_CODE = ` -def __readallfile(): - import os - files = [] - for file in os.listdir(): - if os.path.isfile(file): - with open(file, 'r') as f: - files.append((file, f.read())) - return files - -__readallfile -`; +type MessageToWorker = + | { + type: "init"; + payload: { PYODIDE_CDN: string; 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 pyodideRef = useRef(null); + const workerRef = useRef(null); const [ready, setReady] = useState(false); const [initializing, setInitializing] = useState(false); - const pyodideOutput = useRef([]); 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 }); + }); + } const init = useCallback(async () => { - // next.config.ts 内でpyodideをimportし、バージョンを取得している - const PYODIDE_CDN = `https://cdn.jsdelivr.net/pyodide/v${process.env.PYODIDE_VERSION}/full/`; + if (workerRef.current || initializing) return; - const { promise, resolve } = Promise.withResolvers(); - const initPyodide = () => { - if (initializing) return; - setInitializing(true); - window - .loadPyodide({ - indexURL: PYODIDE_CDN, - }) - .then((pyodide) => { - pyodideRef.current = pyodide; + setInitializing(true); + const worker = new Worker("/pyodide.worker.js"); + workerRef.current = worker; - // 標準出力とエラーをハンドリングする設定 - pyodide.setStdout({ - batched: (str) => { - pyodideOutput.current.push({ type: "stdout", message: str }); - }, - }); - pyodide.setStderr({ - batched: (str) => { - pyodideOutput.current.push({ type: "stderr", message: str }); - }, - }); + interruptBuffer.current = new Uint8Array(new SharedArrayBuffer(1)); - setReady(true); - setInitializing(false); - resolve(); - }); + 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); + } }; - // スクリプトタグを動的に追加 - if ("loadPyodide" in window) { - initPyodide(); - } else { - const script = document.createElement("script"); - script.src = `${PYODIDE_CDN}pyodide.js`; - script.async = true; - script.onload = initPyodide; - script.onerror = () => { - // TODO - }; - document.body.appendChild(script); + // next.config.ts 内でpyodideをimportし、バージョンを取得している + const PYODIDE_CDN = `https://cdn.jsdelivr.net/pyodide/v${process.env.PYODIDE_VERSION}/full/`; + postMessage({ + type: "init", + payload: { PYODIDE_CDN, interruptBuffer: interruptBuffer.current }, + }).then(({ success }) => { + if (success) { + setReady(true); + } + setInitializing(false); + }); + }, [initializing]); + + useEffect(() => { + return () => { + workerRef.current?.terminate(); + }; + }, []); - // コンポーネントのクリーンアップ時にスクリプトタグを削除 - // return () => { - // document.body.removeChild(script); - // }; + const interrupt = useCallback(() => { + if (interruptBuffer.current) { + interruptBuffer.current[0] = 2; } - return promise; - }, [initializing]); + }, []); - const runPython = useCallback<(code: string) => Promise>( - async (code: string) => { + const runPython = useCallback( + async (code: string): Promise => { if (!mutex.current.isLocked()) { throw new Error("mutex of PyodideContext must be locked for runPython"); } - - const pyodide = pyodideRef.current; - if (!pyodide || !ready) { + if (!workerRef.current || !ready) { return [{ type: "error", message: "Pyodide is not ready yet." }]; } - try { - const result = await pyodide.runPythonAsync(code); - if (result !== undefined) { - pyodideOutput.current.push({ - type: "return", - message: String(result), - }); - } else { - // 標準出力/エラーがない場合 - } - } catch (e) { - console.log(e); - if (e instanceof Error) { - // エラーがPyodideのTracebackの場合、2行目からが出てくるまでを隠す - if (e.name === "PythonError" && e.message.startsWith("Traceback")) { - const lines = e.message.split("\n"); - const execLineIndex = lines.findIndex((line) => - line.includes("") - ); - pyodideOutput.current.push({ - type: "error", - message: lines - .slice(0, 1) - .concat(lines.slice(execLineIndex)) - .join("\n") - .trim(), - }); - } else { - pyodideOutput.current.push({ - type: "error", - message: `予期せぬエラー: ${e.message.trim()}`, - }); - } - } else { - pyodideOutput.current.push({ - type: "error", - message: `予期せぬエラー: ${String(e).trim()}`, - }); - } - } - const pyReadFile = pyodide.runPython(READALLFILE_CODE) as PyCallable; - for (const [file, content] of pyReadFile() as [string, string][]) { - writeFile(file, content); + if (interruptBuffer.current) { + interruptBuffer.current[0] = 0; } - const output = [...pyodideOutput.current]; - pyodideOutput.current = []; // 出力をクリア + const { output, updatedFiles } = await postMessage({ + type: "runPython", + payload: { code }, + }); + for (const [name, content] of updatedFiles) { + writeFile(name, content); + } return output; }, [ready, writeFile] ); - /** - * ファイルを実行する - */ - const runFile = useCallback<(name: string) => Promise>( - async (name: string) => { - if (mutex.current.isLocked()) { - throw new Error( - "mutex of PyodideContext must not be locked for runFile" - ); - } - const pyodide = pyodideRef.current; - if (!pyodide /*|| !ready*/) { + const runFile = useCallback( + async (name: string): Promise => { + 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 () => { - try { - const pyWriteFile = pyodide.runPython(WRITEFILE_CODE) as PyCallable; - const pyExecFile = pyodide.runPython(EXECFILE_CODE) as PyCallable; - - for (const filename of Object.keys(files)) { - if (files[filename]) { - pyWriteFile(HOME + filename, files[filename]); - } - } - pyExecFile(HOME + name); - } catch (e) { - console.log(e); - if (e instanceof Error) { - // エラーがPyodideのTracebackの場合、2行目からが出てくるまでを隠す - // 自身も隠す - if (e.name === "PythonError" && e.message.startsWith("Traceback")) { - const lines = e.message.split("\n"); - const execLineIndex = lines.findLastIndex((line) => - line.includes("") - ); - pyodideOutput.current.push({ - type: "error", - message: lines - .slice(0, 1) - .concat(lines.slice(execLineIndex + 1)) - .join("\n") - .trim(), - }); - } else { - pyodideOutput.current.push({ - type: "error", - message: `予期せぬエラー: ${e.message.trim()}`, - }); - } - } else { - pyodideOutput.current.push({ - type: "error", - message: `予期せぬエラー: ${String(e).trim()}`, - }); - } - } - - const pyReadFile = pyodide.runPython(READALLFILE_CODE) as PyCallable; - for (const [file, content] of pyReadFile() as [string, string][]) { - writeFile(file, content); + const { output, updatedFiles } = + await postMessage({ + type: "runFile", + payload: { name, files }, + }); + for (const [newName, content] of updatedFiles) { + writeFile(newName, content); } - - const output = [...pyodideOutput.current]; - pyodideOutput.current = []; // 出力をクリア return output; }); }, - [files, writeFile] + [files, ready, writeFile] ); - /** - * Pythonコードの構文が完結しているかチェックする - */ - const checkSyntax = useCallback<(code: string) => Promise>( - async (code) => { - if (mutex.current.isLocked()) { - throw new Error( - "mutex of PyodideContext must not be locked for checkSyntax" - ); - } - - const pyodide = pyodideRef.current; - if (!pyodide || !ready) return "invalid"; - - try { - // Pythonのコードを実行して結果を受け取る - const status = await mutex.current.runExclusive(() => - (pyodide.runPython(CHECK_SYNTAX_CODE) as PyCallable)(code) - ); - return status; - } catch (e) { - console.error("Syntax check error:", e); - return "invalid"; - } + 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] ); @@ -330,6 +201,7 @@ export function PyodideProvider({ children }: { children: ReactNode }) { checkSyntax, mutex: mutex.current, runFile, + interrupt, }} > {children} diff --git a/app/terminal/repl.tsx b/app/terminal/repl.tsx index 4c2e8d5..0c9dd81 100644 --- a/app/terminal/repl.tsx +++ b/app/terminal/repl.tsx @@ -75,6 +75,7 @@ interface ReplComponentProps { // 構文チェックのコールバック関数 // incompleteの場合は次の行に続くことを示す checkSyntax?: (code: string) => Promise; + interrupt?: () => void; } export function ReplTerminal(props: ReplComponentProps) { const inputBuffer = useRef([]); @@ -95,6 +96,7 @@ export function ReplTerminal(props: ReplComponentProps) { sendCommand, checkSyntax, mutex, + interrupt, } = props; const { terminalRef, terminalInstanceRef, termReady } = useTerminal({ @@ -234,7 +236,13 @@ export function ReplTerminal(props: ReplComponentProps) { const isLastChar = i === key.length - 1; // inputBufferは必ず1行以上ある状態にする - if (code === 13) { + if (code === 3) { + // Ctrl+C + if (interrupt) { + interrupt(); + terminalInstanceRef.current.write("^C"); + } + } else if (code === 13) { // Enter const hasContent = inputBuffer.current[inputBuffer.current.length - 1].trim() @@ -304,6 +312,7 @@ export function ReplTerminal(props: ReplComponentProps) { mutex, terminalInstanceRef, addReplOutput, + interrupt, ] ); useEffect(() => { diff --git a/next.config.ts b/next.config.ts index 069c8e8..8ec6405 100644 --- a/next.config.ts +++ b/next.config.ts @@ -16,6 +16,24 @@ const nextConfig: NextConfig = { PYODIDE_VERSION: pyodideVersion, }, serverExternalPackages: ["@prisma/client", ".prisma/client"], + async headers() { + // pyodideをworkerで動作させるために必要 + return [ + { + source: "/:path*", + headers: [ + { + key: "Cross-Origin-Opener-Policy", + value: "same-origin", + }, + { + key: "Cross-Origin-Embedder-Policy", + value: "require-corp", + }, + ], + }, + ]; + }, }; export default nextConfig; diff --git a/public/_headers b/public/_headers index 3b460e6..2472c04 100644 --- a/public/_headers +++ b/public/_headers @@ -1,2 +1,5 @@ /_next/static/* Cache-Control: public,max-age=31536000,immutable +/pyodide.worker.js + Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Embedder-Policy: require-corp diff --git a/public/pyodide.worker.js b/public/pyodide.worker.js new file mode 100644 index 0000000..2f0a047 --- /dev/null +++ b/public/pyodide.worker.js @@ -0,0 +1,241 @@ +// Pyodide web worker +let pyodide; +let pyodideOutput = []; + +// Helper function to read all files from the Pyodide file system +function readAllFiles() { + const dirFiles = pyodide.FS.readdir(HOME); + const updatedFiles = []; + for (const filename of dirFiles) { + if (filename === "." || filename === "..") continue; + const filepath = HOME + filename; + const stat = pyodide.FS.stat(filepath); + if (pyodide.FS.isFile(stat.mode)) { + const content = pyodide.FS.readFile(filepath, { encoding: "utf8" }); + updatedFiles.push([filename, content]); + } + } + return updatedFiles; +} + +async function init(id, payload) { + const { PYODIDE_CDN, interruptBuffer } = payload; + if (!pyodide) { + importScripts(`${PYODIDE_CDN}pyodide.js`); + pyodide = await loadPyodide({ + indexURL: PYODIDE_CDN, + }); + + pyodide.setStdout({ + batched: (str) => { + pyodideOutput.push({ type: "stdout", message: str }); + }, + }); + pyodide.setStderr({ + batched: (str) => { + pyodideOutput.push({ type: "stderr", message: str }); + }, + }); + + pyodide.setInterruptBuffer(interruptBuffer); + } + self.postMessage({ id, payload: { success: true } }); +} + +async function runPython(id, payload) { + const { code } = payload; + if (!pyodide) { + self.postMessage({ id, error: "Pyodide not initialized" }); + return; + } + try { + const result = await pyodide.runPythonAsync(code); + if (result !== undefined) { + pyodideOutput.push({ + type: "return", + message: String(result), + }); + } else { + // 標準出力/エラーがない場合 + } + } catch (e) { + console.log(e); + if (e instanceof Error) { + // エラーがPyodideのTracebackの場合、2行目からが出てくるまでを隠す + if (e.name === "PythonError" && e.message.startsWith("Traceback")) { + const lines = e.message.split("\n"); + const execLineIndex = lines.findIndex((line) => + line.includes("") + ); + pyodideOutput.push({ + type: "error", + message: lines + .slice(0, 1) + .concat(lines.slice(execLineIndex)) + .join("\n") + .trim(), + }); + } else { + pyodideOutput.push({ + type: "error", + message: `予期せぬエラー: ${e.message.trim()}`, + }); + } + } else { + pyodideOutput.push({ + type: "error", + message: `予期せぬエラー: ${String(e).trim()}`, + }); + } + } + + const updatedFiles = readAllFiles(); + + const output = [...pyodideOutput]; + pyodideOutput = []; // 出力をクリア + + self.postMessage({ + id, + payload: { output, updatedFiles }, + }); +} + +async function runFile(id, payload) { + const { name, files } = payload; + if (!pyodide) { + self.postMessage({ id, error: "Pyodide not initialized" }); + return; + } + try { + // Use Pyodide FS API to write files to the file system + for (const filename of Object.keys(files)) { + if (files[filename]) { + pyodide.FS.writeFile(HOME + filename, files[filename], { + encoding: "utf8", + }); + } + } + + const pyExecFile = pyodide.runPython(EXECFILE_CODE); /* as PyCallable*/ + pyExecFile(HOME + name); + } catch (e) { + console.log(e); + if (e instanceof Error) { + // エラーがPyodideのTracebackの場合、2行目からが出てくるまでを隠す + // 自身も隠す + if (e.name === "PythonError" && e.message.startsWith("Traceback")) { + const lines = e.message.split("\n"); + const execLineIndex = lines.findLastIndex((line) => + line.includes("") + ); + pyodideOutput.push({ + type: "error", + message: lines + .slice(0, 1) + .concat(lines.slice(execLineIndex + 1)) + .join("\n") + .trim(), + }); + } else { + pyodideOutput.push({ + type: "error", + message: `予期せぬエラー: ${e.message.trim()}`, + }); + } + } else { + pyodideOutput.push({ + type: "error", + message: `予期せぬエラー: ${String(e).trim()}`, + }); + } + } + + const updatedFiles = readAllFiles(); + + const output = [...pyodideOutput]; + pyodideOutput = []; // 出力をクリア + self.postMessage({ + id, + payload: { output, updatedFiles }, + }); +} + +async function checkSyntax(id, payload) { + const { code } = payload; + if (!pyodide) { + self.postMessage({ + id, + payload: { status: "invalid" }, + }); + return; + } + + try { + // Pythonのコードを実行して結果を受け取る + const status = pyodide.runPython(CHECK_SYNTAX_CODE)(code); + self.postMessage({ id, payload: { status } }); + } 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 "runPython": + await runPython(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; + } +}; + +// Python側で実行する構文チェックのコード +// codeop.compile_commandは、コードが不完全な場合はNoneを返します。 +const CHECK_SYNTAX_CODE = ` +def __check_syntax(code): + import codeop + + compiler = codeop.compile_command + try: + # compile_commandは、コードが完結していればコンパイルオブジェクトを、 + # 不完全(まだ続きがある)であればNoneを返す + if compiler(code) is not None: + return "complete" + else: + return "incomplete" + except (SyntaxError, ValueError, OverflowError): + # 明らかな構文エラーの場合 + return "invalid" + +__check_syntax +`; + +const HOME = `/home/pyodide/`; + +// https://stackoverflow.com/questions/436198/what-alternative-is-there-to-execfile-in-python-3-how-to-include-a-python-fil +const EXECFILE_CODE = ` +def __execfile(filepath): + with open(filepath, 'rb') as file: + exec_globals = { + "__file__": filepath, + "__name__": "__main__", + } + exec(compile(file.read(), filepath, 'exec'), exec_globals) + +__execfile +`;