Skip to content
6 changes: 5 additions & 1 deletion app/terminal/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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) {
Expand All @@ -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":
Expand Down
5 changes: 4 additions & 1 deletion app/terminal/highlight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
21 changes: 21 additions & 0 deletions app/terminal/javascript/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"use client";

import { EditorComponent } from "../editor";
import { ReplTerminal } from "../repl";

export default function JavaScriptPage() {
return (
<div className="p-4 flex flex-col gap-4">
<ReplTerminal
terminalId=""
language="javascript"
initContent={"> console.log('hello, world!')\nhello, world!"}
/>
<EditorComponent
language="javascript"
filename="main.js"
initContent="console.log('hello, world!');"
/>
</div>
);
}
255 changes: 255 additions & 0 deletions app/terminal/javascript/runtime.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
"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<RuntimeContext>(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";
}
| {
type: "runJavaScript";
payload: { code: string };
}
| {
type: "checkSyntax";
payload: { code: string };
}
| {
type: "restoreState";
};

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<Worker | null>(null);
const [ready, setReady] = useState<boolean>(false);
const mutex = useRef<MutexInterface>(new Mutex());
const messageCallbacks = useRef<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Map<number, [(payload: any) => void, (error: string) => void]>
>(new Map());
const nextMessageId = useRef<number>(0);
const isInterrupted = useRef<boolean>(false);

function postMessage<T>({ type, payload }: MessageToWorker) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot
error: Property 'payload' does not exist on type 'MessageToWorker'.

initとrestorestateメッセージに空のpayloadを追加すると解決すると思います。

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bdfa2a1. Added payload?: undefined to init type and payload: { commands: string[] } to restoreState type.

const id = nextMessageId.current++;
return new Promise<T>((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);
}
};

postMessage<InitPayloadFromWorker>({
type: "init",
}).then(({ success }) => {
if (success) {
setReady(true);
}
});

return worker;
}, []);

useEffect(() => {
const worker = initializeWorker();

return () => {
worker.terminate();
};
}, [initializeWorker]);

const interrupt = useCallback(async () => {
// Since we can't interrupt JavaScript execution directly,
// we terminate the worker and restart it, then restore state
isInterrupted.current = true;

// 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);

// Create a new worker
initializeWorker();

// Wait for initialization with timeout
const maxRetries = 50; // 5 seconds total
let retries = 0;

await new Promise<void>((resolve, reject) => {
const checkInterval = setInterval(() => {
retries++;
if (retries > maxRetries) {
clearInterval(checkInterval);
reject(new Error("Worker initialization timeout"));
return;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot initializeWorker() の戻り値をPromiseにすればintervalは不要になると思います。

また、再初期化およびrestoreState中にrunCommand()などが呼ばれるのを防ぐためこの部分をmutex.current.runExclusive()で囲ってください。

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bdfa2a1. Changed initializeWorker() to return a Promise and wrapped the entire interrupt() function in mutex.current.runExclusive() to prevent concurrent calls.


if (workerRef.current) {
// Try to restore state
postMessage<{ success: boolean }>({
type: "restoreState",
}).then(() => {
clearInterval(checkInterval);
isInterrupted.current = false;
resolve();
}).catch(() => {
// Keep trying
});
}
}, 100);
});
}, [initializeWorker]);

const runCommand = useCallback(
async (code: string): Promise<ReplOutput[]> => {
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<RunPayloadFromWorker>({
type: "runJavaScript",
payload: { code },
});
return output;
} catch (error) {
// If interrupted or worker was terminated, return appropriate message
if (isInterrupted.current) {
return [{ type: "error", message: "実行が中断されました" }];
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot interrupt()内のrejectメッセージ「Worker interrupted」で伝わるので、ここの分岐は不要です。
isInterrupted変数も不要になります。

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bdfa2a1. Removed isInterrupted variable and the conditional check. Now all errors (including "Worker interrupted") are handled uniformly.

// Handle other errors
if (error instanceof Error) {
return [{ type: "error", message: error.message }];
}
return [{ type: "error", message: String(error) }];
}
},
[ready]
);

const checkSyntax = useCallback(
async (code: string): Promise<SyntaxStatus> => {
if (!workerRef.current || !ready) return "invalid";
const { status } = await mutex.current.runExclusive(() =>
postMessage<StatusPayloadFromWorker>({
type: "checkSyntax",
payload: { code },
})
);
return status;
},
[ready]
);

const runFiles = useCallback(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async (_filenames: string[]): Promise<ReplOutput[]> => {
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 (
<JavaScriptContext.Provider
value={{
ready,
runCommand,
checkSyntax,
mutex: mutex.current,
runFiles,
interrupt,
splitReplExamples,
getCommandlineStr,
}}
>
{children}
</JavaScriptContext.Provider>
);
}
18 changes: 16 additions & 2 deletions app/terminal/runtime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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
Expand All @@ -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;
Expand All @@ -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}`);
Expand All @@ -64,7 +71,9 @@ export function useRuntime(language: RuntimeLang): RuntimeContext {
export function RuntimeProvider({ children }: { children: ReactNode }) {
return (
<PyodideProvider>
<WandboxProvider>{children}</WandboxProvider>
<WandboxProvider>
<JavaScriptProvider>{children}</JavaScriptProvider>
</WandboxProvider>
</PyodideProvider>
);
}
Expand All @@ -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 {
Expand Down
Loading