<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="author" content="Katie Bell"> <meta name="description" content="Simple REPL for Python WASM"> <title>wasm-python terminal</title> <link rel="stylesheet" href="https://unpkg.com/xterm@4.18.0/css/xterm.css" crossorigin integrity="sha384-4eEEn/eZgVHkElpKAzzPx/Kow/dTSgFk1BNe+uHdjHa+NkZJDh5Vqkq31+y7Eycd"/> <style> body { font-family: arial; max-width: 800px; margin: 0 auto } #code { width: 100%; height: 180px; } #info { padding-top: 20px; } .button-container { display: flex; justify-content: end; height: 50px; align-items: center; gap: 10px; } button { padding: 6px 18px; } </style> <script src="https://unpkg.com/xterm@4.18.0/lib/xterm.js" crossorigin integrity="sha384-yYdNmem1ioP5Onm7RpXutin5A8TimLheLNQ6tnMi01/ZpxXdAwIm2t4fJMx1Djs+"/></script> <script type="module"> class WorkerManager { constructor(workerURL, standardIO, readyCallBack, finishedCallback) { this.workerURL = workerURL this.worker = null this.standardIO = standardIO this.readyCallBack = readyCallBack this.finishedCallback = finishedCallback this.initialiseWorker() } async initialiseWorker() { if (!this.worker) { this.worker = new Worker(this.workerURL) this.worker.addEventListener('message', this.handleMessageFromWorker) } } async run(options) { this.worker.postMessage({ type: 'run', args: options.args || [], files: options.files || {} }) } reset() { if (this.worker) { this.worker.terminate() this.worker = null } this.standardIO.message('Worker process terminated.') this.initialiseWorker() } handleStdinData(inputValue) { if (this.stdinbuffer && this.stdinbufferInt) { let startingIndex = 1 if (this.stdinbufferInt[0] > 0) { startingIndex = this.stdinbufferInt[0] } const data = new TextEncoder().encode(inputValue) data.forEach((value, index) => { this.stdinbufferInt[startingIndex + index] = value }) this.stdinbufferInt[0] = startingIndex + data.length - 1 Atomics.notify(this.stdinbufferInt, 0, 1) } } handleMessageFromWorker = (event) => { const type = event.data.type if (type === 'ready') { this.readyCallBack() } else if (type === 'stdout') { this.standardIO.stdout(event.data.stdout) } else if (type === 'stderr') { this.standardIO.stderr(event.data.stderr) } else if (type === 'stdin') { // Leave it to the terminal to decide whether to chunk it into lines // or send characters depending on the use case. this.stdinbuffer = event.data.buffer this.stdinbufferInt = new Int32Array(this.stdinbuffer) this.standardIO.stdin().then((inputValue) => { this.handleStdinData(inputValue) }) } else if (type === 'finished') { this.standardIO.message(`Exited with status: ${event.data.returnCode}`) this.finishedCallback() } } } class WasmTerminal { constructor() { this.inputBuffer = new BufferQueue(); this.input = '' this.resolveInput = null this.activeInput = false this.inputStartCursor = null this.xterm = new Terminal( { scrollback: 10000, fontSize: 14, theme: { background: '#1a1c1f' }, cols: 100} ); this.xterm.onKey((keyEvent) => { // Fix for iOS Keyboard Jumping on space if (keyEvent.key === " ") { keyEvent.domEvent.preventDefault(); } }); this.xterm.onData(this.handleTermData) } open(container) { this.xterm.open(container); } handleTermData = (data) => { const ord = data.charCodeAt(0); data = data.replace(/\r(?!\n)/g, "\n") // Convert lone CRs to LF // Handle pasted data if (data.length > 1 && data.includes("\n")) { let alreadyWrittenChars = 0; // If line already had data on it, merge pasted data with it if (this.input != '') { this.inputBuffer.addData(this.input); alreadyWrittenChars = this.input.length; this.input = ''; } this.inputBuffer.addData(data); // If input is active, write the first line if (this.activeInput) { let line = this.inputBuffer.nextLine(); this.writeLine(line.slice(alreadyWrittenChars)); this.resolveInput(line); this.activeInput = false; } // When input isn't active, add to line buffer } else if (!this.activeInput) { // Skip non-printable characters if (!(ord === 0x1b || ord == 0x7f || ord < 32)) { this.inputBuffer.addData(data); } // TODO: Handle ANSI escape sequences } else if (ord === 0x1b) { // Handle special characters } else if (ord < 32 || ord === 0x7f) { switch (data) { case "\x0c": // CTRL+L this.clear(); break; case "\n": // ENTER case "\x0a": // CTRL+J case "\x0d": // CTRL+M this.resolveInput(this.input + this.writeLine('\n')); this.input = ''; this.activeInput = false; break; case "\x7F": // BACKSPACE case "\x08": // CTRL+H this.handleCursorErase(true); break; case "\x04": // CTRL+D // Send empty input if (this.input === '') { this.resolveInput('') this.activeInput = false; } } } else { this.handleCursorInsert(data); } } writeLine(line) { this.xterm.write(line.slice(0, -1)) this.xterm.write('\r\n'); return line; } handleCursorInsert(data) { this.input += data; this.xterm.write(data) } handleCursorErase() { // Don't delete past the start of input if (this.xterm.buffer.active.cursorX <= this.inputStartCursor) { return } this.input = this.input.slice(0, -1) this.xterm.write('\x1B[D') this.xterm.write('\x1B[P') } prompt = async () => { this.activeInput = true // Hack to allow stdout/stderr to finish before we figure out where input starts setTimeout(() => {this.inputStartCursor = this.xterm.buffer.active.cursorX}, 1) // If line buffer has a line ready, send it immediately if (this.inputBuffer.hasLineReady()) { return new Promise((resolve, reject) => { resolve(this.writeLine(this.inputBuffer.nextLine())); this.activeInput = false; }) // If line buffer has an incomplete line, use it for the active line } else if (this.inputBuffer.lastLineIsIncomplete()) { // Hack to ensure cursor input start doesn't end up after user input setTimeout(() => {this.handleCursorInsert(this.inputBuffer.nextLine())}, 1); } return new Promise((resolve, reject) => { this.resolveInput = (value) => { resolve(value) } }) } clear() { this.xterm.clear(); } print(charCode) { let array = [charCode]; if (charCode == 10) { array = [13, 10]; // Replace \n with \r\n } this.xterm.write(new Uint8Array(array)); } } class BufferQueue { constructor(xterm) { this.buffer = [] } isEmpty() { return this.buffer.length == 0 } lastLineIsIncomplete() { return !this.isEmpty() && !this.buffer[this.buffer.length-1].endsWith("\n") } hasLineReady() { return !this.isEmpty() && this.buffer[0].endsWith("\n") } addData(data) { let lines = data.match(/.*(\n|$)/g) if (this.lastLineIsIncomplete()) { this.buffer[this.buffer.length-1] += lines.shift() } for (let line of lines) { this.buffer.push(line) } } nextLine() { return this.buffer.shift() } } const runButton = document.getElementById('run') const replButton = document.getElementById('repl') const stopButton = document.getElementById('stop') const clearButton = document.getElementById('clear') const codeBox = document.getElementById('codebox') window.onload = () => { const terminal = new WasmTerminal() terminal.open(document.getElementById('terminal')) const stdio = { stdout: (charCode) => { terminal.print(charCode) }, stderr: (charCode) => { terminal.print(charCode) }, stdin: async () => { return await terminal.prompt() }, message: (text) => { terminal.writeLine(`\r\n${text}\r\n`) }, } const programRunning = (isRunning) => { if (isRunning) { replButton.setAttribute('disabled', true) runButton.setAttribute('disabled', true) stopButton.removeAttribute('disabled') } else { replButton.removeAttribute('disabled') runButton.removeAttribute('disabled') stopButton.setAttribute('disabled', true) } } runButton.addEventListener('click', (e) => { terminal.clear() programRunning(true) const code = codeBox.value pythonWorkerManager.run({args: ['main.py'], files: {'main.py': code}}) }) replButton.addEventListener('click', (e) => { terminal.clear() programRunning(true) // Need to use "-i -" to force interactive mode. // Looks like isatty always returns false in emscripten pythonWorkerManager.run({args: ['-i', '-'], files: {}}) }) stopButton.addEventListener('click', (e) => { programRunning(false) pythonWorkerManager.reset() }) clearButton.addEventListener('click', (e) => { terminal.clear() }) const readyCallback = () => { replButton.removeAttribute('disabled') runButton.removeAttribute('disabled') clearButton.removeAttribute('disabled') } const finishedCallback = () => { programRunning(false) } const pythonWorkerManager = new WorkerManager('./python.worker.js', stdio, readyCallback, finishedCallback) } </script> </head> <body> <h1>Simple REPL for Python WASM</h1> <textarea id="codebox" cols="108" rows="16"> print('Welcome to WASM!') </textarea> <div class="button-container"> <button id="run" disabled>Run</button> <button id="repl" disabled>Start REPL</button> <button id="stop" disabled>Stop</button> <button id="clear" disabled>Clear</button> </div> <div id="terminal"></div> <div id="info"> The simple REPL provides a limited Python experience in the browser. <a href="https://github.com/python/cpython/blob/main/Tools/wasm/README.md"> Tools/wasm/README.md</a> contains a list of known limitations and issues. Networking, subprocesses, and threading are not available. </div> </body> </html>