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
+`;