From be5b650f014645b16d61b45d29f78d4f0ae2ff4e Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sun, 9 Nov 2025 02:31:00 +0900 Subject: [PATCH 01/13] =?UTF-8?q?javascript.worker=E3=81=AErunFiles()?= =?UTF-8?q?=E3=82=92=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/terminal/page.tsx | 6 ++++-- app/terminal/tests.ts | 20 +++++++++++++++----- public/javascript.worker.js | 34 +++++++++++++++++++++++++++------- 3 files changed, 46 insertions(+), 14 deletions(-) diff --git a/app/terminal/page.tsx b/app/terminal/page.tsx index 94bace1..1120b3d 100644 --- a/app/terminal/page.tsx +++ b/app/terminal/page.tsx @@ -69,8 +69,10 @@ const sampleConfig: Record = { javascript: { repl: true, replInitContent: '> console.log("Hello, World!");\nHello, World!', - editor: false, - exec: false, + editor: { + "main.js": 'console.log("Hello, World!");', + }, + exec: ["main.js"], }, cpp: { repl: false, diff --git a/app/terminal/tests.ts b/app/terminal/tests.ts index 52fbc70..9ea6fad 100644 --- a/app/terminal/tests.ts +++ b/app/terminal/tests.ts @@ -60,7 +60,10 @@ export function defineTests( python: [`${varName} = ${value}`, `print(${varName})`], ruby: [`${varName} = ${value}`, `puts ${varName}`], cpp: [null, null], - javascript: [`var ${varName} = ${value}`, `console.log(${varName})`], + javascript: [ + `var ${varName} = ${value}`, + `console.log(${varName})`, + ], } satisfies Record )[lang]; if (!setIntVarCode || !printIntVarCode) { @@ -109,7 +112,11 @@ export function defineTests( python: [`testVar = 42`, `while True:\n pass`, `print(testVar)`], ruby: [`testVar = 42`, `loop do\nend`, `puts testVar`], cpp: [null, null, null], - javascript: [`var testVar = 42`, `while(true) {}`, `console.log(testVar)`], + javascript: [ + `var testVar = 42`, + `while(true) {}`, + `console.log(testVar)`, + ], } satisfies Record )[lang]; if (!setIntVarCode || !infLoopCode || !printIntVarCode) { @@ -156,7 +163,7 @@ export function defineTests( "test.cpp", `#include \nint main() {\n std::cout << "${msg}" << std::endl;\n return 0;\n}\n`, ], - javascript: [null, null], + javascript: ["test.js", `console.log("${msg}")`], } satisfies Record )[lang]; if (!filename || !code) { @@ -185,7 +192,7 @@ export function defineTests( "test_error.cpp", `#include \nint main() {\n throw std::runtime_error("${errorMsg}");\n return 0;\n}\n`, ], - javascript: [null, null], + javascript: ["test_error.js", `throw new Error("${errorMsg}");\n`], } satisfies Record )[lang]; if (!filename || !code) { @@ -230,7 +237,10 @@ export function defineTests( ["test_multi_main.cpp", "test_multi_sub.cpp"], ], javascript: [null, null], - } satisfies Record, string[]] | [null, null]> + } satisfies Record< + RuntimeLang, + [Record, string[]] | [null, null] + > )[lang]; if (!codes || !execFiles) { this.skip(); diff --git a/public/javascript.worker.js b/public/javascript.worker.js index 98a59c0..0601c83 100644 --- a/public/javascript.worker.js +++ b/public/javascript.worker.js @@ -41,6 +41,7 @@ async function runCode(id, payload) { } } catch (e) { originalConsole.log(e); + // TODO: stack trace? if (e instanceof Error) { jsOutput.push({ type: "error", @@ -49,7 +50,7 @@ async function runCode(id, payload) { } else { jsOutput.push({ type: "error", - message: `予期せぬエラー: ${String(e)}`, + message: `${String(e)}`, }); } } @@ -64,12 +65,31 @@ async function runCode(id, payload) { } function runFile(id, payload) { - const output = [ - { - type: "error", - message: "File execution is not supported in this runtime", - }, - ]; + const { name, files } = payload; + // pyodide worker などと異なり、複数ファイルを読み込んでimportのようなことをするのには対応していません。 + try { + // Execute code directly with eval in the worker global scope + // This will preserve variables across calls + globalThis.eval(files[name]); + } catch (e) { + originalConsole.log(e); + // TODO: stack trace? + 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: [] }, From 461a1b01d7eb2698495344da431c5acc55aaa5ff Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sun, 9 Nov 2025 04:11:39 +0900 Subject: [PATCH 02/13] =?UTF-8?q?TypeScript=E5=AE=9F=E8=A1=8C=E7=92=B0?= =?UTF-8?q?=E5=A2=83=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * `@typescript/vfs` で型チェック&トランスパイル * typescriptで生成されたjsファイルをjsEvalのコンテキストに渡すため、writeFile()とrunFiles()の動作を変更しています * writeFile() が更新後の全ファイルをpromiseで返し、 runFiles() はcontextからファイルを取得する代わりに引数で受け取る * (runFiles() の呼び出し側は常にfilesをContextから取ってきて引数に渡さないといけなくなる) --- .gitignore | 3 + app/terminal/README.md | 5 +- app/terminal/editor.tsx | 6 +- app/terminal/embedContext.tsx | 36 +++++--- app/terminal/exec.tsx | 5 +- app/terminal/page.tsx | 7 ++ app/terminal/runtime.tsx | 20 +++- app/terminal/typescript/runtime.tsx | 138 ++++++++++++++++++++++++++++ app/terminal/wandbox/runtime.tsx | 38 ++++---- app/terminal/worker/runtime.tsx | 14 +-- copyAllDTSFiles.ts | 23 +++++ package-lock.json | 37 +++++++- package.json | 10 +- public/_headers | 2 + 14 files changed, 291 insertions(+), 53 deletions(-) create mode 100644 app/terminal/typescript/runtime.tsx create mode 100644 copyAllDTSFiles.ts diff --git a/.gitignore b/.gitignore index efb8a61..181e30d 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + + +/public/typescript/ diff --git a/app/terminal/README.md b/app/terminal/README.md index fd4329c..ec89b6e 100644 --- a/app/terminal/README.md +++ b/app/terminal/README.md @@ -51,8 +51,9 @@ runtime.tsx の `useRuntime(lang)` は各言語のフックを呼び出し、そ ### ファイル実行用 -* runFiles: `(filenames: string[]) => Promise` - * 指定されたファイルを実行します。ファイルの中身はEmbedContextから取得されます。 +* runFiles: `(filenames: string[], files: Record) => Promise` + * 指定されたファイルを実行します。 + * EmbedContextから取得したfilesを呼び出し側で引数に渡します * 呼び出し側でmutexのロックはせず、必要であればrunFiles()内でロックします。 * getCommandlineStr: `(filenames: string[]) => string` * 指定されたファイルを実行するためのコマンドライン引数文字列を返します。表示用です。 diff --git a/app/terminal/editor.tsx b/app/terminal/editor.tsx index 386587d..1a532d2 100644 --- a/app/terminal/editor.tsx +++ b/app/terminal/editor.tsx @@ -73,7 +73,7 @@ export function EditorComponent(props: EditorProps) { const code = files[props.filename] || props.initContent; useEffect(() => { if (!files[props.filename]) { - writeFile(props.filename, props.initContent); + writeFile({ [props.filename]: props.initContent }); } }, [files, props.filename, props.initContent, writeFile]); @@ -92,7 +92,7 @@ export function EditorComponent(props: EditorProps) { // codeの内容が変更された場合のみ表示する (props.readonly || code == props.initContent) && "invisible" )} - onClick={() => writeFile(props.filename, props.initContent)} + onClick={() => writeFile({ [props.filename]: props.initContent })} > {/**/} writeFile(props.filename, code)} + onChange={(code: string) => writeFile({ [props.filename]: code })} /> ); diff --git a/app/terminal/embedContext.tsx b/app/terminal/embedContext.tsx index 8749307..a6265c1 100644 --- a/app/terminal/embedContext.tsx +++ b/app/terminal/embedContext.tsx @@ -25,7 +25,11 @@ type TerminalId = string; interface IEmbedContext { files: Record; - writeFile: (name: Filename, content: string) => void; + // ファイルを書き込む。更新後のページ内の全ファイル内容を返す + // 返り値を使うことで再レンダリングを待たずに最新の内容を取得できる + writeFile: ( + updatedFiles: Record + ) => Promise>; replOutputs: Record; addReplOutput: ( @@ -72,16 +76,26 @@ export function EmbedContextProvider({ children }: { children: ReactNode }) { }, [pathname, currentPathname]); const writeFile = useCallback( - (name: Filename, content: string) => { - setFiles((files) => { - if (files[pathname]?.[name] !== content) { - files = { ...files }; - files[pathname] = { ...(files[pathname] ?? {}) }; - files[pathname][name] = content; - return files; - } else { - return files; - } + (updatedFiles: Record) => { + return new Promise>((resolve) => { + setFiles((files) => { + let changed = false; + const newFiles = { ...files }; + newFiles[pathname] = { ...(newFiles[pathname] ?? {}) }; + for (const [name, content] of Object.entries(updatedFiles)) { + if (newFiles[pathname][name] !== content) { + changed = true; + newFiles[pathname][name] = content; + } + } + if (changed) { + resolve(newFiles[pathname]); + return newFiles; + } else { + resolve(files[pathname] || {}); + return files; + } + }); }); }, [pathname] diff --git a/app/terminal/exec.tsx b/app/terminal/exec.tsx index 3caa615..b9118db 100644 --- a/app/terminal/exec.tsx +++ b/app/terminal/exec.tsx @@ -31,7 +31,7 @@ export function ExecFile(props: ExecProps) { } }, }); - const { setExecResult } = useEmbedContext(); + const { files, setExecResult } = useEmbedContext(); const { ready, runFiles, getCommandlineStr } = useRuntime(props.language); @@ -45,7 +45,7 @@ export function ExecFile(props: ExecProps) { (async () => { clearTerminal(terminalInstanceRef.current!); terminalInstanceRef.current!.write(systemMessageColor("実行中です...")); - const outputs = await runFiles(props.filenames); + const outputs = await runFiles(props.filenames, files); clearTerminal(terminalInstanceRef.current!); writeOutput(terminalInstanceRef.current!, outputs, false); // TODO: 1つのファイル名しか受け付けないところに無理やりコンマ区切りで全部のファイル名を突っ込んでいる @@ -60,6 +60,7 @@ export function ExecFile(props: ExecProps) { runFiles, setExecResult, terminalInstanceRef, + files, ]); return ( diff --git a/app/terminal/page.tsx b/app/terminal/page.tsx index 1120b3d..4b57af2 100644 --- a/app/terminal/page.tsx +++ b/app/terminal/page.tsx @@ -74,6 +74,13 @@ const sampleConfig: Record = { }, exec: ["main.js"], }, + typescript: { + repl: false, + editor: { + "main.ts": 'function greet(name: string): void {\n console.log("Hello, " + name + "!");\n}\n\ngreet("World");', + }, + exec: ["main.ts"], + }, cpp: { repl: false, editor: { diff --git a/app/terminal/runtime.tsx b/app/terminal/runtime.tsx index d72c7a0..89da185 100644 --- a/app/terminal/runtime.tsx +++ b/app/terminal/runtime.tsx @@ -7,6 +7,7 @@ import { PyodideContext, usePyodide } from "./worker/pyodide"; import { RubyContext, useRuby } from "./worker/ruby"; import { JSEvalContext, useJSEval } from "./worker/jsEval"; import { WorkerProvider } from "./worker/runtime"; +import { TypeScriptProvider, useTypeScript } from "./typescript/runtime"; /** * Common runtime context interface for different languages @@ -23,7 +24,7 @@ export interface RuntimeContext { checkSyntax?: (code: string) => Promise; splitReplExamples?: (content: string) => ReplCommand[]; // file - runFiles: (filenames: string[]) => Promise; + runFiles: (filenames: string[], files: Record) => Promise; getCommandlineStr?: (filenames: string[]) => string; } export interface LangConstants { @@ -31,7 +32,12 @@ export interface LangConstants { prompt?: string; promptMore?: string; } -export type RuntimeLang = "python" | "ruby" | "cpp" | "javascript"; +export type RuntimeLang = + | "python" + | "ruby" + | "cpp" + | "javascript" + | "typescript"; export function getRuntimeLang( lang: string | undefined @@ -50,6 +56,9 @@ export function getRuntimeLang( case "javascript": case "js": return "javascript"; + case "typescript": + case "ts": + return "typescript"; default: console.warn(`Unsupported language for runtime: ${lang}`); return undefined; @@ -60,6 +69,7 @@ export function useRuntime(language: RuntimeLang): RuntimeContext { const pyodide = usePyodide(); const ruby = useRuby(); const jsEval = useJSEval(); + const typescript = useTypeScript(jsEval); const wandboxCpp = useWandbox("cpp"); switch (language) { @@ -69,6 +79,8 @@ export function useRuntime(language: RuntimeLang): RuntimeContext { return ruby; case "javascript": return jsEval; + case "typescript": + return typescript; case "cpp": return wandboxCpp; default: @@ -81,7 +93,9 @@ export function RuntimeProvider({ children }: { children: ReactNode }) { - {children} + + {children} + diff --git a/app/terminal/typescript/runtime.tsx b/app/terminal/typescript/runtime.tsx new file mode 100644 index 0000000..8585f7e --- /dev/null +++ b/app/terminal/typescript/runtime.tsx @@ -0,0 +1,138 @@ +"use client"; + +import ts, { CompilerOptions } from "typescript"; +import { + createSystem, + createVirtualTypeScriptEnvironment, + knownLibFilesForCompilerOptions, + VirtualTypeScriptEnvironment, +} from "@typescript/vfs"; +import { + createContext, + ReactNode, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import { useEmbedContext } from "../embedContext"; +import { ReplOutput } from "../repl"; +import { RuntimeContext } from "../runtime"; + +export const compilerOptions: CompilerOptions = {}; + +const TypeScriptContext = createContext( + null +); +export function TypeScriptProvider({ children }: { children: ReactNode }) { + const [tsEnv, setTSEnv] = useState(null); + useEffect(() => { + if (tsEnv === null) { + const abortController = new AbortController(); + (async () => { + const system = createSystem(new Map()); + const libFiles = knownLibFilesForCompilerOptions(compilerOptions, ts); + const libFileContents = await Promise.all( + libFiles.map(async (libFile) => { + const response = await fetch( + `/typescript/${ts.version}/${libFile}`, + { signal: abortController.signal } + ); + if (response.ok) { + return response.text(); + } else { + return undefined; + } + }) + ); + libFiles.forEach((libFile, index) => { + const content = libFileContents[index]; + if (content !== undefined) { + system.writeFile(`/${libFile}`, content); + } + }); + const env = createVirtualTypeScriptEnvironment( + system, + [], + ts, + compilerOptions + ); + setTSEnv(env); + })(); + return () => { + abortController.abort(); + }; + } + }, [tsEnv]); + return ( + + {children} + + ); +} + +export function useTypeScript(jsEval: RuntimeContext): RuntimeContext { + const tsEnv = useContext(TypeScriptContext); + + const { writeFile } = useEmbedContext(); + const runFiles = useCallback( + async (filenames: string[], files: Record) => { + if (tsEnv === null) { + return [ + { type: "error" as const, message: "TypeScript is not ready yet." }, + ]; + } + + for (const [filename, content] of Object.entries(files)) { + tsEnv.createFile(filename, content); + } + + const outputs: ReplOutput[] = []; + + for (const diagnostic of tsEnv.languageService.getSyntacticDiagnostics( + filenames[0] + )) { + outputs.push({ + type: "error", + message: ts.formatDiagnosticsWithColorAndContext([diagnostic], { + getCurrentDirectory: () => "", + getCanonicalFileName: (f) => f, + getNewLine: () => "\n", + }), + }); + } + + for (const diagnostic of tsEnv.languageService.getSemanticDiagnostics( + filenames[0] + )) { + outputs.push({ + type: "error", + message: ts.formatDiagnosticsWithColorAndContext([diagnostic], { + getCurrentDirectory: () => "", + getCanonicalFileName: (f) => f, + getNewLine: () => "\n", + }), + }); + } + + const emitOutput = tsEnv.languageService.getEmitOutput(filenames[0]); + files = await writeFile(Object.fromEntries(emitOutput.outputFiles.map((of) => [of.name, of.text]))); + + console.log(emitOutput) + const jsOutputs = jsEval.runFiles([emitOutput.outputFiles[0].name], files); + + return outputs.concat(await jsOutputs); + }, + [tsEnv, writeFile, jsEval] + ); + return { + ready: tsEnv !== null, + runFiles, + getCommandlineStr, + }; +} + +function getCommandlineStr(filenames: string[]) { + return `tsc ${filenames.join(" ")}`; +} diff --git a/app/terminal/wandbox/runtime.tsx b/app/terminal/wandbox/runtime.tsx index 8111687..5e206c2 100644 --- a/app/terminal/wandbox/runtime.tsx +++ b/app/terminal/wandbox/runtime.tsx @@ -10,7 +10,6 @@ import { import useSWR from "swr"; import { compilerInfoFetcher, SelectedCompiler } from "./api"; import { cppRunFiles, selectCppCompiler } from "./cpp"; -import { useEmbedContext } from "../embedContext"; import { RuntimeContext, RuntimeLang } from "../runtime"; import { ReplOutput } from "../repl"; @@ -23,13 +22,15 @@ interface IWandboxContext { ) => (filenames: string[]) => string; runFilesWithLang: ( lang: WandboxLang - ) => (filenames: string[]) => Promise; + ) => ( + filenames: string[], + files: Record + ) => Promise; } const WandboxContext = createContext(null!); export function WandboxProvider({ children }: { children: ReactNode }) { - const { files } = useEmbedContext(); const { data: compilerList, error } = useSWR("list", compilerInfoFetcher); if (error) { console.error("Failed to fetch compiler list from Wandbox:", error); @@ -68,21 +69,22 @@ export function WandboxProvider({ children }: { children: ReactNode }) { // Curried function for language-specific file execution const runFilesWithLang = useCallback( - (lang: WandboxLang) => async (filenames: string[]) => { - if (!selectedCompiler) { - return [ - { type: "error" as const, message: "Wandbox is not ready yet." }, - ]; - } - switch (lang) { - case "cpp": - return cppRunFiles(selectedCompiler.cpp, files, filenames); - default: - lang satisfies never; - throw new Error(`unsupported language: ${lang}`); - } - }, - [selectedCompiler, files] + (lang: WandboxLang) => + async (filenames: string[], files: Record) => { + if (!selectedCompiler) { + return [ + { type: "error" as const, message: "Wandbox is not ready yet." }, + ]; + } + switch (lang) { + case "cpp": + return cppRunFiles(selectedCompiler.cpp, files, filenames); + default: + lang satisfies never; + throw new Error(`unsupported language: ${lang}`); + } + }, + [selectedCompiler] ); return ( diff --git a/app/terminal/worker/runtime.tsx b/app/terminal/worker/runtime.tsx index 78ed53f..82ed345 100644 --- a/app/terminal/worker/runtime.tsx +++ b/app/terminal/worker/runtime.tsx @@ -51,7 +51,7 @@ export function WorkerProvider({ const workerRef = useRef(null); const [ready, setReady] = useState(false); const mutex = useRef(new Mutex()); - const { files, writeFile } = useEmbedContext(); + const { writeFile } = useEmbedContext(); const messageCallbacks = useRef< // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -173,9 +173,7 @@ export function WorkerProvider({ payload: { code }, }); - for (const [name, content] of updatedFiles) { - writeFile(name, content); - } + writeFile(Object.fromEntries(updatedFiles)); // Save command to history if interrupt method is 'restart' if (capabilities.current?.interrupt === "restart") { @@ -211,7 +209,7 @@ export function WorkerProvider({ ); const runFiles = useCallback( - async (filenames: string[]): Promise => { + async (filenames: string[], files: Record): Promise => { if (filenames.length !== 1) { return [ { @@ -240,13 +238,11 @@ export function WorkerProvider({ type: "runFile", payload: { name: filenames[0], files }, }); - for (const [newName, content] of updatedFiles) { - writeFile(newName, content); - } + writeFile(Object.fromEntries(updatedFiles)); return output; }); }, - [files, ready, writeFile] + [ready, writeFile] ); return ( diff --git a/copyAllDTSFiles.ts b/copyAllDTSFiles.ts new file mode 100644 index 0000000..fe3b647 --- /dev/null +++ b/copyAllDTSFiles.ts @@ -0,0 +1,23 @@ +// node_modules/typescript/lib からd.tsファイルをすべてpublic/typescript/version/にコピーする。 + +import { knownLibFilesForCompilerOptions } from "@typescript/vfs"; +import { compilerOptions } from "./app/terminal/typescript/runtime"; +import ts from "typescript"; +import fs from "node:fs/promises"; +import { existsSync } from "node:fs"; + +const libFiles = knownLibFilesForCompilerOptions(compilerOptions, ts); + +const destDir = `./public/typescript/${ts.version}/`; +await fs.mkdir(destDir, { recursive: true }); + +for (const libFile of libFiles) { + const srcPath = `./node_modules/typescript/lib/${libFile}`; + const destPath = `${destDir}${libFile}`; + if(existsSync(srcPath)) { + await fs.copyFile(srcPath, destPath); + console.log(`Copied ${libFile} to ${destPath}`); + } else { + console.warn(`Source file does not exist: ${srcPath}`); + } +} diff --git a/package-lock.json b/package-lock.json index cbdc49d..c5df637 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@fontsource-variable/noto-sans-jp": "^5.2.6", "@google/genai": "^1.21.0", "@opennextjs/cloudflare": "^1.7.1", + "@typescript/vfs": "^1.6.2", "@xterm/addon-fit": "^0.11.0-beta.115", "@xterm/xterm": "^5.6.0-beta.115", "ace-builds": "^1.43.2", @@ -33,6 +34,7 @@ "react-syntax-highlighter": "^16.1.0", "remark-gfm": "^4.0.1", "swr": "^2.3.6", + "typescript": "5.9.3", "zod": "^4.0.17" }, "devDependencies": { @@ -53,7 +55,7 @@ "prettier": "^3.6.2", "prisma": "^6.18.0", "tailwindcss": "^4", - "typescript": "5.9.3", + "tsx": "^4.20.6", "wrangler": "^4.27.0" } }, @@ -12569,6 +12571,18 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript/vfs": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.2.tgz", + "integrity": "sha512-hoBwJwcbKHmvd2QVebiytN1aELvpk9B74B4L1mFm/XT1Q/VOYAWl2vQ9AWRFtQq8zmz6enTpfTV8WRc4ATjW/g==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1" + }, + "peerDependencies": { + "typescript": "*" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -21589,6 +21603,26 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.20.6", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", + "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/tsyringe": { "version": "4.10.0", "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", @@ -21716,7 +21750,6 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index c210f10..f31a9ab 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,15 @@ "name": "my-code", "version": "0.1.0", "private": true, + "type": "module", "scripts": { - "dev": "npm run cf-typegen && next dev", - "build": "npm run cf-typegen && next build", + "dev": "npm run cf-typegen && npm run copyAllDTSFiles && next dev", + "build": "npm run cf-typegen && npm run copyAllDTSFiles && next build", "start": "next start", "lint": "npm run cf-typegen && next lint", "tsc": "npm run cf-typegen && tsc", "format": "prettier --write app/", + "copyAllDTSFiles": "tsx ./copyAllDTSFiles.ts", "cf-preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview --port 3000", "cf-deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy", "cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts" @@ -18,6 +20,7 @@ "@fontsource-variable/noto-sans-jp": "^5.2.6", "@google/genai": "^1.21.0", "@opennextjs/cloudflare": "^1.7.1", + "@typescript/vfs": "^1.6.2", "@xterm/addon-fit": "^0.11.0-beta.115", "@xterm/xterm": "^5.6.0-beta.115", "ace-builds": "^1.43.2", @@ -39,6 +42,7 @@ "react-syntax-highlighter": "^16.1.0", "remark-gfm": "^4.0.1", "swr": "^2.3.6", + "typescript": "5.9.3", "zod": "^4.0.17" }, "devDependencies": { @@ -59,7 +63,7 @@ "prettier": "^3.6.2", "prisma": "^6.18.0", "tailwindcss": "^4", - "typescript": "5.9.3", + "tsx": "^4.20.6", "wrangler": "^4.27.0" } } diff --git a/public/_headers b/public/_headers index 2abdfb7..2f6efa6 100644 --- a/public/_headers +++ b/public/_headers @@ -1,5 +1,7 @@ /_next/static/* Cache-Control: public,max-age=31536000,immutable +/typescript/* + Cache-Control: public,max-age=86400 /*.worker.js Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp From 0b373f006c2620f7f9865df2cc761eee3cfda381 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sun, 9 Nov 2025 20:19:07 +0900 Subject: [PATCH 03/13] =?UTF-8?q?dynamic=20import=20=E3=81=AB=E5=A4=89?= =?UTF-8?q?=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/terminal/typescript/runtime.tsx | 104 +++++++++++++++++++--------- 1 file changed, 72 insertions(+), 32 deletions(-) diff --git a/app/terminal/typescript/runtime.tsx b/app/terminal/typescript/runtime.tsx index 8585f7e..28356c8 100644 --- a/app/terminal/typescript/runtime.tsx +++ b/app/terminal/typescript/runtime.tsx @@ -1,38 +1,64 @@ "use client"; -import ts, { CompilerOptions } from "typescript"; -import { - createSystem, - createVirtualTypeScriptEnvironment, - knownLibFilesForCompilerOptions, - VirtualTypeScriptEnvironment, -} from "@typescript/vfs"; +import type { CompilerOptions } from "typescript"; +import type { VirtualTypeScriptEnvironment } from "@typescript/vfs"; import { createContext, ReactNode, useCallback, useContext, useEffect, - useRef, useState, } from "react"; import { useEmbedContext } from "../embedContext"; import { ReplOutput } from "../repl"; import { RuntimeContext } from "../runtime"; +import dynamic from "next/dynamic"; export const compilerOptions: CompilerOptions = {}; -const TypeScriptContext = createContext( - null +type TSModules = { + ts: typeof import("typescript"); + vfs: typeof import("@typescript/vfs"); +}; +interface ITypeScriptContext { + modules: TSModules | null; + setModules: (modules: TSModules) => void; + tsEnv: VirtualTypeScriptEnvironment | null; +} +const TypeScriptContext = createContext({ + modules: null, + setModules: () => {}, + tsEnv: null, +}); +const LazyInitTypeScript = dynamic( + async () => { + const ts = await import("typescript"); + const vfs = await import("@typescript/vfs"); + return function LazyInitTypeScript() { + const { setModules } = useContext(TypeScriptContext); + useEffect(() => { + setModules({ ts, vfs }); + }, [setModules]); + return null; + }; + }, + { ssr: false } ); export function TypeScriptProvider({ children }: { children: ReactNode }) { const [tsEnv, setTSEnv] = useState(null); + const [modules, setModules] = useState(null); + useEffect(() => { - if (tsEnv === null) { + if (modules !== null && tsEnv === null) { + const { ts, vfs } = modules; const abortController = new AbortController(); (async () => { - const system = createSystem(new Map()); - const libFiles = knownLibFilesForCompilerOptions(compilerOptions, ts); + const system = vfs.createSystem(new Map()); + const libFiles = vfs.knownLibFilesForCompilerOptions( + compilerOptions, + ts + ); const libFileContents = await Promise.all( libFiles.map(async (libFile) => { const response = await fetch( @@ -52,7 +78,7 @@ export function TypeScriptProvider({ children }: { children: ReactNode }) { system.writeFile(`/${libFile}`, content); } }); - const env = createVirtualTypeScriptEnvironment( + const env = vfs.createVirtualTypeScriptEnvironment( system, [], ts, @@ -64,21 +90,22 @@ export function TypeScriptProvider({ children }: { children: ReactNode }) { abortController.abort(); }; } - }, [tsEnv]); + }, [tsEnv, setTSEnv, modules]); return ( - + + {children} ); } export function useTypeScript(jsEval: RuntimeContext): RuntimeContext { - const tsEnv = useContext(TypeScriptContext); + const { modules, tsEnv } = useContext(TypeScriptContext); const { writeFile } = useEmbedContext(); const runFiles = useCallback( async (filenames: string[], files: Record) => { - if (tsEnv === null) { + if (tsEnv === null || modules === null) { return [ { type: "error" as const, message: "TypeScript is not ready yet." }, ]; @@ -95,11 +122,14 @@ export function useTypeScript(jsEval: RuntimeContext): RuntimeContext { )) { outputs.push({ type: "error", - message: ts.formatDiagnosticsWithColorAndContext([diagnostic], { - getCurrentDirectory: () => "", - getCanonicalFileName: (f) => f, - getNewLine: () => "\n", - }), + message: modules.ts.formatDiagnosticsWithColorAndContext( + [diagnostic], + { + getCurrentDirectory: () => "", + getCanonicalFileName: (f) => f, + getNewLine: () => "\n", + } + ), }); } @@ -108,23 +138,33 @@ export function useTypeScript(jsEval: RuntimeContext): RuntimeContext { )) { outputs.push({ type: "error", - message: ts.formatDiagnosticsWithColorAndContext([diagnostic], { - getCurrentDirectory: () => "", - getCanonicalFileName: (f) => f, - getNewLine: () => "\n", - }), + message: modules.ts.formatDiagnosticsWithColorAndContext( + [diagnostic], + { + getCurrentDirectory: () => "", + getCanonicalFileName: (f) => f, + getNewLine: () => "\n", + } + ), }); } const emitOutput = tsEnv.languageService.getEmitOutput(filenames[0]); - files = await writeFile(Object.fromEntries(emitOutput.outputFiles.map((of) => [of.name, of.text]))); + files = await writeFile( + Object.fromEntries( + emitOutput.outputFiles.map((of) => [of.name, of.text]) + ) + ); - console.log(emitOutput) - const jsOutputs = jsEval.runFiles([emitOutput.outputFiles[0].name], files); + console.log(emitOutput); + const jsOutputs = jsEval.runFiles( + [emitOutput.outputFiles[0].name], + files + ); return outputs.concat(await jsOutputs); }, - [tsEnv, writeFile, jsEval] + [modules, tsEnv, writeFile, jsEval] ); return { ready: tsEnv !== null, From eadb2a92a6cb4d5d27c53e9dda7ad9342387dd25 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sun, 9 Nov 2025 20:31:49 +0900 Subject: [PATCH 04/13] =?UTF-8?q?syntaxhighlighter=E3=82=82dynamic=20impor?= =?UTF-8?q?t=E3=81=AB=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/[docs_id]/markdown.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/[docs_id]/markdown.tsx b/app/[docs_id]/markdown.tsx index b3a8c03..cf8ad75 100644 --- a/app/[docs_id]/markdown.tsx +++ b/app/[docs_id]/markdown.tsx @@ -1,6 +1,5 @@ import Markdown, { Components } from "react-markdown"; import remarkGfm from "remark-gfm"; -import SyntaxHighlighter from "react-syntax-highlighter"; import { EditorComponent, getAceLang } from "../terminal/editor"; import { ExecFile } from "../terminal/exec"; import { useChangeTheme } from "./themeToggle"; @@ -11,6 +10,9 @@ import { import { ReactNode } from "react"; import { getRuntimeLang } from "@/terminal/runtime"; import { ReplTerminal } from "@/terminal/repl"; +import dynamic from "next/dynamic"; +// SyntaxHighlighterはファイルサイズがでかいので & HydrationErrorを起こすので、SSRを無効化する +const SyntaxHighlighter = dynamic(() => import("react-syntax-highlighter"), { ssr: false }); export function StyledMarkdown({ content }: { content: string }) { return ( From 42b5b506c459a7746e213bfac5fbcb8177a676c3 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sun, 9 Nov 2025 21:15:03 +0900 Subject: [PATCH 05/13] =?UTF-8?q?dynamic=E3=81=AF=E4=B8=8D=E8=A6=81?= =?UTF-8?q?=E3=81=AA=E3=81=93=E3=81=A8=E3=81=8C=E3=82=8F=E3=81=8B=E3=81=A3?= =?UTF-8?q?=E3=81=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/terminal/typescript/runtime.tsx | 128 +++++++++++----------------- 1 file changed, 50 insertions(+), 78 deletions(-) diff --git a/app/terminal/typescript/runtime.tsx b/app/terminal/typescript/runtime.tsx index 28356c8..0704038 100644 --- a/app/terminal/typescript/runtime.tsx +++ b/app/terminal/typescript/runtime.tsx @@ -13,47 +13,24 @@ import { import { useEmbedContext } from "../embedContext"; import { ReplOutput } from "../repl"; import { RuntimeContext } from "../runtime"; -import dynamic from "next/dynamic"; export const compilerOptions: CompilerOptions = {}; -type TSModules = { - ts: typeof import("typescript"); - vfs: typeof import("@typescript/vfs"); -}; -interface ITypeScriptContext { - modules: TSModules | null; - setModules: (modules: TSModules) => void; - tsEnv: VirtualTypeScriptEnvironment | null; -} -const TypeScriptContext = createContext({ - modules: null, - setModules: () => {}, - tsEnv: null, -}); -const LazyInitTypeScript = dynamic( - async () => { - const ts = await import("typescript"); - const vfs = await import("@typescript/vfs"); - return function LazyInitTypeScript() { - const { setModules } = useContext(TypeScriptContext); - useEffect(() => { - setModules({ ts, vfs }); - }, [setModules]); - return null; - }; - }, - { ssr: false } +const TypeScriptContext = createContext( + null ); export function TypeScriptProvider({ children }: { children: ReactNode }) { const [tsEnv, setTSEnv] = useState(null); - const [modules, setModules] = useState(null); useEffect(() => { - if (modules !== null && tsEnv === null) { - const { ts, vfs } = modules; + // useEffectはサーバーサイドでは実行されないが、 + // typeof window !== "undefined" でガードしないとなぜかesbuildが"typescript"を + // サーバーサイドでのインポート対象とみなしてしまう。 + if (tsEnv === null && typeof window !== "undefined") { const abortController = new AbortController(); (async () => { + const ts = await import("typescript"); + const vfs = await import("@typescript/vfs"); const system = vfs.createSystem(new Map()); const libFiles = vfs.knownLibFilesForCompilerOptions( compilerOptions, @@ -90,81 +67,76 @@ export function TypeScriptProvider({ children }: { children: ReactNode }) { abortController.abort(); }; } - }, [tsEnv, setTSEnv, modules]); + }, [tsEnv, setTSEnv]); return ( - - + {children} ); } export function useTypeScript(jsEval: RuntimeContext): RuntimeContext { - const { modules, tsEnv } = useContext(TypeScriptContext); + const tsEnv = useContext(TypeScriptContext); const { writeFile } = useEmbedContext(); const runFiles = useCallback( async (filenames: string[], files: Record) => { - if (tsEnv === null || modules === null) { + if (tsEnv === null || typeof window === "undefined") { return [ { type: "error" as const, message: "TypeScript is not ready yet." }, ]; - } + } else { + for (const [filename, content] of Object.entries(files)) { + tsEnv.createFile(filename, content); + } - for (const [filename, content] of Object.entries(files)) { - tsEnv.createFile(filename, content); - } + const outputs: ReplOutput[] = []; - const outputs: ReplOutput[] = []; + const ts = await import("typescript"); - for (const diagnostic of tsEnv.languageService.getSyntacticDiagnostics( - filenames[0] - )) { - outputs.push({ - type: "error", - message: modules.ts.formatDiagnosticsWithColorAndContext( - [diagnostic], - { + for (const diagnostic of tsEnv.languageService.getSyntacticDiagnostics( + filenames[0] + )) { + outputs.push({ + type: "error", + message: ts.formatDiagnosticsWithColorAndContext([diagnostic], { getCurrentDirectory: () => "", getCanonicalFileName: (f) => f, getNewLine: () => "\n", - } - ), - }); - } + }), + }); + } - for (const diagnostic of tsEnv.languageService.getSemanticDiagnostics( - filenames[0] - )) { - outputs.push({ - type: "error", - message: modules.ts.formatDiagnosticsWithColorAndContext( - [diagnostic], - { + for (const diagnostic of tsEnv.languageService.getSemanticDiagnostics( + filenames[0] + )) { + outputs.push({ + type: "error", + message: ts.formatDiagnosticsWithColorAndContext([diagnostic], { getCurrentDirectory: () => "", getCanonicalFileName: (f) => f, getNewLine: () => "\n", - } - ), - }); - } + }), + }); + } - const emitOutput = tsEnv.languageService.getEmitOutput(filenames[0]); - files = await writeFile( - Object.fromEntries( - emitOutput.outputFiles.map((of) => [of.name, of.text]) - ) - ); + const emitOutput = tsEnv.languageService.getEmitOutput(filenames[0]); + files = await writeFile( + Object.fromEntries( + emitOutput.outputFiles.map((of) => [of.name, of.text]) + ) + ); - console.log(emitOutput); - const jsOutputs = jsEval.runFiles( - [emitOutput.outputFiles[0].name], - files - ); + console.log(emitOutput); + const jsOutputs = jsEval.runFiles( + [emitOutput.outputFiles[0].name], + files + ); - return outputs.concat(await jsOutputs); + return outputs.concat(await jsOutputs); + } }, - [modules, tsEnv, writeFile, jsEval] + [tsEnv, writeFile, jsEval] ); return { ready: tsEnv !== null, From 168713166d202f97083d551cefbbece15e87fec2 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sun, 9 Nov 2025 21:32:06 +0900 Subject: [PATCH 06/13] =?UTF-8?q?prism=E3=81=A8xterm=E3=82=82dynamic=20imp?= =?UTF-8?q?ort=E3=81=AB=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/terminal/highlight.ts | 20 +++- app/terminal/repl.tsx | 17 +++- app/terminal/terminal.tsx | 202 ++++++++++++++++++++------------------ 3 files changed, 131 insertions(+), 108 deletions(-) diff --git a/app/terminal/highlight.ts b/app/terminal/highlight.ts index 5bc7069..982f260 100644 --- a/app/terminal/highlight.ts +++ b/app/terminal/highlight.ts @@ -1,10 +1,18 @@ -import Prism from "prismjs"; import chalk from "chalk"; import { RuntimeLang } from "./runtime"; -// 言語定義をインポート -import "prismjs/components/prism-python"; -import "prismjs/components/prism-ruby"; -import "prismjs/components/prism-javascript"; + +export async function importPrism() { + if (typeof window !== "undefined") { + const Prism = await import("prismjs"); + // 言語定義をインポート + await import("prismjs/components/prism-python"); + await import("prismjs/components/prism-ruby"); + await import("prismjs/components/prism-javascript"); + return Prism; + } else { + return null!; + } +} type PrismLang = "python" | "ruby" | "javascript"; @@ -74,6 +82,7 @@ const prismToAnsi: Record string> = { * @returns {string} ANSIで色付けされた文字列 */ export function highlightCodeToAnsi( + Prism: typeof import("prismjs"), code: string, language: RuntimeLang ): string { @@ -129,3 +138,4 @@ export function highlightCodeToAnsi( "" ); } + diff --git a/app/terminal/repl.tsx b/app/terminal/repl.tsx index b74e986..6cf3846 100644 --- a/app/terminal/repl.tsx +++ b/app/terminal/repl.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import { highlightCodeToAnsi } from "./highlight"; +import { highlightCodeToAnsi, importPrism } from "./highlight"; import chalk from "chalk"; import { clearTerminal, @@ -11,7 +11,7 @@ import { systemMessageColor, useTerminal, } from "./terminal"; -import { Terminal } from "@xterm/xterm"; +import type { Terminal } from "@xterm/xterm"; import { useEmbedContext } from "./embedContext"; import { emptyMutex, langConstants, RuntimeLang, useRuntime } from "./runtime"; @@ -69,6 +69,13 @@ export function ReplTerminal({ }: ReplComponentProps) { const { addReplOutput } = useEmbedContext(); + const [Prism, setPrism] = useState(null); + useEffect(() => { + if(Prism === null){ + importPrism().then((prism) => setPrism(prism)); + } + }, [Prism]); + const { ready: runtimeReady, mutex: runtimeMutex = emptyMutex, @@ -122,7 +129,7 @@ export function ReplTerminal({ // inputBufferを更新し、画面に描画する const updateBuffer = useCallback( (newBuffer: () => string[]) => { - if (terminalInstanceRef.current) { + if (terminalInstanceRef.current && Prism) { hideCursor(terminalInstanceRef.current); // バッファの行数分カーソルを戻す if (inputBuffer.current.length >= 2) { @@ -141,7 +148,7 @@ export function ReplTerminal({ ); if (language) { terminalInstanceRef.current.write( - highlightCodeToAnsi(inputBuffer.current[i], language) + highlightCodeToAnsi(Prism, inputBuffer.current[i], language) ); } else { terminalInstanceRef.current.write(inputBuffer.current[i]); @@ -153,7 +160,7 @@ export function ReplTerminal({ showCursor(terminalInstanceRef.current); } }, - [prompt, promptMore, language, terminalInstanceRef] + [Prism, prompt, promptMore, language, terminalInstanceRef] ); // ランタイムからのoutputを描画し、inputBufferをリセット diff --git a/app/terminal/terminal.tsx b/app/terminal/terminal.tsx index aaf2b8a..0ce015b 100644 --- a/app/terminal/terminal.tsx +++ b/app/terminal/terminal.tsx @@ -1,8 +1,8 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { Terminal } from "@xterm/xterm"; -import { FitAddon } from "@xterm/addon-fit"; +import type { Terminal } from "@xterm/xterm"; +import type { FitAddon } from "@xterm/addon-fit"; import "@xterm/xterm/css/xterm.css"; import chalk from "chalk"; import { useChangeTheme } from "../[docs_id]/themeToggle"; @@ -70,104 +70,110 @@ export function useTerminal(props: TerminalProps) { // ターミナルの初期化処理 useEffect(() => { - const abortController = new AbortController(); - // globals.cssでフォントを指定し読み込んでいるが、 - // それが読み込まれる前にterminalを初期化してしまうとバグる。 - document.fonts.load("0.875rem Inconsolata Variable").then(() => { - if (!abortController.signal.aborted) { - const fromCSS = (varName: string) => - window.getComputedStyle(document.body).getPropertyValue(varName); - // "--color-" + color_name のように文字列を分割するとTailwindCSSが認識せずCSSの値として出力されない場合があるので注意 - const term = new Terminal({ - cursorBlink: true, - convertEol: true, - cursorStyle: "bar", - cursorInactiveStyle: "none", - fontSize: 14, - lineHeight: 1.4, - letterSpacing: 0, - fontFamily: "'Inconsolata Variable','Noto Sans JP Variable'", - theme: { - // DaisyUIの変数を使用してテーマを設定している - // TODO: ダークテーマ/ライトテーマを切り替えたときに再設定する? - background: fromCSS("--color-base-300"), - foreground: fromCSS("--color-base-content"), - cursor: fromCSS("--color-base-content"), - selectionBackground: fromCSS("--color-primary"), - selectionForeground: fromCSS("--color-primary-content"), - black: fromCSS("--color-black"), - brightBlack: fromCSS("--color-neutral-500"), - red: fromCSS("--color-red-600"), - brightRed: fromCSS("--color-red-400"), - green: fromCSS("--color-green-600"), - brightGreen: fromCSS("--color-green-400"), - yellow: fromCSS("--color-yellow-700"), - brightYellow: fromCSS("--color-yellow-400"), - blue: fromCSS("--color-indigo-600"), - brightBlue: fromCSS("--color-indigo-400"), - magenta: fromCSS("--color-fuchsia-600"), - brightMagenta: fromCSS("--color-fuchsia-400"), - cyan: fromCSS("--color-cyan-600"), - brightCyan: fromCSS("--color-cyan-400"), - white: fromCSS("--color-base-100"), - brightWhite: fromCSS("--color-white"), - }, - }); - terminalInstanceRef.current = term; - - fitAddonRef.current = new FitAddon(); - term.loadAddon(fitAddonRef.current); - // fitAddon.fit(); - - term.open(terminalRef.current); - - // https://github.com/xtermjs/xterm.js/issues/2478 - // my.code();ではCtrl+Cでのkeyboardinterruptは要らないので、コピーペーストに置き換えてしまう - term.attachCustomKeyEventHandler((arg) => { - if ( - arg.ctrlKey && - (arg.key === "c" || arg.key === "x") && - arg.type === "keydown" - ) { - const selection = term.getSelection(); - if (selection) { - navigator.clipboard.writeText(selection); + if (typeof window !== "undefined") { + const abortController = new AbortController(); + // globals.cssでフォントを指定し読み込んでいるが、 + // それが読み込まれる前にterminalを初期化してしまうとバグる。 + Promise.all([ + import("@xterm/xterm"), + import("@xterm/addon-fit"), + document.fonts.load("0.875rem Inconsolata Variable"), + ]).then(([{ Terminal }, { FitAddon }]) => { + if (!abortController.signal.aborted) { + const fromCSS = (varName: string) => + window.getComputedStyle(document.body).getPropertyValue(varName); + // "--color-" + color_name のように文字列を分割するとTailwindCSSが認識せずCSSの値として出力されない場合があるので注意 + const term = new Terminal({ + cursorBlink: true, + convertEol: true, + cursorStyle: "bar", + cursorInactiveStyle: "none", + fontSize: 14, + lineHeight: 1.4, + letterSpacing: 0, + fontFamily: "'Inconsolata Variable','Noto Sans JP Variable'", + theme: { + // DaisyUIの変数を使用してテーマを設定している + // TODO: ダークテーマ/ライトテーマを切り替えたときに再設定する? + background: fromCSS("--color-base-300"), + foreground: fromCSS("--color-base-content"), + cursor: fromCSS("--color-base-content"), + selectionBackground: fromCSS("--color-primary"), + selectionForeground: fromCSS("--color-primary-content"), + black: fromCSS("--color-black"), + brightBlack: fromCSS("--color-neutral-500"), + red: fromCSS("--color-red-600"), + brightRed: fromCSS("--color-red-400"), + green: fromCSS("--color-green-600"), + brightGreen: fromCSS("--color-green-400"), + yellow: fromCSS("--color-yellow-700"), + brightYellow: fromCSS("--color-yellow-400"), + blue: fromCSS("--color-indigo-600"), + brightBlue: fromCSS("--color-indigo-400"), + magenta: fromCSS("--color-fuchsia-600"), + brightMagenta: fromCSS("--color-fuchsia-400"), + cyan: fromCSS("--color-cyan-600"), + brightCyan: fromCSS("--color-cyan-400"), + white: fromCSS("--color-base-100"), + brightWhite: fromCSS("--color-white"), + }, + }); + terminalInstanceRef.current = term; + + fitAddonRef.current = new FitAddon(); + term.loadAddon(fitAddonRef.current); + // fitAddon.fit(); + + term.open(terminalRef.current); + + // https://github.com/xtermjs/xterm.js/issues/2478 + // my.code();ではCtrl+Cでのkeyboardinterruptは要らないので、コピーペーストに置き換えてしまう + term.attachCustomKeyEventHandler((arg) => { + if ( + arg.ctrlKey && + (arg.key === "c" || arg.key === "x") && + arg.type === "keydown" + ) { + const selection = term.getSelection(); + if (selection) { + navigator.clipboard.writeText(selection); + return false; + } + } + if (arg.ctrlKey && arg.key === "v" && arg.type === "keydown") { return false; } - } - if (arg.ctrlKey && arg.key === "v" && arg.type === "keydown") { - return false; - } - return true; - }); - - setTermReady(true); - onReadyRef.current?.(); - } - }); - - const observer = new ResizeObserver(() => { - // fitAddon.fit(); - const dims = fitAddonRef.current?.proposeDimensions(); - if (dims && !isNaN(dims.cols)) { - const rows = Math.max(5, getRowsRef.current?.(dims.cols) ?? 0); - terminalInstanceRef.current?.resize(dims.cols, rows); - } - }); - observer.observe(terminalRef.current); - - return () => { - abortController.abort("terminal component dismount"); - observer.disconnect(); - if (fitAddonRef.current) { - fitAddonRef.current.dispose(); - fitAddonRef.current = null; - } - if (terminalInstanceRef.current) { - terminalInstanceRef.current.dispose(); - terminalInstanceRef.current = null; - } - }; + return true; + }); + + setTermReady(true); + onReadyRef.current?.(); + } + }); + + const observer = new ResizeObserver(() => { + // fitAddon.fit(); + const dims = fitAddonRef.current?.proposeDimensions(); + if (dims && !isNaN(dims.cols)) { + const rows = Math.max(5, getRowsRef.current?.(dims.cols) ?? 0); + terminalInstanceRef.current?.resize(dims.cols, rows); + } + }); + observer.observe(terminalRef.current); + + return () => { + abortController.abort("terminal component dismount"); + observer.disconnect(); + if (fitAddonRef.current) { + fitAddonRef.current.dispose(); + fitAddonRef.current = null; + } + if (terminalInstanceRef.current) { + terminalInstanceRef.current.dispose(); + terminalInstanceRef.current = null; + } + }; + } }, []); // テーマが変わったときにterminalのテーマを更新する From d1381a4dd9d43a28a86a6764485caeaf90ed2393 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sun, 9 Nov 2025 21:37:46 +0900 Subject: [PATCH 07/13] =?UTF-8?q?mocha=E3=82=82dynamic=20import=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/terminal/page.tsx | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/app/terminal/page.tsx b/app/terminal/page.tsx index 4b57af2..00e6b6e 100644 --- a/app/terminal/page.tsx +++ b/app/terminal/page.tsx @@ -1,6 +1,5 @@ "use client"; import { Heading } from "@/[docs_id]/markdown"; -import "mocha/mocha.js"; import "mocha/mocha.css"; import { Fragment, useEffect, useRef, useState } from "react"; import { useWandbox } from "./wandbox/runtime"; @@ -150,19 +149,23 @@ function MochaTest() { ); const { writeFile } = useEmbedContext(); - const runTest = () => { - setMochaState("running"); + const runTest = async () => { + if(typeof window !== "undefined") { + setMochaState("running"); + + await import("mocha/mocha.js"); - mocha.setup("bdd"); + mocha.setup("bdd"); - for (const lang of Object.keys(runtimeRef.current) as RuntimeLang[]) { - defineTests(lang, runtimeRef, writeFile); - } + for (const lang of Object.keys(runtimeRef.current) as RuntimeLang[]) { + defineTests(lang, runtimeRef, writeFile); + } - const runner = mocha.run(); - runner.on("end", () => { - setMochaState("finished"); - }); + const runner = mocha.run(); + runner.on("end", () => { + setMochaState("finished"); + }); + } }; return ( From 582f309a6611d4beebb91f7f3658cfb65b0d3635 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sun, 9 Nov 2025 21:40:18 +0900 Subject: [PATCH 08/13] =?UTF-8?q?module=E3=81=AE=E5=AE=A3=E8=A8=80?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- declatations.d.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/declatations.d.ts b/declatations.d.ts index 7bd2f6a..2f49eec 100644 --- a/declatations.d.ts +++ b/declatations.d.ts @@ -1 +1,3 @@ declare module "ace-builds/src-min-noconflict/*"; +declare module "prismjs/components/*"; +declare module "mocha/mocha.js"; From 17154f1d683878b25be25ee926053b6420efd284 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Mon, 10 Nov 2025 22:39:45 +0900 Subject: [PATCH 09/13] =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E3=81=A7write?= =?UTF-8?q?File=E5=BE=8C=E3=81=AEtimeout=E3=81=AF=E4=B8=8D=E8=A6=81?= =?UTF-8?q?=E3=80=81files=E3=81=ABReadonly=E8=BF=BD=E5=8A=A0=E3=80=81type?= =?UTF-8?q?=E3=82=A8=E3=83=A9=E3=83=BC=E4=BF=AE=E6=AD=A3=E3=81=AA=E3=81=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/terminal/embedContext.tsx | 10 +++++----- app/terminal/exec.tsx | 1 + app/terminal/page.tsx | 6 ++++-- app/terminal/repl.tsx | 11 +++++++++-- app/terminal/runtime.tsx | 2 +- app/terminal/tests.ts | 18 ++++-------------- app/terminal/typescript/runtime.tsx | 2 +- app/terminal/wandbox/runtime.tsx | 4 ++-- app/terminal/worker/runtime.tsx | 5 ++++- 9 files changed, 31 insertions(+), 28 deletions(-) diff --git a/app/terminal/embedContext.tsx b/app/terminal/embedContext.tsx index a6265c1..53138d3 100644 --- a/app/terminal/embedContext.tsx +++ b/app/terminal/embedContext.tsx @@ -24,21 +24,21 @@ type Filename = string; type TerminalId = string; interface IEmbedContext { - files: Record; + files: Readonly>; // ファイルを書き込む。更新後のページ内の全ファイル内容を返す // 返り値を使うことで再レンダリングを待たずに最新の内容を取得できる writeFile: ( - updatedFiles: Record - ) => Promise>; + updatedFiles: Readonly> + ) => Promise>>; - replOutputs: Record; + replOutputs: Readonly>; addReplOutput: ( terminalId: TerminalId, command: string, output: ReplOutput[] ) => void; - execResults: Record; + execResults: Readonly>; setExecResult: (filename: Filename, output: ReplOutput[]) => void; } const EmbedContext = createContext(null!); diff --git a/app/terminal/exec.tsx b/app/terminal/exec.tsx index 5afb5d7..73c1448 100644 --- a/app/terminal/exec.tsx +++ b/app/terminal/exec.tsx @@ -52,6 +52,7 @@ export function ExecFile(props: ExecProps) { outputs, false, undefined, + null, // ファイル実行で"return"メッセージが返ってくることはないはずなので、Prismを渡す必要はない props.language ); // TODO: 1つのファイル名しか受け付けないところに無理やりコンマ区切りで全部のファイル名を突っ込んでいる diff --git a/app/terminal/page.tsx b/app/terminal/page.tsx index 743eaf6..c7abb13 100644 --- a/app/terminal/page.tsx +++ b/app/terminal/page.tsx @@ -147,7 +147,9 @@ function MochaTest() { const [mochaState, setMochaState] = useState<"idle" | "running" | "finished">( "idle" ); - const { writeFile } = useEmbedContext(); + const { files } = useEmbedContext(); + const filesRef = useRef(files); + filesRef.current = files; const runTest = async () => { if(typeof window !== "undefined") { @@ -158,7 +160,7 @@ function MochaTest() { mocha.setup("bdd"); for (const lang of Object.keys(runtimeRef.current) as RuntimeLang[]) { - defineTests(lang, runtimeRef, writeFile); + defineTests(lang, runtimeRef, filesRef); } const runner = mocha.run(); diff --git a/app/terminal/repl.tsx b/app/terminal/repl.tsx index 954d9d4..79666ef 100644 --- a/app/terminal/repl.tsx +++ b/app/terminal/repl.tsx @@ -30,6 +30,7 @@ export function writeOutput( outputs: ReplOutput[], endNewLine: boolean, returnPrefix: string | undefined, + Prism: typeof import("prismjs") | null, language: RuntimeLang ) { for (let i = 0; i < outputs.length; i++) { @@ -53,7 +54,12 @@ export function writeOutput( if (returnPrefix) { term.write(returnPrefix); } - term.write(highlightCodeToAnsi(message, language)); + if (Prism) { + term.write(highlightCodeToAnsi(Prism, message, language)); + } else { + console.warn("Prism is not loaded, cannot highlight return value"); + term.write(message); + } break; default: term.write(message); @@ -79,7 +85,7 @@ export function ReplTerminal({ const [Prism, setPrism] = useState(null); useEffect(() => { - if(Prism === null){ + if (Prism === null) { importPrism().then((prism) => setPrism(prism)); } }, [Prism]); @@ -180,6 +186,7 @@ export function ReplTerminal({ outputs, true, returnPrefix, + Prism, language ); // 出力が終わったらプロンプトを表示 diff --git a/app/terminal/runtime.tsx b/app/terminal/runtime.tsx index fcbe71c..21aabc0 100644 --- a/app/terminal/runtime.tsx +++ b/app/terminal/runtime.tsx @@ -24,7 +24,7 @@ export interface RuntimeContext { checkSyntax?: (code: string) => Promise; splitReplExamples?: (content: string) => ReplCommand[]; // file - runFiles: (filenames: string[], files: Record) => Promise; + runFiles: (filenames: string[], files: Readonly>) => Promise; getCommandlineStr?: (filenames: string[]) => string; } export interface LangConstants { diff --git a/app/terminal/tests.ts b/app/terminal/tests.ts index 9ea6fad..b5cd22d 100644 --- a/app/terminal/tests.ts +++ b/app/terminal/tests.ts @@ -5,7 +5,7 @@ import { emptyMutex, RuntimeContext, RuntimeLang } from "./runtime"; export function defineTests( lang: RuntimeLang, runtimeRef: RefObject>, - writeFile: (name: string, content: string) => void + filesRef: RefObject>>, ) { describe(`${lang} Runtime`, function () { this.timeout( @@ -136,7 +136,6 @@ export function defineTests( while (!runtimeRef.current[lang].ready) { await new Promise((resolve) => setTimeout(resolve, 100)); } - await new Promise((resolve) => setTimeout(resolve, 100)); const result = await ( runtimeRef.current[lang].mutex || emptyMutex ).runExclusive(() => @@ -169,10 +168,7 @@ export function defineTests( if (!filename || !code) { this.skip(); } - writeFile(filename, code); - // use setTimeout to wait for writeFile to propagate. - await new Promise((resolve) => setTimeout(resolve, 100)); - const result = await runtimeRef.current[lang].runFiles([filename]); + const result = await runtimeRef.current[lang].runFiles([filename], {[filename]: code}); console.log(`${lang} single file stdout test: `, result); expect(result).to.be.deep.equal([ { @@ -198,9 +194,7 @@ export function defineTests( if (!filename || !code) { this.skip(); } - writeFile(filename, code); - await new Promise((resolve) => setTimeout(resolve, 100)); - const result = await runtimeRef.current[lang].runFiles([filename]); + const result = await runtimeRef.current[lang].runFiles([filename], {[filename]: code}); console.log(`${lang} single file error capture test: `, result); // eslint-disable-next-line @typescript-eslint/no-unused-expressions expect(result.filter((r) => r.message.includes(errorMsg))).to.not.be @@ -245,11 +239,7 @@ export function defineTests( if (!codes || !execFiles) { this.skip(); } - for (const [filename, code] of Object.entries(codes)) { - writeFile(filename, code); - } - await new Promise((resolve) => setTimeout(resolve, 100)); - const result = await runtimeRef.current[lang].runFiles(execFiles); + const result = await runtimeRef.current[lang].runFiles(execFiles, codes); console.log(`${lang} multifile stdout test: `, result); expect(result).to.be.deep.equal([ { diff --git a/app/terminal/typescript/runtime.tsx b/app/terminal/typescript/runtime.tsx index 0704038..917da82 100644 --- a/app/terminal/typescript/runtime.tsx +++ b/app/terminal/typescript/runtime.tsx @@ -80,7 +80,7 @@ export function useTypeScript(jsEval: RuntimeContext): RuntimeContext { const { writeFile } = useEmbedContext(); const runFiles = useCallback( - async (filenames: string[], files: Record) => { + async (filenames: string[], files: Readonly>) => { if (tsEnv === null || typeof window === "undefined") { return [ { type: "error" as const, message: "TypeScript is not ready yet." }, diff --git a/app/terminal/wandbox/runtime.tsx b/app/terminal/wandbox/runtime.tsx index 5e206c2..aadbd6b 100644 --- a/app/terminal/wandbox/runtime.tsx +++ b/app/terminal/wandbox/runtime.tsx @@ -24,7 +24,7 @@ interface IWandboxContext { lang: WandboxLang ) => ( filenames: string[], - files: Record + files: Readonly> ) => Promise; } @@ -70,7 +70,7 @@ export function WandboxProvider({ children }: { children: ReactNode }) { // Curried function for language-specific file execution const runFilesWithLang = useCallback( (lang: WandboxLang) => - async (filenames: string[], files: Record) => { + async (filenames: string[], files: Readonly>) => { if (!selectedCompiler) { return [ { type: "error" as const, message: "Wandbox is not ready yet." }, diff --git a/app/terminal/worker/runtime.tsx b/app/terminal/worker/runtime.tsx index bf9e87b..3ecd2a4 100644 --- a/app/terminal/worker/runtime.tsx +++ b/app/terminal/worker/runtime.tsx @@ -237,7 +237,10 @@ export function WorkerProvider({ ); const runFiles = useCallback( - async (filenames: string[], files: Record): Promise => { + async ( + filenames: string[], + files: Readonly> + ): Promise => { if (filenames.length !== 1) { return [ { From bbe6fdba5e254ab1a611969c51b90766238d8f29 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Mon, 10 Nov 2025 23:35:00 +0900 Subject: [PATCH 10/13] =?UTF-8?q?TypeScript=E3=81=AE=E3=83=86=E3=82=B9?= =?UTF-8?q?=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/terminal/editor.tsx | 14 +++++++++++++- app/terminal/highlight.ts | 1 + app/terminal/page.tsx | 7 +++++-- app/terminal/runtime.tsx | 1 + app/terminal/tests.ts | 23 +++++++++++++++++++---- 5 files changed, 39 insertions(+), 7 deletions(-) diff --git a/app/terminal/editor.tsx b/app/terminal/editor.tsx index 1a532d2..70ebd3f 100644 --- a/app/terminal/editor.tsx +++ b/app/terminal/editor.tsx @@ -14,6 +14,7 @@ const AceEditor = dynamic( await import("ace-builds/src-min-noconflict/mode-ruby"); 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-typescript"); 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"); @@ -30,7 +31,15 @@ import { langConstants } from "./runtime"; // snippetを有効化するにはsnippetもimportする必要がある: import "ace-builds/src-min-noconflict/snippets/python"; // mode-xxxx.js のファイル名と、AceEditorの mode プロパティの値が対応する -export type AceLang = "python" | "ruby" | "c_cpp" | "javascript" | "json" | "csv" | "text"; +export type AceLang = + | "python" + | "ruby" + | "c_cpp" + | "javascript" + | "typescript" + | "json" + | "csv" + | "text"; export function getAceLang(lang: string | undefined): AceLang { // Markdownで指定される可能性のある言語名からAceLangを取得 switch (lang) { @@ -46,6 +55,9 @@ export function getAceLang(lang: string | undefined): AceLang { case "javascript": case "js": return "javascript"; + case "typescript": + case "ts": + return "typescript"; case "json": return "json"; case "csv": diff --git a/app/terminal/highlight.ts b/app/terminal/highlight.ts index 982f260..76a1dc1 100644 --- a/app/terminal/highlight.ts +++ b/app/terminal/highlight.ts @@ -25,6 +25,7 @@ function getPrismLanguage(language: RuntimeLang): PrismLang { case "javascript": return "javascript"; case "cpp": + case "typescript": throw new Error( `highlight for ${language} is disabled because it should not support REPL` ); diff --git a/app/terminal/page.tsx b/app/terminal/page.tsx index c7abb13..3125790 100644 --- a/app/terminal/page.tsx +++ b/app/terminal/page.tsx @@ -12,6 +12,7 @@ import { useJSEval } from "./worker/jsEval"; import { ReplTerminal } from "./repl"; import { EditorComponent, getAceLang } from "./editor"; import { ExecFile } from "./exec"; +import { useTypeScript } from "./typescript/runtime"; export default function RuntimeTestPage() { return ( @@ -130,13 +131,15 @@ function RuntimeSample({ function MochaTest() { const pyodide = usePyodide(); const ruby = useRuby(); - const javascript = useJSEval(); + const jsEval = useJSEval(); + const typescript = useTypeScript(jsEval); const wandboxCpp = useWandbox("cpp"); const runtimeRef = useRef>(null!); runtimeRef.current = { python: pyodide, ruby: ruby, - javascript: javascript, + javascript: jsEval, + typescript: typescript, cpp: wandboxCpp, }; diff --git a/app/terminal/runtime.tsx b/app/terminal/runtime.tsx index 21aabc0..2778ca6 100644 --- a/app/terminal/runtime.tsx +++ b/app/terminal/runtime.tsx @@ -120,6 +120,7 @@ export function langConstants(lang: RuntimeLang | AceLang): LangConstants { returnPrefix: "=> ", }; case "javascript": + case "typescript": return { tabSize: 2, prompt: "> ", diff --git a/app/terminal/tests.ts b/app/terminal/tests.ts index b5cd22d..44acc08 100644 --- a/app/terminal/tests.ts +++ b/app/terminal/tests.ts @@ -5,7 +5,7 @@ import { emptyMutex, RuntimeContext, RuntimeLang } from "./runtime"; export function defineTests( lang: RuntimeLang, runtimeRef: RefObject>, - filesRef: RefObject>>, + filesRef: RefObject>> ) { describe(`${lang} Runtime`, function () { this.timeout( @@ -35,6 +35,7 @@ export function defineTests( ruby: `puts "${msg}"`, cpp: null, javascript: `console.log("${msg}")`, + typescript: null, } satisfies Record )[lang]; if (!printCode) { @@ -64,6 +65,7 @@ export function defineTests( `var ${varName} = ${value}`, `console.log(${varName})`, ], + typescript: [null, null], } satisfies Record )[lang]; if (!setIntVarCode || !printIntVarCode) { @@ -92,6 +94,7 @@ export function defineTests( ruby: `raise "${errorMsg}"`, cpp: null, javascript: `throw new Error("${errorMsg}")`, + typescript: null, } satisfies Record )[lang]; if (!errorCode) { @@ -117,6 +120,7 @@ export function defineTests( `while(true) {}`, `console.log(testVar)`, ], + typescript: [null, null, null], } satisfies Record )[lang]; if (!setIntVarCode || !infLoopCode || !printIntVarCode) { @@ -163,12 +167,15 @@ export function defineTests( `#include \nint main() {\n std::cout << "${msg}" << std::endl;\n return 0;\n}\n`, ], javascript: ["test.js", `console.log("${msg}")`], + typescript: ["test.ts", `console.log("${msg}")`], } satisfies Record )[lang]; if (!filename || !code) { this.skip(); } - const result = await runtimeRef.current[lang].runFiles([filename], {[filename]: code}); + const result = await runtimeRef.current[lang].runFiles([filename], { + [filename]: code, + }); console.log(`${lang} single file stdout test: `, result); expect(result).to.be.deep.equal([ { @@ -189,12 +196,16 @@ export function defineTests( `#include \nint main() {\n throw std::runtime_error("${errorMsg}");\n return 0;\n}\n`, ], javascript: ["test_error.js", `throw new Error("${errorMsg}");\n`], + // TODO: tscが出す型エラーのテストはできていない + typescript: ["test_error.ts", `throw new Error("${errorMsg}");\n`], } satisfies Record )[lang]; if (!filename || !code) { this.skip(); } - const result = await runtimeRef.current[lang].runFiles([filename], {[filename]: code}); + const result = await runtimeRef.current[lang].runFiles([filename], { + [filename]: code, + }); console.log(`${lang} single file error capture test: `, result); // eslint-disable-next-line @typescript-eslint/no-unused-expressions expect(result.filter((r) => r.message.includes(errorMsg))).to.not.be @@ -231,6 +242,7 @@ export function defineTests( ["test_multi_main.cpp", "test_multi_sub.cpp"], ], javascript: [null, null], + typescript: [null, null], } satisfies Record< RuntimeLang, [Record, string[]] | [null, null] @@ -239,7 +251,10 @@ export function defineTests( if (!codes || !execFiles) { this.skip(); } - const result = await runtimeRef.current[lang].runFiles(execFiles, codes); + const result = await runtimeRef.current[lang].runFiles( + execFiles, + codes + ); console.log(`${lang} multifile stdout test: `, result); expect(result).to.be.deep.equal([ { From 6b7b28b53ae1684a33dcf69e11b9d08019043653 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Mon, 10 Nov 2025 23:45:21 +0900 Subject: [PATCH 11/13] =?UTF-8?q?ruby=E3=81=AE=E3=83=86=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/terminal/tests.ts | 88 +++++++++++++++++++++++++++++-------------- 1 file changed, 60 insertions(+), 28 deletions(-) diff --git a/app/terminal/tests.ts b/app/terminal/tests.ts index 44acc08..308629b 100644 --- a/app/terminal/tests.ts +++ b/app/terminal/tests.ts @@ -45,12 +45,7 @@ export function defineTests( runtimeRef.current[lang].mutex || emptyMutex ).runExclusive(() => runtimeRef.current[lang].runCommand!(printCode)); console.log(`${lang} REPL stdout test: `, result); - expect(result).to.be.deep.equal([ - { - type: "stdout", - message: msg, - }, - ]); + expect(result).to.be.deep.include({ type: "stdout", message: msg }); }); it("should preserve variables across commands", async function () { @@ -78,12 +73,10 @@ export function defineTests( return runtimeRef.current[lang].runCommand!(printIntVarCode); }); console.log(`${lang} REPL variable preservation test: `, result); - expect(result).to.be.deep.equal([ - { - type: "stdout", - message: value.toString(), - }, - ]); + expect(result).to.be.deep.include({ + type: "stdout", + message: value.toString(), + }); }); it("should capture errors", async function () { @@ -146,12 +139,31 @@ export function defineTests( runtimeRef.current[lang].runCommand!(printIntVarCode) ); console.log(`${lang} REPL interrupt recovery test: `, result); - expect(result).to.be.deep.equal([ + expect(result).to.be.deep.include({ type: "stdout", message: "42" }); + }); + + it("should capture files modified by command", async function () { + const targetFile = "test.txt"; + const msg = "Hello, World!"; + const writeCode = ( { - type: "stdout", - message: "42", - }, - ]); + python: `with open("${targetFile}", "w") as f:\n f.write("${msg}")`, + ruby: `File.open("${targetFile}", "w") {|f| f.write("${msg}") }`, + cpp: null, + javascript: null, + typescript: null, + } satisfies Record + )[lang]; + if (!writeCode) { + this.skip(); + } + const result = await ( + runtimeRef.current[lang].mutex || emptyMutex + ).runExclusive(() => runtimeRef.current[lang].runCommand!(writeCode)); + console.log(`${lang} REPL file modify test: `, result); + // wait for files to be updated + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(filesRef.current[targetFile]).to.equal(msg); }); }); @@ -177,12 +189,7 @@ export function defineTests( [filename]: code, }); console.log(`${lang} single file stdout test: `, result); - expect(result).to.be.deep.equal([ - { - type: "stdout", - message: msg, - }, - ]); + expect(result).to.be.deep.include({ type: "stdout", message: msg }); }); it("should capture errors", async function () { @@ -256,12 +263,37 @@ export function defineTests( codes ); console.log(`${lang} multifile stdout test: `, result); - expect(result).to.be.deep.equal([ + expect(result).to.be.deep.include({ type: "stdout", message: msg }); + }); + + it("should capture files modified by script", async function () { + const targetFile = "test.txt"; + const msg = "Hello, World!"; + const [filename, code] = ( { - type: "stdout", - message: msg, - }, - ]); + python: [ + "test.py", + `with open("${targetFile}", "w") as f:\n f.write("${msg}")`, + ], + ruby: [ + "test.rb", + `File.open("${targetFile}", "w") {|f| f.write("${msg}") }`, + ], + cpp: [null, null], + javascript: [null, null], + typescript: [null, null], + } satisfies Record + )[lang]; + if (!filename || !code) { + this.skip(); + } + const result = await runtimeRef.current[lang].runFiles([filename], { + [filename]: code, + }); + console.log(`${lang} file modify test: `, result); + // wait for files to be updated + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(filesRef.current[targetFile]).to.equal(msg); }); }); }); From d382701441c5a15d25e7533680cdccca4df2789f Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Tue, 11 Nov 2025 00:20:33 +0900 Subject: [PATCH 12/13] =?UTF-8?q?=E5=9E=8B=E3=82=A8=E3=83=A9=E3=83=BC?= =?UTF-8?q?=E3=82=92=E4=BF=AE=E6=AD=A3=E3=80=81=E4=B8=80=E5=BF=9Cself?= =?UTF-8?q?=E3=81=AB=E7=B5=B1=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/terminal/worker/jsEval.worker.ts | 12 +++++++----- app/terminal/worker/pyodide.worker.ts | 7 +++++-- app/terminal/worker/ruby.worker.ts | 7 +++++-- tsconfig.json | 2 +- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/app/terminal/worker/jsEval.worker.ts b/app/terminal/worker/jsEval.worker.ts index f47b2ed..463b5ec 100644 --- a/app/terminal/worker/jsEval.worker.ts +++ b/app/terminal/worker/jsEval.worker.ts @@ -1,11 +1,13 @@ +/// + import type { ReplOutput } from "../repl"; import type { MessageType, WorkerRequest, WorkerResponse } from "./runtime"; let jsOutput: ReplOutput[] = []; // Helper function to capture console output -const originalConsole = globalThis.console; -globalThis.console = { +const originalConsole = self.console; +self.console = { ...originalConsole, // eslint-disable-next-line @typescript-eslint/no-explicit-any log: (...args: any[]) => { @@ -38,7 +40,7 @@ async function runCode({ id, payload }: WorkerRequest["runCode"]) { try { // Execute code directly with eval in the worker global scope // This will preserve variables across calls - const result = globalThis.eval(code); + const result = self.eval(code); if (result !== undefined) { jsOutput.push({ @@ -77,7 +79,7 @@ function runFile({ id, payload }: WorkerRequest["runFile"]) { try { // Execute code directly with eval in the worker global scope // This will preserve variables across calls - globalThis.eval(files[name]); + self.eval(files[name]); } catch (e) { originalConsole.log(e); // TODO: stack trace? @@ -147,7 +149,7 @@ async function restoreState({ id, payload }: WorkerRequest["restoreState"]) { for (const command of commands) { try { - globalThis.eval(command); + self.eval(command); } catch (e) { // If restoration fails, we still continue with other commands originalConsole.error("Failed to restore command:", command, e); diff --git a/app/terminal/worker/pyodide.worker.ts b/app/terminal/worker/pyodide.worker.ts index 0aa5edf..55c4749 100644 --- a/app/terminal/worker/pyodide.worker.ts +++ b/app/terminal/worker/pyodide.worker.ts @@ -1,3 +1,6 @@ +/// +/// + import type { PyodideInterface } from "pyodide"; // import { loadPyodide } from "pyodide"; -> Reading from "node:child_process" is not handled by plugins import { version as pyodideVersion } from "pyodide/package.json"; @@ -32,10 +35,10 @@ function readAllFiles(): Record { async function init({ id, payload }: WorkerRequest["init"]) { const { interruptBuffer } = payload; if (!pyodide) { - (globalThis as WorkerGlobalScope).importScripts(`${PYODIDE_CDN}pyodide.js`); + self.importScripts(`${PYODIDE_CDN}pyodide.js`); // eslint-disable-next-line @typescript-eslint/no-explicit-any - pyodide = await (globalThis as any).loadPyodide({ indexURL: PYODIDE_CDN }); + pyodide = await (self as any).loadPyodide({ indexURL: PYODIDE_CDN }); pyodide.setStdout({ batched: (str: string) => { diff --git a/app/terminal/worker/ruby.worker.ts b/app/terminal/worker/ruby.worker.ts index af39088..9fc396c 100644 --- a/app/terminal/worker/ruby.worker.ts +++ b/app/terminal/worker/ruby.worker.ts @@ -1,3 +1,6 @@ +/// +/// + import { DefaultRubyVM } from "@ruby/wasm-wasi/dist/browser"; import type { RubyVM } from "@ruby/wasm-wasi/dist/vm"; import type { MessageType, WorkerRequest, WorkerResponse } from "./runtime"; @@ -12,12 +15,12 @@ declare global { var stdout: { write: (str: string) => void }; var stderr: { write: (str: string) => void }; } -globalThis.stdout = { +self.stdout = { write(str: string) { stdoutBuffer += str; }, }; -globalThis.stderr = { +self.stderr = { write(str: string) { stderrBuffer += str; }, diff --git a/tsconfig.json b/tsconfig.json index 91a8094..fc693c6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext", "webworker"], + "lib": ["dom", "dom.iterable", "es2023"], "allowJs": true, "skipLibCheck": true, "strict": true, From 6d20622686e3a3dca60c9275462e5e27ea6a15df Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Tue, 11 Nov 2025 00:28:21 +0900 Subject: [PATCH 13/13] =?UTF-8?q?typescript=E3=83=95=E3=82=A1=E3=82=A4?= =?UTF-8?q?=E3=83=AB=E3=81=AF=E3=83=91=E3=82=B9=E3=81=AB=E3=83=90=E3=83=BC?= =?UTF-8?q?=E3=82=B8=E3=83=A7=E3=83=B3=E5=85=A5=E3=82=8C=E3=81=A6=E3=82=8B?= =?UTF-8?q?=E3=81=AE=E3=81=A7immutable=E3=81=A7=E3=81=84=E3=81=84=E3=81=8B?= =?UTF-8?q?=E3=82=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/_headers | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/_headers b/public/_headers index e5f1b5c..133717c 100644 --- a/public/_headers +++ b/public/_headers @@ -3,4 +3,4 @@ Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp /typescript/* - Cache-Control: public,max-age=86400 \ No newline at end of file + Cache-Control: public,max-age=31536000,immutable