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